acty-langchain 0.1.0__tar.gz

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.
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ build/
3
+ dist/
4
+ *.egg-info/
5
+ .pytest_cache/
6
+ __pycache__/
7
+ .coverage
8
+ .mypy_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Konstantin Polev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,125 @@
1
+ Metadata-Version: 2.4
2
+ Name: acty-langchain
3
+ Version: 0.1.0
4
+ Summary: LangChain helpers for acty payloads
5
+ Project-URL: Homepage, https://github.com/conspol/acty-langchain
6
+ Project-URL: Repository, https://github.com/conspol/acty-langchain
7
+ Project-URL: Issues, https://github.com/conspol/acty-langchain/issues
8
+ Maintainer-email: Konstantin Polev <70580603+conspol@users.noreply.github.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: langchain,messages,retry,workflow
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Libraries
19
+ Requires-Python: >=3.12
20
+ Requires-Dist: acty-core<0.2.0,>=0.1.0
21
+ Requires-Dist: langchain-core>=0.3.0
22
+ Requires-Dist: tenacity>=8.2.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: acty<0.2.0,>=0.1.0; extra == 'dev'
25
+ Requires-Dist: build>=1.2.2; extra == 'dev'
26
+ Requires-Dist: pytest-asyncio>=1.3.0; extra == 'dev'
27
+ Requires-Dist: pytest>=9.0.0; extra == 'dev'
28
+ Requires-Dist: twine>=5.1.1; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # acty-langchain
32
+
33
+ `acty-langchain` provides LangChain-specific helpers for the Acty ecosystem:
34
+ message serialization, repair hints for malformed JSON outputs, and runnable
35
+ executor utilities that integrate with `acty`.
36
+
37
+ ## Install
38
+
39
+ Runtime helpers only:
40
+
41
+ ```bash
42
+ pip install acty-langchain
43
+ ```
44
+
45
+ If you want to run LangChain runnables through `ActyEngine`:
46
+
47
+ ```bash
48
+ pip install acty acty-langchain
49
+ ```
50
+
51
+ For local development:
52
+
53
+ ```bash
54
+ pip install -e .[dev]
55
+ ```
56
+
57
+ ## Message Payload Helpers
58
+
59
+ ```python
60
+ from langchain_core.messages import HumanMessage
61
+ from acty_langchain import messages_to_acty_payload, messages_from_acty_payload
62
+
63
+ payload = messages_to_acty_payload(
64
+ [HumanMessage(content="hello")],
65
+ payload={"exec_id": "chat"},
66
+ )
67
+
68
+ roundtrip = messages_from_acty_payload(payload)
69
+ ```
70
+
71
+ These helpers convert LangChain messages into JSON-safe payload structures that
72
+ can be stored, retried, or cached inside Acty workflows.
73
+
74
+ ## Runnable Payload Shape
75
+
76
+ The runnable executor accepts payloads shaped like:
77
+
78
+ ```python
79
+ {
80
+ "runnable": my_chain,
81
+ "input": {"question": "hi"},
82
+ }
83
+ ```
84
+
85
+ Resolver-based execution can also use:
86
+
87
+ ```python
88
+ {
89
+ "exec_id": "qa",
90
+ "input": {"question": "hi"},
91
+ }
92
+ ```
93
+
94
+ ## Runnable Execution
95
+
96
+ ```python
97
+ from acty import ActyEngine, EngineConfig
98
+ from acty_langchain import LangChainRunnableExecutor, runnable_retry_factory
99
+
100
+ engine = ActyEngine(
101
+ executor=LangChainRunnableExecutor(),
102
+ config=EngineConfig(
103
+ attempt_retry_policy=runnable_retry_factory(),
104
+ ),
105
+ )
106
+ ```
107
+
108
+ ## JSON Repair Helpers
109
+
110
+ `acty-langchain` also includes repair utilities for invalid JSON responses:
111
+
112
+ ```python
113
+ from acty_langchain import DefaultPromptedJsonRepairer
114
+
115
+ repairer = DefaultPromptedJsonRepairer(
116
+ system_instruction="Return only valid JSON.",
117
+ repair_request="Please repair the assistant output above.",
118
+ )
119
+ ```
120
+
121
+ ## Development
122
+
123
+ - tests live under `tests/`
124
+ - the package depends on `acty-core` at runtime
125
+ - tests also install `acty` so runnable executor integration can be exercised
@@ -0,0 +1,95 @@
1
+ # acty-langchain
2
+
3
+ `acty-langchain` provides LangChain-specific helpers for the Acty ecosystem:
4
+ message serialization, repair hints for malformed JSON outputs, and runnable
5
+ executor utilities that integrate with `acty`.
6
+
7
+ ## Install
8
+
9
+ Runtime helpers only:
10
+
11
+ ```bash
12
+ pip install acty-langchain
13
+ ```
14
+
15
+ If you want to run LangChain runnables through `ActyEngine`:
16
+
17
+ ```bash
18
+ pip install acty acty-langchain
19
+ ```
20
+
21
+ For local development:
22
+
23
+ ```bash
24
+ pip install -e .[dev]
25
+ ```
26
+
27
+ ## Message Payload Helpers
28
+
29
+ ```python
30
+ from langchain_core.messages import HumanMessage
31
+ from acty_langchain import messages_to_acty_payload, messages_from_acty_payload
32
+
33
+ payload = messages_to_acty_payload(
34
+ [HumanMessage(content="hello")],
35
+ payload={"exec_id": "chat"},
36
+ )
37
+
38
+ roundtrip = messages_from_acty_payload(payload)
39
+ ```
40
+
41
+ These helpers convert LangChain messages into JSON-safe payload structures that
42
+ can be stored, retried, or cached inside Acty workflows.
43
+
44
+ ## Runnable Payload Shape
45
+
46
+ The runnable executor accepts payloads shaped like:
47
+
48
+ ```python
49
+ {
50
+ "runnable": my_chain,
51
+ "input": {"question": "hi"},
52
+ }
53
+ ```
54
+
55
+ Resolver-based execution can also use:
56
+
57
+ ```python
58
+ {
59
+ "exec_id": "qa",
60
+ "input": {"question": "hi"},
61
+ }
62
+ ```
63
+
64
+ ## Runnable Execution
65
+
66
+ ```python
67
+ from acty import ActyEngine, EngineConfig
68
+ from acty_langchain import LangChainRunnableExecutor, runnable_retry_factory
69
+
70
+ engine = ActyEngine(
71
+ executor=LangChainRunnableExecutor(),
72
+ config=EngineConfig(
73
+ attempt_retry_policy=runnable_retry_factory(),
74
+ ),
75
+ )
76
+ ```
77
+
78
+ ## JSON Repair Helpers
79
+
80
+ `acty-langchain` also includes repair utilities for invalid JSON responses:
81
+
82
+ ```python
83
+ from acty_langchain import DefaultPromptedJsonRepairer
84
+
85
+ repairer = DefaultPromptedJsonRepairer(
86
+ system_instruction="Return only valid JSON.",
87
+ repair_request="Please repair the assistant output above.",
88
+ )
89
+ ```
90
+
91
+ ## Development
92
+
93
+ - tests live under `tests/`
94
+ - the package depends on `acty-core` at runtime
95
+ - tests also install `acty` so runnable executor integration can be exercised
@@ -0,0 +1,45 @@
1
+ [project]
2
+ name = "acty-langchain"
3
+ version = "0.1.0"
4
+ description = "LangChain helpers for acty payloads"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ maintainers = [{ name = "Konstantin Polev", email = "70580603+conspol@users.noreply.github.com" }]
10
+ keywords = ["langchain", "messages", "retry", "workflow"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Topic :: Software Development :: Libraries",
19
+ ]
20
+ dependencies = [
21
+ "acty-core>=0.1.0,<0.2.0",
22
+ "langchain-core>=0.3.0",
23
+ "tenacity>=8.2.0",
24
+ ]
25
+
26
+ [project.optional-dependencies]
27
+ dev = [
28
+ "acty>=0.1.0,<0.2.0",
29
+ "build>=1.2.2",
30
+ "pytest>=9.0.0",
31
+ "pytest-asyncio>=1.3.0",
32
+ "twine>=5.1.1",
33
+ ]
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/conspol/acty-langchain"
37
+ Repository = "https://github.com/conspol/acty-langchain"
38
+ Issues = "https://github.com/conspol/acty-langchain/issues"
39
+
40
+ [build-system]
41
+ requires = ["hatchling"]
42
+ build-backend = "hatchling.build"
43
+
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["src/acty_langchain"]
@@ -0,0 +1,4 @@
1
+ [pytest]
2
+ testpaths = tests
3
+ pythonpath = src
4
+ asyncio_mode = auto
@@ -0,0 +1,35 @@
1
+ """Helpers for LangChain integration with acty."""
2
+
3
+ from .executor import (
4
+ LangChainRunnableExecutor,
5
+ make_langchain_executor,
6
+ runnable_retry_factory,
7
+ runnable_retry_payload_on_retry,
8
+ )
9
+ from .messages import (
10
+ from_acty_messages,
11
+ messages_from_acty_payload,
12
+ messages_to_acty_payload,
13
+ to_acty_messages,
14
+ )
15
+ from .repair_hints import format_json_parse_error, summarize_validation_errors
16
+ from .repairer import DefaultPromptedJsonRepairer, PromptedJsonRepairer
17
+ from .runnable import RetryPayloadFn, select_runnable_payload, validate_runnable_payload
18
+
19
+ __all__ = [
20
+ "LangChainRunnableExecutor",
21
+ "make_langchain_executor",
22
+ "runnable_retry_factory",
23
+ "runnable_retry_payload_on_retry",
24
+ "from_acty_messages",
25
+ "messages_from_acty_payload",
26
+ "messages_to_acty_payload",
27
+ "to_acty_messages",
28
+ "format_json_parse_error",
29
+ "summarize_validation_errors",
30
+ "DefaultPromptedJsonRepairer",
31
+ "PromptedJsonRepairer",
32
+ "RetryPayloadFn",
33
+ "select_runnable_payload",
34
+ "validate_runnable_payload",
35
+ ]
@@ -0,0 +1,271 @@
1
+ """LangChain runnable executor for acty payloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ from typing import Any, Awaitable, Callable, Mapping, TYPE_CHECKING
8
+
9
+ from tenacity import AsyncRetrying, RetryCallState, stop_after_attempt
10
+
11
+ from acty_core.core.types import Job, JobResult
12
+ from acty_core.lifecycle import GroupLifecycleController
13
+
14
+ from .runnable import validate_runnable_payload
15
+
16
+ if TYPE_CHECKING:
17
+ from acty import ExecResolver
18
+ from acty_core.scheduler import TaggedExecutor
19
+
20
+ RetryPayloadUpdate = Mapping[str, Any] | None
21
+ RetryFactory = Callable[[Job], AsyncRetrying | Awaitable[AsyncRetrying]]
22
+ # Callable registries may accept (exec_id) or (exec_id, job).
23
+ RunnableRegistry = Mapping[str, Any] | Callable[[str], Any] | Callable[[str, Job], Any]
24
+
25
+
26
+ def _build_registry_resolver(registry: RunnableRegistry):
27
+ """Build an ExecResolver for mapping or callable registries.
28
+
29
+ Callable registries may accept (exec_id) or (exec_id, job).
30
+ """
31
+ try:
32
+ from acty import CallableExecResolver, MappingExecResolver
33
+ except ImportError as exc: # pragma: no cover - optional dependency
34
+ raise ImportError("make_langchain_executor requires acty to be installed") from exc
35
+ if callable(registry):
36
+ def _resolve(exec_id: str, job: Job) -> Any:
37
+ try:
38
+ sig = inspect.signature(registry)
39
+ except (TypeError, ValueError):
40
+ return registry(exec_id)
41
+ params = list(sig.parameters.values())
42
+ job_param = next((param for param in params if param.name == "job"), None)
43
+ if job_param is not None:
44
+ if job_param.kind == inspect.Parameter.POSITIONAL_ONLY:
45
+ return registry(exec_id, job)
46
+ return registry(exec_id, job=job)
47
+ has_var_positional = any(param.kind == inspect.Parameter.VAR_POSITIONAL for param in params)
48
+ positional = [
49
+ param
50
+ for param in params
51
+ if param.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
52
+ ]
53
+ if has_var_positional or len(positional) >= 2:
54
+ return registry(exec_id, job)
55
+ return registry(exec_id)
56
+
57
+ return CallableExecResolver(_resolve)
58
+ return MappingExecResolver(registry)
59
+
60
+
61
+ def make_langchain_executor(
62
+ *,
63
+ registry: RunnableRegistry | None = None,
64
+ resolver: "ExecResolver" | None = None,
65
+ allow_unwrapped_retry: bool = False,
66
+ **executor_kwargs: Any,
67
+ ) -> "TaggedExecutor":
68
+ """Return a ready-to-use LangChain executor, optionally wrapped with a resolver."""
69
+
70
+ if resolver is not None and registry is not None:
71
+ raise ValueError("Pass only one of resolver or registry")
72
+ if "runnable_registry" in executor_kwargs:
73
+ raise ValueError("Use registry/resolver with make_langchain_executor, not runnable_registry")
74
+
75
+ base = LangChainRunnableExecutor(
76
+ allow_unwrapped_retry=allow_unwrapped_retry,
77
+ **executor_kwargs,
78
+ )
79
+
80
+ if resolver is None and registry is not None:
81
+ resolver = _build_registry_resolver(registry)
82
+ if resolver is None:
83
+ return base
84
+ try:
85
+ from acty import ResolverExecutor
86
+ except ImportError as exc: # pragma: no cover - optional dependency
87
+ raise ImportError("make_langchain_executor requires acty to be installed") from exc
88
+ return ResolverExecutor(base, resolver)
89
+
90
+
91
+ class LangChainRunnableExecutor:
92
+ """Execute LangChain runnables from acty job payloads."""
93
+
94
+ handles_lifecycle = False
95
+ supports_executor_retry = False
96
+
97
+ def __init__(
98
+ self,
99
+ controller: GroupLifecycleController | None = None,
100
+ *,
101
+ allow_unwrapped_retry: bool = False,
102
+ ) -> None:
103
+ self._controller = controller
104
+ self._allow_unwrapped_retry = allow_unwrapped_retry
105
+
106
+ @classmethod
107
+ def with_registry(
108
+ cls,
109
+ registry: RunnableRegistry,
110
+ *,
111
+ resolver: "ExecResolver" | None = None,
112
+ allow_unwrapped_retry: bool = False,
113
+ **executor_kwargs: Any,
114
+ ) -> "TaggedExecutor":
115
+ """Build an executor wired to a registry via make_langchain_executor."""
116
+ return make_langchain_executor(
117
+ registry=registry,
118
+ resolver=resolver,
119
+ allow_unwrapped_retry=allow_unwrapped_retry,
120
+ **executor_kwargs,
121
+ )
122
+
123
+ def bind(self, controller: GroupLifecycleController) -> None:
124
+ self._controller = controller
125
+
126
+ async def execute(self, job: Job, *, pool: str) -> JobResult:
127
+ call_payload = validate_runnable_payload(job.payload)
128
+ if "runnable" not in call_payload:
129
+ raise RuntimeError(
130
+ "exec_id payloads require ResolverExecutor (use make_langchain_executor)"
131
+ )
132
+ retry = call_payload.get("retry")
133
+ if retry is not None and not self._allow_unwrapped_retry:
134
+ if not self.supports_executor_retry:
135
+ message = (
136
+ "Payload retry requires EngineConfig.attempt_retry_policy "
137
+ "(use runnable_retry_factory())"
138
+ )
139
+ if getattr(self, "_wrapper_forwarding_required", False):
140
+ message = f"{message}; executor wrappers must forward supports_executor_retry"
141
+ raise RuntimeError(message)
142
+ output = await _invoke_runnable(call_payload)
143
+ return JobResult(
144
+ job_id=job.id,
145
+ kind=job.kind,
146
+ ok=True,
147
+ output=_wrap_output(output),
148
+ group_id=job.group_id,
149
+ follower_id=job.follower_id,
150
+ )
151
+
152
+ async def retry_payload_on_retry(self, retry_state: RetryCallState, job: Job) -> RetryPayloadUpdate:
153
+ """Apply retry_payload_fn to update payloads between retries."""
154
+ return await _runnable_retry_payload_on_retry(retry_state, job.payload, job.payload)
155
+
156
+
157
+ def runnable_retry_factory(
158
+ *,
159
+ default_retry: AsyncRetrying | None = None,
160
+ resolver: "ExecResolver" | None = None,
161
+ ) -> RetryFactory:
162
+ """Return a tenacity retry factory that prefers per-job payload retry.
163
+
164
+ Returns a fresh AsyncRetrying instance per job by copying the selected retry
165
+ object. This isolates retry state across concurrent jobs, even if the caller
166
+ passes a shared instance.
167
+ """
168
+
169
+ if default_retry is None:
170
+ default_retry = AsyncRetrying(stop=stop_after_attempt(1))
171
+ if not isinstance(default_retry, AsyncRetrying):
172
+ raise ValueError("default_retry must be a tenacity.AsyncRetrying instance")
173
+
174
+ async def _factory_async(job: Job) -> AsyncRetrying:
175
+ try:
176
+ from acty import resolve_payload_with_exec_id
177
+ except ImportError as exc: # pragma: no cover - optional dependency
178
+ raise ImportError("runnable_retry_factory resolver support requires acty to be installed") from exc
179
+
180
+ payload = resolve_payload_with_exec_id(job.payload, resolver, job=job)
181
+ if inspect.isawaitable(payload):
182
+ payload = await payload
183
+ call_payload = validate_runnable_payload(payload)
184
+ if "runnable" not in call_payload:
185
+ raise RuntimeError("exec_id payloads require ResolverExecutor to inject a runnable")
186
+ retry = call_payload.get("retry") or default_retry
187
+ if not isinstance(retry, AsyncRetrying):
188
+ raise ValueError("retry must be a tenacity.AsyncRetrying instance")
189
+ return retry.copy()
190
+
191
+ def _factory(job: Job) -> AsyncRetrying:
192
+ call_payload = validate_runnable_payload(job.payload)
193
+ if "runnable" not in call_payload:
194
+ raise RuntimeError("exec_id payloads require resolver in runnable_retry_factory")
195
+ retry = call_payload.get("retry") or default_retry
196
+ if not isinstance(retry, AsyncRetrying):
197
+ raise ValueError("retry must be a tenacity.AsyncRetrying instance")
198
+ return retry.copy()
199
+
200
+ return _factory_async if resolver is not None else _factory
201
+
202
+
203
+ async def runnable_retry_payload_on_retry(retry_state: RetryCallState, job: Job) -> RetryPayloadUpdate:
204
+ """Apply payload retry_payload_fn to update payloads between retries."""
205
+ return await _runnable_retry_payload_on_retry(retry_state, job.payload, job.payload)
206
+
207
+
208
+ async def _runnable_retry_payload_on_retry(
209
+ retry_state: RetryCallState,
210
+ job_payload: Mapping[str, Any] | Any,
211
+ call_payload: Mapping[str, Any] | Any,
212
+ ) -> RetryPayloadUpdate:
213
+ """Internal helper that allows resolving payloads separately from stored metadata."""
214
+
215
+ outcome = retry_state.outcome
216
+ if outcome is None or not outcome.failed:
217
+ return None
218
+ exc = outcome.exception()
219
+ if exc is None or not isinstance(exc, Exception):
220
+ return None
221
+
222
+ call_payload_map = validate_runnable_payload(call_payload)
223
+ retry_payload_fn = call_payload_map.get("retry_payload_fn")
224
+ if retry_payload_fn is None:
225
+ return None
226
+
227
+ maybe_new = retry_payload_fn(retry_state.attempt_number, exc, call_payload_map)
228
+ if inspect.isawaitable(maybe_new):
229
+ maybe_new = await maybe_new
230
+ if maybe_new is None:
231
+ return None
232
+ if not isinstance(maybe_new, Mapping):
233
+ raise ValueError("retry_payload_fn must return a mapping")
234
+
235
+ new_payload = dict(maybe_new)
236
+ if "retry" not in new_payload and call_payload_map.get("retry") is not None:
237
+ new_payload["retry"] = call_payload_map.get("retry")
238
+ if "retry_payload_fn" not in new_payload and call_payload_map.get("retry_payload_fn") is not None:
239
+ new_payload["retry_payload_fn"] = call_payload_map.get("retry_payload_fn")
240
+
241
+ if isinstance(job_payload, Mapping) and "runnable" not in job_payload and "payload" in job_payload:
242
+ merged = dict(job_payload)
243
+ merged["payload"] = new_payload
244
+ validate_runnable_payload(merged)
245
+ return merged
246
+ if isinstance(job_payload, Mapping) and "exec_id" in job_payload and "exec_id" not in new_payload:
247
+ merged = dict(new_payload)
248
+ merged["exec_id"] = job_payload["exec_id"]
249
+ validate_runnable_payload(merged)
250
+ return merged
251
+ validate_runnable_payload(new_payload)
252
+ return new_payload
253
+
254
+
255
+ async def _invoke_runnable(call_payload: Mapping[str, Any]) -> Any:
256
+ if "runnable" not in call_payload:
257
+ raise ValueError("Runnable payload must include 'runnable'")
258
+ runnable = call_payload["runnable"]
259
+ input_payload = call_payload["input"]
260
+ config = call_payload.get("config")
261
+ if hasattr(runnable, "ainvoke"):
262
+ return await runnable.ainvoke(input_payload, config=config)
263
+ if hasattr(runnable, "invoke"):
264
+ return await asyncio.to_thread(runnable.invoke, input_payload, config=config)
265
+ raise ValueError("Runnable does not support invoke/ainvoke")
266
+
267
+
268
+ def _wrap_output(output: Any) -> Mapping[str, Any]:
269
+ if isinstance(output, Mapping):
270
+ return output
271
+ return {"output": output}
@@ -0,0 +1,82 @@
1
+ """LangChain message serialization helpers for acty payloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Iterable, Mapping, Sequence
6
+
7
+ _LANGCHAIN_CACHE: tuple[type, Any, Any] | None = None
8
+
9
+
10
+ def _load_langchain():
11
+ global _LANGCHAIN_CACHE
12
+ if _LANGCHAIN_CACHE is not None:
13
+ return _LANGCHAIN_CACHE
14
+ try:
15
+ from langchain_core.messages import BaseMessage, messages_from_dict, messages_to_dict
16
+ except ImportError as exc: # pragma: no cover - dependency is optional
17
+ raise ImportError(
18
+ "acty-langchain requires langchain-core to be installed"
19
+ ) from exc
20
+ _LANGCHAIN_CACHE = (BaseMessage, messages_from_dict, messages_to_dict)
21
+ return _LANGCHAIN_CACHE
22
+
23
+
24
+ def _is_serialized_message(item: Mapping[str, Any]) -> bool:
25
+ return "type" in item and "data" in item
26
+
27
+
28
+ def to_acty_messages(
29
+ messages: Iterable[Any] | Sequence[Mapping[str, Any]],
30
+ ) -> list[dict[str, Any]]:
31
+ """Convert LangChain messages to JSON-safe dicts for acty payloads.
32
+
33
+ Accepts either BaseMessage objects or already-serialized dicts produced
34
+ by langchain_core.messages.messages_to_dict.
35
+ """
36
+ items = list(messages)
37
+ if not items:
38
+ return []
39
+
40
+ if isinstance(items[0], Mapping):
41
+ if not all(isinstance(m, Mapping) and _is_serialized_message(m) for m in items):
42
+ raise ValueError("Serialized messages must be dicts with 'type' and 'data'")
43
+ return [dict(m) for m in items]
44
+
45
+ BaseMessage, _messages_from_dict, messages_to_dict = _load_langchain()
46
+ if not all(isinstance(m, BaseMessage) for m in items):
47
+ raise ValueError("Expected BaseMessage objects or serialized dicts")
48
+ return messages_to_dict(items)
49
+
50
+
51
+ def from_acty_messages(messages: Iterable[Mapping[str, Any]]) -> list[Any]:
52
+ """Convert serialized LangChain message dicts back to BaseMessage objects."""
53
+ items = list(messages)
54
+ if not items:
55
+ return []
56
+ if not all(isinstance(m, Mapping) and _is_serialized_message(m) for m in items):
57
+ raise ValueError("Serialized messages must be dicts with 'type' and 'data'")
58
+ BaseMessage, messages_from_dict, _messages_to_dict = _load_langchain()
59
+ return messages_from_dict(items)
60
+
61
+
62
+ def messages_to_acty_payload(
63
+ messages: Iterable[Any] | Sequence[Mapping[str, Any]],
64
+ *,
65
+ key: str = "messages",
66
+ payload: Mapping[str, Any] | None = None,
67
+ ) -> dict[str, Any]:
68
+ """Attach serialized LangChain messages under payload[key]."""
69
+ base = dict(payload or {})
70
+ base[key] = to_acty_messages(messages)
71
+ return base
72
+
73
+
74
+ def messages_from_acty_payload(
75
+ payload: Mapping[str, Any],
76
+ *,
77
+ key: str = "messages",
78
+ ) -> list[Any]:
79
+ """Extract LangChain messages from payload[key]."""
80
+ if key not in payload:
81
+ raise KeyError(f"payload missing '{key}'")
82
+ return from_acty_messages(payload[key])