langgraph-api 0.18.0.dev11__py3-none-any.whl → 0.18.0.dev13__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.
- langgraph_api/__init__.py +1 -1
- langgraph_api/api/__init__.py +12 -13
- langgraph_api/api/event_streaming.py +1 -1
- langgraph_api/api/mcp/_call_thread.py +220 -0
- langgraph_api/api/mcp/_calls.py +14 -0
- langgraph_api/api/mcp/_constants.py +14 -0
- langgraph_api/api/mcp/_digest.py +11 -0
- langgraph_api/api/mcp/_elicitation.py +371 -0
- langgraph_api/api/mcp/_forms.py +314 -0
- langgraph_api/api/mcp/_outcome.py +23 -2
- langgraph_api/api/mcp/_pauses.py +276 -0
- langgraph_api/api/mcp/_request_state.py +109 -0
- langgraph_api/api/mcp/_server.py +3 -3
- langgraph_api/api/mcp/_tools.py +40 -42
- langgraph_api/api/runs.py +2 -0
- langgraph_api/config/__init__.py +2 -0
- langgraph_api/config/schemas.py +2 -0
- langgraph_api/grpc/client.py +8 -0
- langgraph_api/grpc/ops/runs.py +7 -0
- langgraph_api/grpc/ops/threads.py +19 -14
- langgraph_api/js/client.mts +86 -49
- langgraph_api/js/global.d.ts +1 -0
- langgraph_api/js/remote.py +219 -54
- langgraph_api/metadata.py +7 -0
- langgraph_api/route.py +3 -20
- langgraph_api/sse.py +90 -55
- langgraph_api/stream.py +31 -32
- langgraph_api/validation.py +15 -0
- langgraph_api/worker.py +9 -0
- {langgraph_api-0.18.0.dev11.dist-info → langgraph_api-0.18.0.dev13.dist-info}/METADATA +2 -2
- {langgraph_api-0.18.0.dev11.dist-info → langgraph_api-0.18.0.dev13.dist-info}/RECORD +35 -29
- langgraph_grpc_common/store.py +9 -2
- {langgraph_api-0.18.0.dev11.dist-info → langgraph_api-0.18.0.dev13.dist-info}/WHEEL +0 -0
- {langgraph_api-0.18.0.dev11.dist-info → langgraph_api-0.18.0.dev13.dist-info}/entry_points.txt +0 -0
- {langgraph_api-0.18.0.dev11.dist-info → langgraph_api-0.18.0.dev13.dist-info}/licenses/LICENSE +0 -0
langgraph_api/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.18.0.
|
|
1
|
+
__version__ = "0.18.0.dev13"
|
langgraph_api/api/__init__.py
CHANGED
|
@@ -28,10 +28,14 @@ from langgraph_api.api.threads import threads_routes
|
|
|
28
28
|
from langgraph_api.api.ui import ui_routes
|
|
29
29
|
from langgraph_api.auth.middleware import auth_middleware
|
|
30
30
|
from langgraph_api.config import (
|
|
31
|
+
A2A_ENABLED,
|
|
31
32
|
FF_PYSPY_PROFILING_ENABLED,
|
|
32
33
|
HTTP_CONFIG,
|
|
33
34
|
LANGGRAPH_ENCRYPTION,
|
|
35
|
+
MCP_ENABLED,
|
|
34
36
|
MIGRATIONS_PATH,
|
|
37
|
+
RUNS_ENABLED,
|
|
38
|
+
THREADS_ENABLED,
|
|
35
39
|
)
|
|
36
40
|
from langgraph_api.feature_flags import (
|
|
37
41
|
FF_V2_EVENT_STREAMING,
|
|
@@ -166,9 +170,9 @@ protected_routes: list[BaseRoute] = []
|
|
|
166
170
|
if HTTP_CONFIG:
|
|
167
171
|
if not HTTP_CONFIG.get("disable_assistants"):
|
|
168
172
|
protected_routes.extend(assistants_routes)
|
|
169
|
-
if
|
|
173
|
+
if RUNS_ENABLED:
|
|
170
174
|
protected_routes.extend(runs_routes)
|
|
171
|
-
if
|
|
175
|
+
if THREADS_ENABLED:
|
|
172
176
|
protected_routes.extend(threads_routes)
|
|
173
177
|
if not HTTP_CONFIG.get("disable_store"):
|
|
174
178
|
protected_routes.extend(store_routes)
|
|
@@ -176,21 +180,16 @@ if HTTP_CONFIG:
|
|
|
176
180
|
protected_routes.extend(profile_routes)
|
|
177
181
|
if not HTTP_CONFIG.get("disable_ui"):
|
|
178
182
|
protected_routes.extend(ui_routes)
|
|
179
|
-
if
|
|
183
|
+
if MCP_ENABLED:
|
|
180
184
|
protected_routes.extend(mcp_routes)
|
|
181
|
-
if
|
|
185
|
+
if A2A_ENABLED:
|
|
182
186
|
protected_routes.extend(a2a_routes)
|
|
183
|
-
#
|
|
184
|
-
#
|
|
185
|
-
#
|
|
186
|
-
# attach route). If an operator hardens a deployment with
|
|
187
|
-
# ``disable_runs=true``, leaving event streaming open would be a
|
|
188
|
-
# backdoor that lets clients create runs anyway. ``disable_event_streaming``
|
|
189
|
-
# is the narrow knob (turn off just v2); ``disable_runs`` is the broad
|
|
190
|
-
# knob (no run-related functionality at all).
|
|
187
|
+
# disable_runs also unmounts event streaming: its WebSocket and the
|
|
188
|
+
# /runs/{run_id}/protocol route can start, cancel and attach runs, so leaving
|
|
189
|
+
# them mounted would let clients create runs on a deployment that disabled runs.
|
|
191
190
|
if (
|
|
192
191
|
FF_V2_EVENT_STREAMING
|
|
193
|
-
and
|
|
192
|
+
and RUNS_ENABLED
|
|
194
193
|
and not HTTP_CONFIG.get("disable_event_streaming")
|
|
195
194
|
):
|
|
196
195
|
protected_routes.extend(event_streaming_routes)
|
|
@@ -330,7 +330,7 @@ async def _thread_events(request: Request) -> Response:
|
|
|
330
330
|
finally:
|
|
331
331
|
await manager.close()
|
|
332
332
|
|
|
333
|
-
return EventSourceResponse(body_iter())
|
|
333
|
+
return EventSourceResponse(body_iter(), on_close=manager.close)
|
|
334
334
|
|
|
335
335
|
|
|
336
336
|
# ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import asynccontextmanager, contextmanager
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from http import HTTPStatus
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import jsonschema_rs
|
|
10
|
+
import mcp_types as types
|
|
11
|
+
import structlog
|
|
12
|
+
from mcp.shared.exceptions import MCPError
|
|
13
|
+
from starlette.exceptions import HTTPException
|
|
14
|
+
|
|
15
|
+
from langgraph_api.api.mcp._constants import (
|
|
16
|
+
BREAKPOINT_MESSAGE,
|
|
17
|
+
FINISHED_CALL_TTL_MINUTES,
|
|
18
|
+
PAUSED_CALL_TTL_MINUTES,
|
|
19
|
+
)
|
|
20
|
+
from langgraph_api.api.mcp._outcome import Failed, Paused, RunOutcome, parse_outcome
|
|
21
|
+
from langgraph_api.api.mcp._request_state import Binding, InvalidRequestState
|
|
22
|
+
from langgraph_api.config import THREAD_TTL
|
|
23
|
+
from langgraph_api.models.run import create_valid_run
|
|
24
|
+
from langgraph_api.validation import RunCreateStateful, sanitize_and_validate
|
|
25
|
+
from langgraph_runtime.database import connect
|
|
26
|
+
from langgraph_runtime.retry import retry_db
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from collections.abc import AsyncIterator, Iterator, Mapping
|
|
30
|
+
from uuid import UUID
|
|
31
|
+
|
|
32
|
+
from langgraph_sdk.client import LangGraphClient
|
|
33
|
+
from langgraph_sdk.schema import Thread
|
|
34
|
+
from pydantic import JsonValue
|
|
35
|
+
|
|
36
|
+
from langgraph_api.api.mcp._calls import ToolCallParams
|
|
37
|
+
from langgraph_api.api.mcp._request_state import RequestState
|
|
38
|
+
from langgraph_api.schema import Interrupt
|
|
39
|
+
|
|
40
|
+
logger = structlog.stdlib.get_logger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class CallThread:
|
|
45
|
+
client: LangGraphClient
|
|
46
|
+
headers: Mapping[str, str]
|
|
47
|
+
assistant_id: str
|
|
48
|
+
thread_id: str
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
async def create(
|
|
52
|
+
cls, client: LangGraphClient, headers: Mapping[str, str], assistant_id: str
|
|
53
|
+
) -> CallThread:
|
|
54
|
+
thread = await client.threads.create(
|
|
55
|
+
ttl=_within_operator_ttl(PAUSED_CALL_TTL_MINUTES), headers=headers
|
|
56
|
+
)
|
|
57
|
+
return cls(client, headers, assistant_id, thread["thread_id"])
|
|
58
|
+
|
|
59
|
+
async def get(self) -> Thread:
|
|
60
|
+
with _invalid_state_if_missing():
|
|
61
|
+
return await self.client.threads.get(self.thread_id, headers=self.headers)
|
|
62
|
+
|
|
63
|
+
async def pending(self, thread: Thread) -> list[Interrupt]:
|
|
64
|
+
if thread["status"] != "interrupted":
|
|
65
|
+
return []
|
|
66
|
+
with _invalid_state_if_missing():
|
|
67
|
+
state = await self.client.threads.get_state(
|
|
68
|
+
self.thread_id, headers=self.headers
|
|
69
|
+
)
|
|
70
|
+
return [
|
|
71
|
+
interrupt for task in state["tasks"] for interrupt in task["interrupts"]
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
async def bind(self, binding: Binding, replacing: Binding | None) -> None:
|
|
75
|
+
if binding == replacing:
|
|
76
|
+
return
|
|
77
|
+
if replacing is not None and await self._bound() not in (replacing, binding):
|
|
78
|
+
return
|
|
79
|
+
await self.client.threads.update(
|
|
80
|
+
self.thread_id,
|
|
81
|
+
metadata=binding.metadata,
|
|
82
|
+
ttl=_within_operator_ttl(PAUSED_CALL_TTL_MINUTES),
|
|
83
|
+
headers=self.headers,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
async def run(self, params: ToolCallParams) -> RunOutcome:
|
|
87
|
+
value = await self.client.runs.wait(
|
|
88
|
+
thread_id=self.thread_id,
|
|
89
|
+
assistant_id=self.assistant_id,
|
|
90
|
+
input=params.input,
|
|
91
|
+
context=params.context,
|
|
92
|
+
durability="exit",
|
|
93
|
+
headers=self.headers,
|
|
94
|
+
raise_error=False,
|
|
95
|
+
)
|
|
96
|
+
return await self._outcome(value)
|
|
97
|
+
|
|
98
|
+
async def resume(
|
|
99
|
+
self,
|
|
100
|
+
value: JsonValue,
|
|
101
|
+
state: RequestState,
|
|
102
|
+
context: Mapping[str, JsonValue] | None,
|
|
103
|
+
) -> RunOutcome:
|
|
104
|
+
try:
|
|
105
|
+
await self._create_run(self._resume_payload(value, context), state.run_id)
|
|
106
|
+
except HTTPException as error:
|
|
107
|
+
if error.status_code == HTTPStatus.NOT_FOUND:
|
|
108
|
+
raise InvalidRequestState from None
|
|
109
|
+
if error.status_code != HTTPStatus.CONFLICT:
|
|
110
|
+
raise
|
|
111
|
+
return await self.join(state)
|
|
112
|
+
return await self._wait_for(state)
|
|
113
|
+
|
|
114
|
+
async def join(self, state: RequestState) -> RunOutcome:
|
|
115
|
+
if not await self.resumed_with(state):
|
|
116
|
+
raise InvalidRequestState
|
|
117
|
+
return await self._wait_for(state)
|
|
118
|
+
|
|
119
|
+
async def resumed_with(self, state: RequestState) -> bool:
|
|
120
|
+
try:
|
|
121
|
+
await self.client.runs.get(
|
|
122
|
+
self.thread_id, str(state.run_id), headers=self.headers
|
|
123
|
+
)
|
|
124
|
+
except httpx.HTTPStatusError as error:
|
|
125
|
+
if error.response.status_code != HTTPStatus.NOT_FOUND:
|
|
126
|
+
raise
|
|
127
|
+
return False
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
async def delete(self) -> None:
|
|
131
|
+
async with self._best_effort("Could not delete a finished MCP call's thread"):
|
|
132
|
+
await self.client.threads.delete(self.thread_id, headers=self.headers)
|
|
133
|
+
|
|
134
|
+
async def expire_soon(self) -> None:
|
|
135
|
+
async with self._best_effort("Could not shorten a finished MCP call's TTL"):
|
|
136
|
+
await self.client.threads.update(
|
|
137
|
+
self.thread_id,
|
|
138
|
+
metadata={},
|
|
139
|
+
ttl=_within_operator_ttl(FINISHED_CALL_TTL_MINUTES),
|
|
140
|
+
headers=self.headers,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
async def _bound(self) -> Binding | None:
|
|
144
|
+
return Binding.stored((await self.get())["metadata"])
|
|
145
|
+
|
|
146
|
+
@asynccontextmanager
|
|
147
|
+
async def _best_effort(self, failure: str) -> AsyncIterator[None]:
|
|
148
|
+
try:
|
|
149
|
+
yield
|
|
150
|
+
except httpx.HTTPStatusError as error:
|
|
151
|
+
if error.response.status_code != HTTPStatus.NOT_FOUND:
|
|
152
|
+
await logger.awarning(
|
|
153
|
+
failure,
|
|
154
|
+
thread_id=self.thread_id,
|
|
155
|
+
status_code=error.response.status_code,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
@retry_db
|
|
159
|
+
async def _create_run(self, payload: dict[str, JsonValue], run_id: UUID) -> None:
|
|
160
|
+
async with connect() as conn:
|
|
161
|
+
await create_valid_run(
|
|
162
|
+
conn, self.thread_id, payload, self.headers, run_id=run_id
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
async def _wait_for(self, state: RequestState) -> RunOutcome:
|
|
166
|
+
with _invalid_state_if_missing():
|
|
167
|
+
value = await self.client.runs.join(
|
|
168
|
+
self.thread_id, str(state.run_id), headers=self.headers
|
|
169
|
+
)
|
|
170
|
+
return await self._outcome(value)
|
|
171
|
+
|
|
172
|
+
async def _outcome(self, value: JsonValue) -> RunOutcome:
|
|
173
|
+
outcome = parse_outcome(value)
|
|
174
|
+
if isinstance(outcome, Failed):
|
|
175
|
+
return outcome
|
|
176
|
+
thread = await self.get()
|
|
177
|
+
match thread["status"]:
|
|
178
|
+
case "busy":
|
|
179
|
+
raise InvalidRequestState
|
|
180
|
+
case "interrupted":
|
|
181
|
+
return await self._paused_or_stopped(thread)
|
|
182
|
+
case _:
|
|
183
|
+
return outcome
|
|
184
|
+
|
|
185
|
+
async def _paused_or_stopped(self, thread: Thread) -> Paused | Failed:
|
|
186
|
+
if interrupts := await self.pending(thread):
|
|
187
|
+
return Paused(tuple(interrupts))
|
|
188
|
+
return Failed(BREAKPOINT_MESSAGE)
|
|
189
|
+
|
|
190
|
+
def _resume_payload(
|
|
191
|
+
self, value: JsonValue, context: Mapping[str, JsonValue] | None
|
|
192
|
+
) -> dict[str, JsonValue]:
|
|
193
|
+
payload: dict[str, JsonValue] = {
|
|
194
|
+
"assistant_id": self.assistant_id,
|
|
195
|
+
"command": {"resume": value},
|
|
196
|
+
"multitask_strategy": "reject",
|
|
197
|
+
"durability": "exit",
|
|
198
|
+
}
|
|
199
|
+
if context is not None:
|
|
200
|
+
payload["context"] = dict(context)
|
|
201
|
+
try:
|
|
202
|
+
sanitize_and_validate(payload, RunCreateStateful)
|
|
203
|
+
except jsonschema_rs.ValidationError:
|
|
204
|
+
raise MCPError(types.INVALID_PARAMS, "Invalid tool call context") from None
|
|
205
|
+
return payload
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _within_operator_ttl(minutes: float) -> float:
|
|
209
|
+
operator_ttl = THREAD_TTL.get("default_ttl") if THREAD_TTL else None
|
|
210
|
+
return minutes if operator_ttl is None else min(minutes, operator_ttl)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@contextmanager
|
|
214
|
+
def _invalid_state_if_missing() -> Iterator[None]:
|
|
215
|
+
try:
|
|
216
|
+
yield
|
|
217
|
+
except httpx.HTTPStatusError as error:
|
|
218
|
+
if error.response.status_code != HTTPStatus.NOT_FOUND:
|
|
219
|
+
raise
|
|
220
|
+
raise InvalidRequestState from None
|
langgraph_api/api/mcp/_calls.py
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
import mcp_types as types
|
|
2
2
|
from pydantic import JsonValue
|
|
3
3
|
|
|
4
|
+
from langgraph_api.api.mcp._digest import digest
|
|
5
|
+
|
|
4
6
|
|
|
5
7
|
class ToolCallParams(types.CallToolRequestParams):
|
|
8
|
+
arguments: dict[str, JsonValue] | None = None
|
|
6
9
|
context: dict[str, JsonValue] | None = None
|
|
10
|
+
|
|
11
|
+
@property
|
|
12
|
+
def input(self) -> dict[str, JsonValue]:
|
|
13
|
+
return self.arguments or {}
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def answers(self) -> types.InputResponses:
|
|
17
|
+
return self.input_responses or {}
|
|
18
|
+
|
|
19
|
+
def digest(self, assistant_id: str) -> str:
|
|
20
|
+
return digest(assistant_id, self.name, self.input)
|
|
@@ -9,3 +9,17 @@ PAUSED_TOOL_MESSAGE = (
|
|
|
9
9
|
RUN_FAILED_MESSAGE = "The tool's run failed."
|
|
10
10
|
INVALID_CURSOR_MESSAGE = "Invalid cursor"
|
|
11
11
|
INTERNAL_ERROR_MESSAGE = "Internal server error"
|
|
12
|
+
UNRESUMABLE_PAUSE_MESSAGE = (
|
|
13
|
+
"This tool paused to ask for human input, and this call can't be resumed."
|
|
14
|
+
)
|
|
15
|
+
ELICITATION_REQUIRED_MESSAGE = (
|
|
16
|
+
"This tool asks for human input and needs {modes} elicitation."
|
|
17
|
+
)
|
|
18
|
+
INVALID_REQUEST_STATE = "Invalid or expired requestState"
|
|
19
|
+
UNASKABLE_INPUT_MESSAGE = "This tool needs input that can't be asked over MCP"
|
|
20
|
+
BREAKPOINT_MESSAGE = (
|
|
21
|
+
"This tool stopped at a breakpoint (interrupt_before or interrupt_after), "
|
|
22
|
+
"which can't be resumed over MCP."
|
|
23
|
+
)
|
|
24
|
+
PAUSED_CALL_TTL_MINUTES = 24 * 60
|
|
25
|
+
FINISHED_CALL_TTL_MINUTES = 10
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
|
|
3
|
+
import orjson
|
|
4
|
+
from pydantic import JsonValue
|
|
5
|
+
|
|
6
|
+
KEY_DIGEST_LENGTH = 16
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def digest(*parts: JsonValue) -> str:
|
|
10
|
+
encoded = orjson.dumps(parts, option=orjson.OPT_SORT_KEYS)
|
|
11
|
+
return hashlib.sha256(encoded).hexdigest()[:KEY_DIGEST_LENGTH]
|