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.
- acty_langchain-0.1.0/.gitignore +8 -0
- acty_langchain-0.1.0/LICENSE +21 -0
- acty_langchain-0.1.0/PKG-INFO +125 -0
- acty_langchain-0.1.0/README.md +95 -0
- acty_langchain-0.1.0/pyproject.toml +45 -0
- acty_langchain-0.1.0/pytest.ini +4 -0
- acty_langchain-0.1.0/src/acty_langchain/__init__.py +35 -0
- acty_langchain-0.1.0/src/acty_langchain/executor.py +271 -0
- acty_langchain-0.1.0/src/acty_langchain/messages.py +82 -0
- acty_langchain-0.1.0/src/acty_langchain/repair_hints.py +394 -0
- acty_langchain-0.1.0/src/acty_langchain/repairer.py +77 -0
- acty_langchain-0.1.0/src/acty_langchain/runnable.py +35 -0
- acty_langchain-0.1.0/tests/test_langchain_integration.py +283 -0
- acty_langchain-0.1.0/tests/test_message_helpers.py +60 -0
- acty_langchain-0.1.0/tests/test_repair_hints.py +32 -0
- acty_langchain-0.1.0/tests/test_repairer.py +63 -0
- acty_langchain-0.1.0/tests/test_runnable_executor.py +777 -0
|
@@ -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,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])
|