usegradient 1.3.1__py3-none-any.whl
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.
- gradient_sdk/__init__.py +122 -0
- gradient_sdk/agent.py +368 -0
- gradient_sdk/client.py +1059 -0
- gradient_sdk/context.py +91 -0
- gradient_sdk/credentials.py +92 -0
- gradient_sdk/decorators.py +202 -0
- gradient_sdk/errors.py +30 -0
- gradient_sdk/graph.py +190 -0
- gradient_sdk/integrations/__init__.py +7 -0
- gradient_sdk/integrations/agent.py +263 -0
- gradient_sdk/integrations/anthropic.py +51 -0
- gradient_sdk/integrations/openai.py +105 -0
- gradient_sdk/model_catalog.py +75 -0
- gradient_sdk/models.py +370 -0
- gradient_sdk/py.typed +1 -0
- gradient_sdk/runtime.py +1474 -0
- gradient_sdk/session.py +132 -0
- gradient_sdk/tools.py +156 -0
- gradient_sdk/tracing.py +361 -0
- usegradient-1.3.1.dist-info/METADATA +235 -0
- usegradient-1.3.1.dist-info/RECORD +22 -0
- usegradient-1.3.1.dist-info/WHEEL +4 -0
gradient_sdk/context.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
import time
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from contextvars import ContextVar
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Iterator, Mapping
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(slots=True)
|
|
12
|
+
class RunContext:
|
|
13
|
+
project: str = "gradient"
|
|
14
|
+
run_id: str = field(default_factory=lambda: "local-" + secrets.token_hex(8))
|
|
15
|
+
session_id: str | None = None
|
|
16
|
+
user_id: str | None = None
|
|
17
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
18
|
+
tags: list[str] = field(default_factory=list)
|
|
19
|
+
secrets: dict[str, str] = field(default_factory=dict, repr=False)
|
|
20
|
+
trace: Any | None = None
|
|
21
|
+
timeout_seconds: float | None = None
|
|
22
|
+
replay_config: dict[str, Any] = field(default_factory=dict)
|
|
23
|
+
environment: dict[str, Any] = field(default_factory=dict)
|
|
24
|
+
cancelled: bool = False
|
|
25
|
+
started_at: float = field(default_factory=time.time)
|
|
26
|
+
|
|
27
|
+
def child(self, **updates: Any) -> "RunContext":
|
|
28
|
+
values = {
|
|
29
|
+
"project": self.project,
|
|
30
|
+
"run_id": self.run_id,
|
|
31
|
+
"session_id": self.session_id,
|
|
32
|
+
"user_id": self.user_id,
|
|
33
|
+
"metadata": dict(self.metadata),
|
|
34
|
+
"tags": list(self.tags),
|
|
35
|
+
"secrets": dict(self.secrets),
|
|
36
|
+
"trace": self.trace,
|
|
37
|
+
"timeout_seconds": self.timeout_seconds,
|
|
38
|
+
"replay_config": dict(self.replay_config),
|
|
39
|
+
"environment": dict(self.environment),
|
|
40
|
+
"cancelled": self.cancelled,
|
|
41
|
+
"started_at": self.started_at,
|
|
42
|
+
}
|
|
43
|
+
values.update(updates)
|
|
44
|
+
return RunContext(**values)
|
|
45
|
+
|
|
46
|
+
def check_cancelled(self) -> None:
|
|
47
|
+
if self.cancelled:
|
|
48
|
+
raise RuntimeError("Gradient run was cancelled")
|
|
49
|
+
|
|
50
|
+
def to_event(self) -> dict[str, Any]:
|
|
51
|
+
return {
|
|
52
|
+
"project": self.project,
|
|
53
|
+
"run_id": self.run_id,
|
|
54
|
+
"session_id": self.session_id,
|
|
55
|
+
"user_id": self.user_id,
|
|
56
|
+
"metadata": dict(self.metadata),
|
|
57
|
+
"tags": list(self.tags),
|
|
58
|
+
"replay_config": dict(self.replay_config),
|
|
59
|
+
"environment": dict(self.environment),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def from_value(
|
|
64
|
+
cls,
|
|
65
|
+
value: "RunContext | Mapping[str, Any] | None" = None,
|
|
66
|
+
**defaults: Any,
|
|
67
|
+
) -> "RunContext":
|
|
68
|
+
if isinstance(value, RunContext):
|
|
69
|
+
return value.child(**defaults) if defaults else value
|
|
70
|
+
payload = dict(value or {})
|
|
71
|
+
payload.update({key: item for key, item in defaults.items() if item is not None})
|
|
72
|
+
return RunContext(**payload)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_current_context: ContextVar[RunContext | None] = ContextVar(
|
|
76
|
+
"gradient_run_context",
|
|
77
|
+
default=None,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def current_context() -> RunContext | None:
|
|
82
|
+
return _current_context.get()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@contextmanager
|
|
86
|
+
def use_context(context: RunContext) -> Iterator[RunContext]:
|
|
87
|
+
token = _current_context.set(context)
|
|
88
|
+
try:
|
|
89
|
+
yield context
|
|
90
|
+
finally:
|
|
91
|
+
_current_context.reset(token)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Mapping
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import tomllib
|
|
9
|
+
|
|
10
|
+
from .errors import GradientAuthError
|
|
11
|
+
|
|
12
|
+
DEFAULT_API_URL = "https://console.usegradient.dev"
|
|
13
|
+
DEFAULT_REGISTRY_URL = "https://registry.usegradient.dev"
|
|
14
|
+
DEFAULT_PROXY_URL = "https://proxy.usegradient.dev"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(slots=True)
|
|
18
|
+
class Credentials:
|
|
19
|
+
api_key: str = ""
|
|
20
|
+
api_url: str = DEFAULT_API_URL
|
|
21
|
+
registry_url: str = DEFAULT_REGISTRY_URL
|
|
22
|
+
proxy_url: str = DEFAULT_PROXY_URL
|
|
23
|
+
slug: str = ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _credentials_path(home: str | Path | None = None) -> Path:
|
|
27
|
+
root = Path(home).expanduser() if home else Path.home()
|
|
28
|
+
return root / ".gradient" / "credentials"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _read_credentials_file(path: Path) -> Credentials:
|
|
32
|
+
if not path.exists():
|
|
33
|
+
return Credentials()
|
|
34
|
+
with path.open("rb") as f:
|
|
35
|
+
raw = tomllib.load(f)
|
|
36
|
+
return Credentials(
|
|
37
|
+
api_key=str(raw.get("api_key", "")),
|
|
38
|
+
api_url=str(raw.get("api_url", "") or DEFAULT_API_URL),
|
|
39
|
+
registry_url=str(raw.get("registry_url", "") or DEFAULT_REGISTRY_URL),
|
|
40
|
+
proxy_url=str(raw.get("proxy_url", "") or DEFAULT_PROXY_URL),
|
|
41
|
+
slug=str(raw.get("slug", "")),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_credentials(
|
|
46
|
+
*,
|
|
47
|
+
path: str | Path | None = None,
|
|
48
|
+
env: Mapping[str, str] | None = None,
|
|
49
|
+
home: str | Path | None = None,
|
|
50
|
+
require_api_key: bool = False,
|
|
51
|
+
) -> Credentials:
|
|
52
|
+
"""Load Gradient credentials from disk and environment variables."""
|
|
53
|
+
|
|
54
|
+
values = _read_credentials_file(Path(path).expanduser() if path else _credentials_path(home))
|
|
55
|
+
environ = os.environ if env is None else env
|
|
56
|
+
|
|
57
|
+
if environ.get("GRADIENT_API_KEY"):
|
|
58
|
+
values.api_key = environ["GRADIENT_API_KEY"]
|
|
59
|
+
if environ.get("GRADIENT_API_URL"):
|
|
60
|
+
values.api_url = environ["GRADIENT_API_URL"]
|
|
61
|
+
if environ.get("GRADIENT_REGISTRY_URL"):
|
|
62
|
+
values.registry_url = environ["GRADIENT_REGISTRY_URL"]
|
|
63
|
+
if environ.get("GRADIENT_PROXY_URL"):
|
|
64
|
+
values.proxy_url = environ["GRADIENT_PROXY_URL"]
|
|
65
|
+
|
|
66
|
+
values.api_url = (values.api_url or DEFAULT_API_URL).rstrip("/")
|
|
67
|
+
values.registry_url = (values.registry_url or DEFAULT_REGISTRY_URL).rstrip("/")
|
|
68
|
+
values.proxy_url = (values.proxy_url or DEFAULT_PROXY_URL).rstrip("/")
|
|
69
|
+
|
|
70
|
+
if require_api_key and not values.api_key:
|
|
71
|
+
raise GradientAuthError(
|
|
72
|
+
"not logged in. Set GRADIENT_API_KEY or run `gradient login --api-key grad_live_...`."
|
|
73
|
+
)
|
|
74
|
+
return values
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def save_credentials(credentials: Credentials, *, path: str | Path | None = None) -> Path:
|
|
78
|
+
"""Write credentials in the same TOML format used by the Gradient CLI."""
|
|
79
|
+
|
|
80
|
+
target = Path(path).expanduser() if path else _credentials_path()
|
|
81
|
+
target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
82
|
+
lines = [
|
|
83
|
+
f"api_key = {json.dumps(credentials.api_key)}",
|
|
84
|
+
f"api_url = {json.dumps(credentials.api_url.rstrip('/'))}",
|
|
85
|
+
f"registry_url = {json.dumps(credentials.registry_url)}",
|
|
86
|
+
f"proxy_url = {json.dumps(credentials.proxy_url)}",
|
|
87
|
+
f"slug = {json.dumps(credentials.slug)}",
|
|
88
|
+
"",
|
|
89
|
+
]
|
|
90
|
+
target.write_text("\n".join(lines), encoding="utf-8")
|
|
91
|
+
target.chmod(0o600)
|
|
92
|
+
return target
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import inspect
|
|
5
|
+
import json
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from typing import Any, Callable, TypeVar
|
|
8
|
+
|
|
9
|
+
from .tools import tool_from_function
|
|
10
|
+
from .tracing import get_tracer
|
|
11
|
+
|
|
12
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _serialize_call_args(fn: Callable[..., Any], args: tuple[Any, ...], kwargs: dict[str, Any]) -> str:
|
|
16
|
+
signature = inspect.signature(fn)
|
|
17
|
+
bound = signature.bind_partial(*args, **kwargs)
|
|
18
|
+
bound.apply_defaults()
|
|
19
|
+
payload = {key: value for key, value in bound.arguments.items() if key != "self"}
|
|
20
|
+
return json.dumps(payload, default=str)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _wrap_callable(
|
|
24
|
+
fn: F,
|
|
25
|
+
*,
|
|
26
|
+
name: str | None,
|
|
27
|
+
kind: str,
|
|
28
|
+
extra_attributes: dict[str, Any] | None = None,
|
|
29
|
+
register_tool: bool = False,
|
|
30
|
+
description: str | None = None,
|
|
31
|
+
parameters: dict[str, Any] | None = None,
|
|
32
|
+
) -> F:
|
|
33
|
+
span_name = name or fn.__name__
|
|
34
|
+
tool_obj = tool_from_function(fn, name=span_name, description=description, parameters=parameters) if register_tool else None
|
|
35
|
+
|
|
36
|
+
if inspect.iscoroutinefunction(fn):
|
|
37
|
+
|
|
38
|
+
@functools.wraps(fn)
|
|
39
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
40
|
+
tracer = get_tracer()
|
|
41
|
+
with tracer.start_span(span_name, kind=kind, attributes=extra_attributes or {}) as span:
|
|
42
|
+
span.set_attribute("input.value", _serialize_call_args(fn, args, kwargs))
|
|
43
|
+
try:
|
|
44
|
+
result = await fn(*args, **kwargs)
|
|
45
|
+
span.set_output(result)
|
|
46
|
+
return result
|
|
47
|
+
except Exception as exc:
|
|
48
|
+
span.record_exception(exc)
|
|
49
|
+
raise
|
|
50
|
+
|
|
51
|
+
if tool_obj is not None:
|
|
52
|
+
async_wrapper.gradient_tool = tool_obj # type: ignore[attr-defined]
|
|
53
|
+
return async_wrapper # type: ignore[return-value]
|
|
54
|
+
|
|
55
|
+
@functools.wraps(fn)
|
|
56
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
57
|
+
tracer = get_tracer()
|
|
58
|
+
with tracer.start_span(span_name, kind=kind, attributes=extra_attributes or {}) as span:
|
|
59
|
+
span.set_attribute("input.value", _serialize_call_args(fn, args, kwargs))
|
|
60
|
+
try:
|
|
61
|
+
result = fn(*args, **kwargs)
|
|
62
|
+
span.set_output(result)
|
|
63
|
+
return result
|
|
64
|
+
except Exception as exc:
|
|
65
|
+
span.record_exception(exc)
|
|
66
|
+
raise
|
|
67
|
+
|
|
68
|
+
if tool_obj is not None:
|
|
69
|
+
sync_wrapper.gradient_tool = tool_obj # type: ignore[attr-defined]
|
|
70
|
+
return sync_wrapper # type: ignore[return-value]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def tool(
|
|
74
|
+
fn: F | None = None,
|
|
75
|
+
*,
|
|
76
|
+
name: str | None = None,
|
|
77
|
+
description: str | None = None,
|
|
78
|
+
parameters: dict[str, Any] | None = None,
|
|
79
|
+
) -> Callable[[F], F] | F:
|
|
80
|
+
def decorator(inner: F) -> F:
|
|
81
|
+
return _wrap_callable(
|
|
82
|
+
inner,
|
|
83
|
+
name=name or inner.__name__,
|
|
84
|
+
kind="TOOL",
|
|
85
|
+
register_tool=True,
|
|
86
|
+
description=description,
|
|
87
|
+
parameters=parameters,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if fn is not None:
|
|
91
|
+
return decorator(fn)
|
|
92
|
+
return decorator
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def llm(
|
|
96
|
+
fn: F | None = None,
|
|
97
|
+
*,
|
|
98
|
+
name: str | None = None,
|
|
99
|
+
provider: str | None = None,
|
|
100
|
+
model: str | None = None,
|
|
101
|
+
) -> Callable[[F], F] | F:
|
|
102
|
+
attrs: dict[str, Any] = {}
|
|
103
|
+
if provider:
|
|
104
|
+
attrs["llm.provider"] = provider
|
|
105
|
+
if model:
|
|
106
|
+
attrs["llm.model_name"] = model
|
|
107
|
+
|
|
108
|
+
def decorator(inner: F) -> F:
|
|
109
|
+
return _wrap_callable(inner, name=name or inner.__name__, kind="LLM", extra_attributes=attrs)
|
|
110
|
+
|
|
111
|
+
if fn is not None:
|
|
112
|
+
return decorator(fn)
|
|
113
|
+
return decorator
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def chain(fn: F | None = None, *, name: str | None = None) -> Callable[[F], F] | F:
|
|
117
|
+
def decorator(inner: F) -> F:
|
|
118
|
+
return _wrap_callable(inner, name=name or inner.__name__, kind="CHAIN")
|
|
119
|
+
|
|
120
|
+
if fn is not None:
|
|
121
|
+
return decorator(fn)
|
|
122
|
+
return decorator
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def task(fn: F | None = None, *, name: str | None = None) -> Callable[[F], F] | F:
|
|
126
|
+
def decorator(inner: F) -> F:
|
|
127
|
+
return _wrap_callable(inner, name=name or inner.__name__, kind="CHAIN")
|
|
128
|
+
|
|
129
|
+
if fn is not None:
|
|
130
|
+
return decorator(fn)
|
|
131
|
+
return decorator
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
workflow = chain
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def agent(fn: F | None = None, *, name: str | None = None) -> Callable[[F], F] | F:
|
|
138
|
+
def decorator(inner: F) -> F:
|
|
139
|
+
return _wrap_callable(inner, name=name or inner.__name__, kind="AGENT")
|
|
140
|
+
|
|
141
|
+
if fn is not None:
|
|
142
|
+
return decorator(fn)
|
|
143
|
+
return decorator
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
agent.step = task # type: ignore[attr-defined]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def retriever(fn: F | None = None, *, name: str | None = None) -> Callable[[F], F] | F:
|
|
150
|
+
def decorator(inner: F) -> F:
|
|
151
|
+
return _wrap_callable(inner, name=name or inner.__name__, kind="RETRIEVER")
|
|
152
|
+
|
|
153
|
+
if fn is not None:
|
|
154
|
+
return decorator(fn)
|
|
155
|
+
return decorator
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def embedding(fn: F | None = None, *, name: str | None = None) -> Callable[[F], F] | F:
|
|
159
|
+
def decorator(inner: F) -> F:
|
|
160
|
+
return _wrap_callable(inner, name=name or inner.__name__, kind="EMBEDDING")
|
|
161
|
+
|
|
162
|
+
if fn is not None:
|
|
163
|
+
return decorator(fn)
|
|
164
|
+
return decorator
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class _TraceContext:
|
|
168
|
+
def __init__(self, name: str, kind: str = "CHAIN", attributes: dict[str, Any] | None = None) -> None:
|
|
169
|
+
self.name = name
|
|
170
|
+
self.kind = kind
|
|
171
|
+
self.attributes = attributes or {}
|
|
172
|
+
self._span = None
|
|
173
|
+
|
|
174
|
+
def __enter__(self):
|
|
175
|
+
tracer = get_tracer()
|
|
176
|
+
self._span = tracer.start_span(self.name, kind=self.kind, attributes=self.attributes)
|
|
177
|
+
return self._span.__enter__()
|
|
178
|
+
|
|
179
|
+
def __exit__(self, exc_type, exc, tb):
|
|
180
|
+
if self._span is not None:
|
|
181
|
+
return self._span.__exit__(exc_type, exc, tb)
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def trace(name: str | None = None, *, kind: str = "CHAIN", attributes: dict[str, Any] | None = None):
|
|
186
|
+
if callable(name):
|
|
187
|
+
fn = name
|
|
188
|
+
return _wrap_callable(fn, name=fn.__name__, kind=kind, extra_attributes=attributes)
|
|
189
|
+
|
|
190
|
+
resolved_name = name or "trace"
|
|
191
|
+
|
|
192
|
+
def decorator(fn: F) -> F:
|
|
193
|
+
return _wrap_callable(fn, name=resolved_name, kind=kind, extra_attributes=attributes)
|
|
194
|
+
|
|
195
|
+
return decorator if name is not None else _TraceContext(resolved_name, kind=kind, attributes=attributes)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@contextmanager
|
|
199
|
+
def trace_context(name: str, *, kind: str = "CHAIN", attributes: dict[str, Any] | None = None):
|
|
200
|
+
tracer = get_tracer()
|
|
201
|
+
with tracer.start_span(name, kind=kind, attributes=attributes or {}) as span:
|
|
202
|
+
yield span
|
gradient_sdk/errors.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
class GradientError(Exception):
|
|
2
|
+
"""Base exception for Gradient SDK errors."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class GradientAuthError(GradientError):
|
|
6
|
+
"""Raised when the API key is missing, invalid, or revoked."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GradientAPIError(GradientError):
|
|
10
|
+
"""Raised for non-2xx Gradient API responses."""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
status: int,
|
|
15
|
+
method: str,
|
|
16
|
+
path: str,
|
|
17
|
+
message: str,
|
|
18
|
+
*,
|
|
19
|
+
fly_request_id: str | None = None,
|
|
20
|
+
response_body: str | None = None,
|
|
21
|
+
) -> None:
|
|
22
|
+
self.status = status
|
|
23
|
+
self.method = method
|
|
24
|
+
self.path = path
|
|
25
|
+
self.message = message
|
|
26
|
+
self.fly_request_id = fly_request_id
|
|
27
|
+
self.response_body = response_body
|
|
28
|
+
suffix = f" (fly-request-id={fly_request_id})" if fly_request_id else ""
|
|
29
|
+
super().__init__(f"Gradient API {status} {method} {path}: {message}{suffix}")
|
|
30
|
+
|
gradient_sdk/graph.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from collections.abc import AsyncIterator, Iterator
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
|
|
7
|
+
from .tracing import get_tracer
|
|
8
|
+
|
|
9
|
+
START = "__start__"
|
|
10
|
+
END = "__end__"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CompiledGraph:
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
*,
|
|
17
|
+
name: str,
|
|
18
|
+
nodes: dict[str, Callable[..., Any]],
|
|
19
|
+
edges: dict[str, list[str]],
|
|
20
|
+
conditional_edges: dict[str, tuple[Callable[..., Any], dict[str, str] | None]],
|
|
21
|
+
entry_point: str,
|
|
22
|
+
recursion_limit: int,
|
|
23
|
+
) -> None:
|
|
24
|
+
self.name = name
|
|
25
|
+
self.nodes = nodes
|
|
26
|
+
self.edges = edges
|
|
27
|
+
self.conditional_edges = conditional_edges
|
|
28
|
+
self.entry_point = entry_point
|
|
29
|
+
self.recursion_limit = recursion_limit
|
|
30
|
+
|
|
31
|
+
def invoke(self, state: dict[str, Any], *, config: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
32
|
+
tracer = get_tracer()
|
|
33
|
+
with tracer.start_span(f"workflow.{self.name}", kind="CHAIN") as root_span:
|
|
34
|
+
root_span.set_attribute("input.value", str(state))
|
|
35
|
+
current = dict(state)
|
|
36
|
+
current_node = self.entry_point
|
|
37
|
+
steps = 0
|
|
38
|
+
while current_node not in (END, None):
|
|
39
|
+
if steps >= self.recursion_limit:
|
|
40
|
+
raise RuntimeError(f"workflow recursion limit exceeded ({self.recursion_limit})")
|
|
41
|
+
steps += 1
|
|
42
|
+
if current_node not in self.nodes:
|
|
43
|
+
raise KeyError(f"unknown workflow node: {current_node}")
|
|
44
|
+
node_fn = self.nodes[current_node]
|
|
45
|
+
with tracer.start_span(f"workflow.node.{current_node}", kind="CHAIN") as node_span:
|
|
46
|
+
node_span.set_attribute("workflow.node", current_node)
|
|
47
|
+
update = self._call_node(node_fn, current, config or {})
|
|
48
|
+
if update:
|
|
49
|
+
current.update(update)
|
|
50
|
+
node_span.set_output(update or {})
|
|
51
|
+
current_node = self._resolve_next(current_node, current)
|
|
52
|
+
root_span.set_output(current)
|
|
53
|
+
return current
|
|
54
|
+
|
|
55
|
+
async def ainvoke(self, state: dict[str, Any], *, config: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
56
|
+
tracer = get_tracer()
|
|
57
|
+
with tracer.start_span(f"workflow.{self.name}", kind="CHAIN") as root_span:
|
|
58
|
+
root_span.set_attribute("input.value", str(state))
|
|
59
|
+
current = dict(state)
|
|
60
|
+
current_node = self.entry_point
|
|
61
|
+
steps = 0
|
|
62
|
+
while current_node not in (END, None):
|
|
63
|
+
if steps >= self.recursion_limit:
|
|
64
|
+
raise RuntimeError(f"workflow recursion limit exceeded ({self.recursion_limit})")
|
|
65
|
+
steps += 1
|
|
66
|
+
if current_node not in self.nodes:
|
|
67
|
+
raise KeyError(f"unknown workflow node: {current_node}")
|
|
68
|
+
node_fn = self.nodes[current_node]
|
|
69
|
+
with tracer.start_span(f"workflow.node.{current_node}", kind="CHAIN") as node_span:
|
|
70
|
+
node_span.set_attribute("workflow.node", current_node)
|
|
71
|
+
update = await self._acall_node(node_fn, current, config or {})
|
|
72
|
+
if update:
|
|
73
|
+
current.update(update)
|
|
74
|
+
node_span.set_output(update or {})
|
|
75
|
+
current_node = self._resolve_next(current_node, current)
|
|
76
|
+
root_span.set_output(current)
|
|
77
|
+
return current
|
|
78
|
+
|
|
79
|
+
def stream(self, state: dict[str, Any], *, config: dict[str, Any] | None = None) -> Iterator[tuple[str, dict[str, Any]]]:
|
|
80
|
+
current = dict(state)
|
|
81
|
+
current_node = self.entry_point
|
|
82
|
+
steps = 0
|
|
83
|
+
while current_node not in (END, None):
|
|
84
|
+
if steps >= self.recursion_limit:
|
|
85
|
+
raise RuntimeError(f"workflow recursion limit exceeded ({self.recursion_limit})")
|
|
86
|
+
steps += 1
|
|
87
|
+
if current_node not in self.nodes:
|
|
88
|
+
raise KeyError(f"unknown workflow node: {current_node}")
|
|
89
|
+
update = self._call_node(self.nodes[current_node], current, config or {})
|
|
90
|
+
if update:
|
|
91
|
+
current.update(update)
|
|
92
|
+
yield current_node, update or {}
|
|
93
|
+
current_node = self._resolve_next(current_node, current)
|
|
94
|
+
|
|
95
|
+
async def astream(
|
|
96
|
+
self,
|
|
97
|
+
state: dict[str, Any],
|
|
98
|
+
*,
|
|
99
|
+
config: dict[str, Any] | None = None,
|
|
100
|
+
) -> AsyncIterator[tuple[str, dict[str, Any]]]:
|
|
101
|
+
current = dict(state)
|
|
102
|
+
current_node = self.entry_point
|
|
103
|
+
steps = 0
|
|
104
|
+
while current_node not in (END, None):
|
|
105
|
+
if steps >= self.recursion_limit:
|
|
106
|
+
raise RuntimeError(f"workflow recursion limit exceeded ({self.recursion_limit})")
|
|
107
|
+
steps += 1
|
|
108
|
+
if current_node not in self.nodes:
|
|
109
|
+
raise KeyError(f"unknown workflow node: {current_node}")
|
|
110
|
+
update = await self._acall_node(self.nodes[current_node], current, config or {})
|
|
111
|
+
if update:
|
|
112
|
+
current.update(update)
|
|
113
|
+
yield current_node, update or {}
|
|
114
|
+
current_node = self._resolve_next(current_node, current)
|
|
115
|
+
|
|
116
|
+
def _resolve_next(self, node: str, state: dict[str, Any]) -> str | None:
|
|
117
|
+
if node in self.conditional_edges:
|
|
118
|
+
router, mapping = self.conditional_edges[node]
|
|
119
|
+
route = router(state)
|
|
120
|
+
if mapping is not None:
|
|
121
|
+
return mapping.get(route, route)
|
|
122
|
+
return route
|
|
123
|
+
next_nodes = self.edges.get(node, [])
|
|
124
|
+
if not next_nodes:
|
|
125
|
+
return END
|
|
126
|
+
return next_nodes[0]
|
|
127
|
+
|
|
128
|
+
def _call_node(self, fn: Callable[..., Any], state: dict[str, Any], config: dict[str, Any]) -> dict[str, Any] | None:
|
|
129
|
+
result = fn(state, config=config) if _accepts_config(fn) else fn(state)
|
|
130
|
+
if inspect.isawaitable(result):
|
|
131
|
+
raise TypeError("async node functions require CompiledGraph.ainvoke()")
|
|
132
|
+
return result
|
|
133
|
+
|
|
134
|
+
async def _acall_node(self, fn: Callable[..., Any], state: dict[str, Any], config: dict[str, Any]) -> dict[str, Any] | None:
|
|
135
|
+
if inspect.iscoroutinefunction(fn):
|
|
136
|
+
return await fn(state, config=config) if _accepts_config(fn) else await fn(state)
|
|
137
|
+
return fn(state, config=config) if _accepts_config(fn) else fn(state)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _accepts_config(fn: Callable[..., Any]) -> bool:
|
|
141
|
+
try:
|
|
142
|
+
return "config" in inspect.signature(fn).parameters
|
|
143
|
+
except (TypeError, ValueError):
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class StateGraph:
|
|
148
|
+
def __init__(self, state_schema: Any = dict, *, name: str = "workflow") -> None:
|
|
149
|
+
self.state_schema = state_schema
|
|
150
|
+
self.name = name
|
|
151
|
+
self._nodes: dict[str, Callable[..., Any]] = {}
|
|
152
|
+
self._edges: dict[str, list[str]] = {}
|
|
153
|
+
self._conditional_edges: dict[str, tuple[Callable[..., Any], dict[str, str] | None]] = {}
|
|
154
|
+
self._entry_point: str | None = None
|
|
155
|
+
|
|
156
|
+
def add_node(self, name: str, fn: Callable[..., Any]) -> "StateGraph":
|
|
157
|
+
self._nodes[name] = fn
|
|
158
|
+
return self
|
|
159
|
+
|
|
160
|
+
def add_edge(self, source: str, target: str) -> "StateGraph":
|
|
161
|
+
if source == START:
|
|
162
|
+
self._entry_point = target
|
|
163
|
+
return self
|
|
164
|
+
self._edges.setdefault(source, []).append(target)
|
|
165
|
+
return self
|
|
166
|
+
|
|
167
|
+
def add_conditional_edges(
|
|
168
|
+
self,
|
|
169
|
+
source: str,
|
|
170
|
+
router: Callable[..., Any],
|
|
171
|
+
mapping: dict[str, str] | None = None,
|
|
172
|
+
) -> "StateGraph":
|
|
173
|
+
self._conditional_edges[source] = (router, mapping)
|
|
174
|
+
return self
|
|
175
|
+
|
|
176
|
+
def set_entry_point(self, name: str) -> "StateGraph":
|
|
177
|
+
self._entry_point = name
|
|
178
|
+
return self
|
|
179
|
+
|
|
180
|
+
def compile(self, *, recursion_limit: int = 25) -> CompiledGraph:
|
|
181
|
+
if not self._entry_point:
|
|
182
|
+
raise ValueError("workflow entry point is not set")
|
|
183
|
+
return CompiledGraph(
|
|
184
|
+
name=self.name,
|
|
185
|
+
nodes=dict(self._nodes),
|
|
186
|
+
edges={key: list(value) for key, value in self._edges.items()},
|
|
187
|
+
conditional_edges=dict(self._conditional_edges),
|
|
188
|
+
entry_point=self._entry_point,
|
|
189
|
+
recursion_limit=recursion_limit,
|
|
190
|
+
)
|