langgraph-api 0.18.0.dev12__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 CHANGED
@@ -1 +1 @@
1
- __version__ = "0.18.0.dev12"
1
+ __version__ = "0.18.0.dev13"
@@ -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 not HTTP_CONFIG.get("disable_runs"):
173
+ if RUNS_ENABLED:
170
174
  protected_routes.extend(runs_routes)
171
- if not HTTP_CONFIG.get("disable_threads"):
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 not HTTP_CONFIG.get("disable_mcp"):
183
+ if MCP_ENABLED:
180
184
  protected_routes.extend(mcp_routes)
181
- if not HTTP_CONFIG.get("disable_a2a"):
185
+ if A2A_ENABLED:
182
186
  protected_routes.extend(a2a_routes)
183
- # ``disable_runs`` also gates event streaming because the v2 transport
184
- # exposes run-creating commands (``run.start`` / ``run.cancel`` /
185
- # ``run.attach`` over the WS, plus the ``/runs/{run_id}/protocol``
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 not HTTP_CONFIG.get("disable_runs")
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
@@ -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]