deepagents-code 0.2.0__py3-none-any.whl → 0.2.2__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.
- deepagents_code/_build_info.py +1 -1
- deepagents_code/_checkpointer_adapter.py +86 -0
- deepagents_code/_env_vars.py +29 -3
- deepagents_code/_git.py +8 -2
- deepagents_code/_js_cost.py +15 -0
- deepagents_code/_langsmith_runtime.py +91 -0
- deepagents_code/_run_queue_wakeup.py +156 -0
- deepagents_code/_session_lock.py +184 -0
- deepagents_code/_textual_patches.py +5 -0
- deepagents_code/_trace_redaction.py +145 -0
- deepagents_code/_version.py +1 -1
- deepagents_code/agent.py +40 -5
- deepagents_code/app.py +779 -176
- deepagents_code/app.tcss +1 -1
- deepagents_code/auto_mode.py +192 -16
- deepagents_code/btw.py +176 -37
- deepagents_code/btw_api.py +80 -1
- deepagents_code/built_in_skills/deepagents-plugin-discovery/SKILL.md +3 -1
- deepagents_code/client/launch/_server_bootstrap.py +11 -1
- deepagents_code/client/launch/server.py +62 -15
- deepagents_code/client/launch/server_manager.py +97 -4
- deepagents_code/client/non_interactive.py +39 -30
- deepagents_code/client/remote_client.py +44 -0
- deepagents_code/config.py +137 -11
- deepagents_code/config_manifest.py +13 -1
- deepagents_code/goal_tools.py +25 -9
- deepagents_code/hooks/models/domain.py +6 -1
- deepagents_code/hooks/transcript.py +24 -5
- deepagents_code/integrations/sandbox_factory.py +69 -2
- deepagents_code/local_context.py +85 -20
- deepagents_code/main.py +165 -24
- deepagents_code/mcp_tools.py +11 -0
- deepagents_code/memory_guard.py +4 -0
- deepagents_code/model_config.py +88 -14
- deepagents_code/offload_api.py +84 -6
- deepagents_code/plugins/__init__.py +4 -0
- deepagents_code/plugins/adapters/mcp.py +2 -4
- deepagents_code/plugins/adapters/skills.py +13 -9
- deepagents_code/plugins/adapters/skills_middleware.py +4 -0
- deepagents_code/plugins/commands_cli.py +127 -4
- deepagents_code/plugins/discovery.py +431 -141
- deepagents_code/plugins/models.py +55 -1
- deepagents_code/plugins/store.py +40 -1
- deepagents_code/server_graph.py +2 -0
- deepagents_code/sessions.py +8 -2
- deepagents_code/thread_ownership.py +59 -15
- deepagents_code/tui/modals/plugin_manager/__init__.py +57 -0
- deepagents_code/tui/modals/plugin_manager/content.py +41 -0
- deepagents_code/tui/modals/plugin_manager/models.py +8 -1
- deepagents_code/tui/modals/plugin_manager/state.py +25 -2
- deepagents_code/tui/widgets/_links.py +43 -2
- deepagents_code/tui/widgets/_static_update.py +35 -0
- deepagents_code/tui/widgets/autocomplete.py +185 -57
- deepagents_code/tui/widgets/chat_input.py +43 -1
- deepagents_code/tui/widgets/final_markdown/__init__.py +18 -0
- deepagents_code/tui/widgets/final_markdown/_layout.py +287 -0
- deepagents_code/tui/widgets/final_markdown/_parse.py +158 -0
- deepagents_code/tui/widgets/final_markdown/_rows.py +126 -0
- deepagents_code/tui/widgets/final_markdown/_table.py +136 -0
- deepagents_code/tui/widgets/final_markdown/_widget.py +329 -0
- deepagents_code/tui/widgets/loading.py +5 -3
- deepagents_code/tui/widgets/message_store.py +93 -9
- deepagents_code/tui/widgets/messages.py +179 -67
- deepagents_code/tui/widgets/selection_action.py +131 -0
- deepagents_code/tui/widgets/thread_selector.py +218 -49
- deepagents_code/ui.py +3 -0
- deepagents_code/update_check.py +30 -5
- deepagents_code/workspace_diagnostics.py +7 -2
- {deepagents_code-0.2.0.dist-info → deepagents_code-0.2.2.dist-info}/METADATA +22 -22
- {deepagents_code-0.2.0.dist-info → deepagents_code-0.2.2.dist-info}/RECORD +74 -61
- {deepagents_code-0.2.0.data → deepagents_code-0.2.2.data}/data/deepagents_code/default_agent_prompt.md +0 -0
- {deepagents_code-0.2.0.dist-info → deepagents_code-0.2.2.dist-info}/WHEEL +0 -0
- {deepagents_code-0.2.0.dist-info → deepagents_code-0.2.2.dist-info}/entry_points.txt +0 -0
- {deepagents_code-0.2.0.dist-info → deepagents_code-0.2.2.dist-info}/licenses/LICENSE +0 -0
deepagents_code/_build_info.py
CHANGED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Forward delta-channel history reads through langgraph-api's saver adapter.
|
|
2
|
+
|
|
3
|
+
The LangGraph dev server wraps a custom checkpointer in
|
|
4
|
+
`langgraph_api._checkpointer._adapter._CustomCheckpointerAdapter`. The adapter
|
|
5
|
+
forwards unknown attributes to the inner saver through `__getattr__`, but
|
|
6
|
+
`BaseCheckpointSaver` already defines `aget_delta_channel_history`, so the
|
|
7
|
+
forwarding never fires. The adapter then runs the base ancestor walk: one
|
|
8
|
+
`aget_tuple` and one full checkpoint decode per ancestor. The SQLite saver
|
|
9
|
+
overrides the method with two indexed queries, and long threads pay the
|
|
10
|
+
difference on every state read and every run start.
|
|
11
|
+
|
|
12
|
+
This module is a best-effort patch over a private langgraph-api class. It only
|
|
13
|
+
patches when the adapter still inherits the base method, and it falls back to
|
|
14
|
+
the base walk when the inner saver has no override of its own. Remove it when
|
|
15
|
+
the adapter forwards these methods itself.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
from typing import TYPE_CHECKING
|
|
22
|
+
|
|
23
|
+
from langgraph.checkpoint.base import BaseCheckpointSaver
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from collections.abc import Mapping, Sequence
|
|
27
|
+
|
|
28
|
+
from langchain_core.runnables import RunnableConfig
|
|
29
|
+
from langgraph.checkpoint.base import DeltaChannelHistory
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
_BASE_ASYNC = BaseCheckpointSaver.aget_delta_channel_history
|
|
34
|
+
_BASE_SYNC = BaseCheckpointSaver.get_delta_channel_history
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _overriding_inner(
|
|
38
|
+
adapter: BaseCheckpointSaver, name: str
|
|
39
|
+
) -> BaseCheckpointSaver | None:
|
|
40
|
+
"""Return the adapter's inner saver if it overrides the base method `name`."""
|
|
41
|
+
inner = adapter.__dict__.get("_inner")
|
|
42
|
+
if not isinstance(inner, BaseCheckpointSaver):
|
|
43
|
+
return None
|
|
44
|
+
if getattr(type(inner), name) is getattr(BaseCheckpointSaver, name):
|
|
45
|
+
return None
|
|
46
|
+
return inner
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def _aget_delta_channel_history(
|
|
50
|
+
self: BaseCheckpointSaver, *, config: RunnableConfig, channels: Sequence[str]
|
|
51
|
+
) -> Mapping[str, DeltaChannelHistory]:
|
|
52
|
+
inner = _overriding_inner(self, "aget_delta_channel_history")
|
|
53
|
+
if inner is None:
|
|
54
|
+
return await _BASE_ASYNC(self, config=config, channels=channels)
|
|
55
|
+
return await inner.aget_delta_channel_history(config=config, channels=channels)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _get_delta_channel_history(
|
|
59
|
+
self: BaseCheckpointSaver, *, config: RunnableConfig, channels: Sequence[str]
|
|
60
|
+
) -> Mapping[str, DeltaChannelHistory]:
|
|
61
|
+
inner = _overriding_inner(self, "get_delta_channel_history")
|
|
62
|
+
if inner is None:
|
|
63
|
+
return _BASE_SYNC(self, config=config, channels=channels)
|
|
64
|
+
return inner.get_delta_channel_history(config=config, channels=channels)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def install_delta_history_forwarding() -> None:
|
|
68
|
+
"""Make langgraph-api's checkpointer adapter use the inner saver's fast path.
|
|
69
|
+
|
|
70
|
+
Call this in the server process before the adapter serves requests. It is
|
|
71
|
+
idempotent and a no-op when the adapter is missing or already overrides
|
|
72
|
+
the methods.
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
# The adapter has no public seam; this patch exists to reach it.
|
|
76
|
+
from langgraph_api._checkpointer._adapter import ( # noqa: PLC2701
|
|
77
|
+
_CustomCheckpointerAdapter,
|
|
78
|
+
)
|
|
79
|
+
except ImportError:
|
|
80
|
+
logger.warning("langgraph-api checkpointer adapter not found; skipping patch")
|
|
81
|
+
return
|
|
82
|
+
adapter = _CustomCheckpointerAdapter
|
|
83
|
+
if adapter.aget_delta_channel_history is _BASE_ASYNC:
|
|
84
|
+
adapter.aget_delta_channel_history = _aget_delta_channel_history
|
|
85
|
+
if adapter.get_delta_channel_history is _BASE_SYNC:
|
|
86
|
+
adapter.get_delta_channel_history = _get_delta_channel_history
|
deepagents_code/_env_vars.py
CHANGED
|
@@ -77,6 +77,15 @@ Values outside 1-900 seconds are ignored in favor of the next config source.
|
|
|
77
77
|
Also settable via `[mcp].tool_timeout` in config.toml.
|
|
78
78
|
"""
|
|
79
79
|
|
|
80
|
+
ASK_ABOUT_SELECTION = "DEEPAGENTS_CODE_ASK_ABOUT_SELECTION"
|
|
81
|
+
"""Show "Ask about this" when conversation text is selected.
|
|
82
|
+
|
|
83
|
+
Enabled by default; set to a falsy value (`0`, `false`, `no`, `off`) to hide
|
|
84
|
+
it. Parsed by `classify_env_bool` (an unrecognized or empty value falls through
|
|
85
|
+
to the config value rather than forcing the default). Also settable via
|
|
86
|
+
`[ui].ask_about_selection` in config.toml. `/reload` applies a change.
|
|
87
|
+
"""
|
|
88
|
+
|
|
80
89
|
AUTO_UPDATE = "DEEPAGENTS_CODE_AUTO_UPDATE"
|
|
81
90
|
"""Toggle automatic app updates. Enabled by default; set to a falsy value
|
|
82
91
|
('0', 'false', 'no', 'off', or empty) to opt out."""
|
|
@@ -277,13 +286,20 @@ EXPERIMENTAL = "DEEPAGENTS_CODE_EXPERIMENTAL"
|
|
|
277
286
|
Off by default; parsed by `is_env_truthy` (see there for the accepted truthy
|
|
278
287
|
values). Marks experimental runs in UI/trace metadata. Behavior behind this
|
|
279
288
|
flag may change or be removed without notice.
|
|
289
|
+
|
|
290
|
+
Set only through the launching shell or global `~/.deepagents/.env`; a project
|
|
291
|
+
`.env` cannot set it.
|
|
280
292
|
"""
|
|
281
293
|
|
|
282
294
|
EXTENSIONS = "DEEPAGENTS_CODE_EXTENSIONS"
|
|
283
295
|
"""Enable loading installed-plugin and trusted-project Python extensions."""
|
|
284
296
|
|
|
285
297
|
EXTENSIONS_TRUST = "DEEPAGENTS_CODE_EXTENSIONS_TRUST"
|
|
286
|
-
"""Default project extension trust policy: `ask`, `always`, or `never`.
|
|
298
|
+
"""Default project extension trust policy: `ask`, `always`, or `never`.
|
|
299
|
+
|
|
300
|
+
Set only through the launching shell or global `~/.deepagents/.env`; a project
|
|
301
|
+
`.env` cannot set it.
|
|
302
|
+
"""
|
|
287
303
|
|
|
288
304
|
EXTERNAL_EVENT_SOCKET = "DEEPAGENTS_CODE_EXTERNAL_EVENT_SOCKET"
|
|
289
305
|
"""Enable the local Unix-socket external event listener.
|
|
@@ -296,7 +312,11 @@ EXTERNAL_EVENT_SOCKET_PATH = "DEEPAGENTS_CODE_EXTERNAL_EVENT_SOCKET_PATH"
|
|
|
296
312
|
"""Override the default Unix-socket path for the external event listener."""
|
|
297
313
|
|
|
298
314
|
EXTRA_SKILLS_DIRS = "DEEPAGENTS_CODE_EXTRA_SKILLS_DIRS"
|
|
299
|
-
"""Paths added to the skill containment allowlist, split with `os.pathsep`.
|
|
315
|
+
"""Paths added to the skill containment allowlist, split with `os.pathsep`.
|
|
316
|
+
|
|
317
|
+
Set only through the launching shell or global `~/.deepagents/.env`; a project
|
|
318
|
+
`.env` cannot set it.
|
|
319
|
+
"""
|
|
300
320
|
|
|
301
321
|
GOAL_AUTO_ACCEPT_CRITERIA = "DEEPAGENTS_CODE_GOAL_AUTO_ACCEPT_CRITERIA"
|
|
302
322
|
"""Apply generated goal criteria automatically in Auto mode.
|
|
@@ -485,6 +505,8 @@ PLUGIN_CACHE_DIR = "DEEPAGENTS_CODE_PLUGIN_CACHE_DIR"
|
|
|
485
505
|
"""Override the plugin install/marketplace cache root.
|
|
486
506
|
|
|
487
507
|
When unset, plugins are stored under `DEFAULT_CONFIG_DIR / "plugins"`.
|
|
508
|
+
Set only through the launching shell or global `~/.deepagents/.env`; a project
|
|
509
|
+
`.env` cannot set it.
|
|
488
510
|
"""
|
|
489
511
|
|
|
490
512
|
PRICES_AUTO_UPDATE = "DEEPAGENTS_CODE_PRICES_AUTO_UPDATE"
|
|
@@ -564,7 +586,11 @@ SERVER_ENV_PREFIX = "DEEPAGENTS_CODE_SERVER_"
|
|
|
564
586
|
"""Environment variable prefix used to pass CLI config to the server subprocess."""
|
|
565
587
|
|
|
566
588
|
SHELL_ALLOW_LIST = "DEEPAGENTS_CODE_SHELL_ALLOW_LIST"
|
|
567
|
-
"""Comma-separated shell commands to allow (or 'recommended'/'all').
|
|
589
|
+
"""Comma-separated shell commands to allow (or 'recommended'/'all').
|
|
590
|
+
|
|
591
|
+
Set only through the launching shell or global `~/.deepagents/.env`; a project
|
|
592
|
+
`.env` cannot set it.
|
|
593
|
+
"""
|
|
568
594
|
|
|
569
595
|
SHOW_HEADER = "DEEPAGENTS_CODE_SHOW_HEADER"
|
|
570
596
|
"""Show Textual's native header bar at the top of the TUI when enabled."""
|
deepagents_code/_git.py
CHANGED
|
@@ -17,6 +17,9 @@ _GIT_DIR_POINTER_PREFIX = "gitdir: "
|
|
|
17
17
|
_GIT_HEAD_REF_PREFIX = "ref: "
|
|
18
18
|
"""Prefix used by `HEAD` when it points at a named ref instead of a commit."""
|
|
19
19
|
|
|
20
|
+
_REFTABLE_HEAD_PLACEHOLDER = "refs/heads/.invalid"
|
|
21
|
+
"""Ref a reftable repository writes to `HEAD`; the real refs live in `reftable/`."""
|
|
22
|
+
|
|
20
23
|
_GIT_REF_PREFIXES = ("refs/heads/", "refs/remotes/", "refs/tags/", "refs/")
|
|
21
24
|
"""Known git ref prefixes stripped when formatting a branch-like display name."""
|
|
22
25
|
|
|
@@ -411,7 +414,8 @@ def read_git_branch_from_filesystem(path: str | Path) -> str | None:
|
|
|
411
414
|
Returns:
|
|
412
415
|
The abbreviated branch name, `HEAD` for detached HEAD, an empty string
|
|
413
416
|
when `path` is not inside a git repository, or `None` when metadata
|
|
414
|
-
exists but cannot be parsed confidently
|
|
417
|
+
exists but cannot be parsed confidently (including reftable
|
|
418
|
+
repositories, whose `HEAD` file only holds a placeholder).
|
|
415
419
|
"""
|
|
416
420
|
git_dir = find_git_dir(path)
|
|
417
421
|
if git_dir is None:
|
|
@@ -431,7 +435,9 @@ def read_git_branch_from_filesystem(path: str | Path) -> str | None:
|
|
|
431
435
|
return ""
|
|
432
436
|
if head.startswith(_GIT_HEAD_REF_PREFIX):
|
|
433
437
|
ref = head.removeprefix(_GIT_HEAD_REF_PREFIX).strip()
|
|
434
|
-
|
|
438
|
+
if not ref or ref == _REFTABLE_HEAD_PLACEHOLDER:
|
|
439
|
+
return None
|
|
440
|
+
return _abbreviate_git_ref(ref)
|
|
435
441
|
return "HEAD"
|
|
436
442
|
|
|
437
443
|
|
deepagents_code/_js_cost.py
CHANGED
|
@@ -7,6 +7,7 @@ import dataclasses
|
|
|
7
7
|
import hashlib
|
|
8
8
|
import itertools
|
|
9
9
|
import json
|
|
10
|
+
import logging
|
|
10
11
|
import math
|
|
11
12
|
from collections.abc import Mapping
|
|
12
13
|
from typing import TYPE_CHECKING, override
|
|
@@ -19,6 +20,7 @@ from langgraph.checkpoint.base import (
|
|
|
19
20
|
empty_checkpoint,
|
|
20
21
|
)
|
|
21
22
|
from langgraph.config import get_config
|
|
23
|
+
from langgraph.errors import GraphBubbleUp, NodeCancelledError
|
|
22
24
|
from langgraph.types import Command
|
|
23
25
|
|
|
24
26
|
from deepagents_code.cost_tracking import (
|
|
@@ -37,6 +39,8 @@ if TYPE_CHECKING:
|
|
|
37
39
|
|
|
38
40
|
from deepagents_code.cost_tracking import CostBreakdown
|
|
39
41
|
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
40
44
|
_ACCOUNTING_KEY = "__deepagents_js_cost_owner"
|
|
41
45
|
_RESPONSE_FORMAT_KEY = "__deepagents_subagent_response_format"
|
|
42
46
|
|
|
@@ -172,6 +176,17 @@ def _cost_task(
|
|
|
172
176
|
config=config,
|
|
173
177
|
tool_call_id=runtime.tool_call_id,
|
|
174
178
|
)
|
|
179
|
+
except asyncio.CancelledError, GraphBubbleUp, NodeCancelledError:
|
|
180
|
+
raise
|
|
181
|
+
except Exception as exc:
|
|
182
|
+
logger.debug("Subagent %r failed", subagent_type, exc_info=exc)
|
|
183
|
+
return {
|
|
184
|
+
"error": (
|
|
185
|
+
f"Subagent {subagent_type!r} failed: "
|
|
186
|
+
f"{type(exc).__name__}: {str(exc)[:300]}. You may retry this task."
|
|
187
|
+
),
|
|
188
|
+
"failed": True,
|
|
189
|
+
}
|
|
175
190
|
finally:
|
|
176
191
|
if invocation is not None:
|
|
177
192
|
active.discard(invocation)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Run untraced `@traceable` bookkeeping inline on the server loop.
|
|
2
|
+
|
|
3
|
+
LangChain wraps every middleware `awrap_tool_call` and `awrap_model_call` hook
|
|
4
|
+
in `langsmith.traceable`. Its async wrapper sends two calls to a worker thread
|
|
5
|
+
per hook: one to set up the run and one to end it. It does this even when
|
|
6
|
+
tracing is off and both calls only build an empty container. About 20 traced
|
|
7
|
+
hooks run per tool round trip, so each round trip pays for about 40 thread
|
|
8
|
+
hops that do no work.
|
|
9
|
+
|
|
10
|
+
`install_untraced_inline_override` installs a LangSmith runtime override. The
|
|
11
|
+
override runs those two calls inline when they cannot trace, and gives every
|
|
12
|
+
other call to LangSmith's default thread hop.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import functools
|
|
18
|
+
import logging
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
import contextvars
|
|
23
|
+
from collections.abc import Awaitable, Callable
|
|
24
|
+
|
|
25
|
+
DefaultAioToThread = Callable[..., Awaitable[Any]]
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _target(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
31
|
+
"""Return the function a `functools.partial` wraps, else `func` itself.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
The underlying callable.
|
|
35
|
+
"""
|
|
36
|
+
return func.func if isinstance(func, functools.partial) else func
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _runs_inline(
|
|
40
|
+
ctx: contextvars.Context,
|
|
41
|
+
func: Callable[..., Any],
|
|
42
|
+
args: tuple[Any, ...],
|
|
43
|
+
) -> bool:
|
|
44
|
+
"""Whether a `traceable` bookkeeping call cannot trace, so it is cheap.
|
|
45
|
+
|
|
46
|
+
Only run setup and run end qualify. Any other call (for example an async
|
|
47
|
+
`Client` API method) can do network I/O, so it keeps the thread hop.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
`True` when the call is safe to run on the event loop.
|
|
51
|
+
"""
|
|
52
|
+
from langsmith import run_helpers
|
|
53
|
+
from langsmith.utils import tracing_is_enabled
|
|
54
|
+
|
|
55
|
+
target = _target(func)
|
|
56
|
+
if target is getattr(run_helpers, "_setup_run", None):
|
|
57
|
+
return not ctx.run(tracing_is_enabled)
|
|
58
|
+
if target is getattr(run_helpers, "_handle_container_end", None):
|
|
59
|
+
container = args[0] if args else None
|
|
60
|
+
return isinstance(container, dict) and container.get("new_run") is None
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def _untraced_inline_aio_to_thread(
|
|
65
|
+
default: DefaultAioToThread,
|
|
66
|
+
ctx: contextvars.Context,
|
|
67
|
+
func: Callable[..., Any],
|
|
68
|
+
/,
|
|
69
|
+
*args: Any, # forwards arbitrary LangSmith call arguments
|
|
70
|
+
**kwargs: Any, # forwards arbitrary LangSmith call arguments
|
|
71
|
+
) -> Any: # noqa: ANN401 # returns whatever `func` returns
|
|
72
|
+
"""Run untraced bookkeeping inline; hop to a thread for everything else.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
The result of `func`.
|
|
76
|
+
"""
|
|
77
|
+
if _runs_inline(ctx, func, args):
|
|
78
|
+
return ctx.run(func, *args, **kwargs)
|
|
79
|
+
return await default(ctx, func, *args, **kwargs)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def install_untraced_inline_override() -> None:
|
|
83
|
+
"""Install the override for this process.
|
|
84
|
+
|
|
85
|
+
The override is process-global. Call it only in the server process, which
|
|
86
|
+
owns its event loop and pins one tracing configuration for its lifetime.
|
|
87
|
+
"""
|
|
88
|
+
import langsmith
|
|
89
|
+
|
|
90
|
+
langsmith.set_runtime_overrides(aio_to_thread=_untraced_inline_aio_to_thread)
|
|
91
|
+
logger.debug("Installed inline LangSmith runtime override for untraced runs")
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Wake the in-memory run queue when a run is created.
|
|
2
|
+
|
|
3
|
+
The server runs on `langgraph_runtime_inmem`. Its background scheduler polls
|
|
4
|
+
for pending runs: `Runs.next(wait=True)` sleeps 0.5 s, then claims only runs
|
|
5
|
+
created before that sleep began. Every user turn and every interrupt resume is
|
|
6
|
+
a new run, so each one waits 0.5-1.0 s before a worker starts it.
|
|
7
|
+
|
|
8
|
+
This patch makes `Runs.put` set an event and makes a waiting `Runs.next` wait
|
|
9
|
+
on that event instead of the fixed sleep. The stock `Runs.next` still claims
|
|
10
|
+
the runs, so queue order and per-thread locking do not change. The 0.5 s
|
|
11
|
+
timeout stays as a fallback, so a missed wakeup costs at most the stock delay.
|
|
12
|
+
|
|
13
|
+
`Runs.next` and `Runs.put` are private APIs. If their shape is not what this
|
|
14
|
+
module expects, the patch logs at debug level and does nothing. Remove this
|
|
15
|
+
module when `langgraph_runtime_inmem` wakes the scheduler on put.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import contextlib
|
|
22
|
+
import functools
|
|
23
|
+
import inspect
|
|
24
|
+
import logging
|
|
25
|
+
from typing import TYPE_CHECKING
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
_POLL_INTERVAL = 0.5
|
|
33
|
+
"""Fallback wait in seconds; matches the stock `Runs.next` sleep."""
|
|
34
|
+
|
|
35
|
+
_PATCHED_MARKER = "_dcode_run_queue_wakeup"
|
|
36
|
+
"""Attribute set on each wrapper so a second install is a no-op."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _Wakeup:
|
|
40
|
+
"""Edge-triggered signal from `Runs.put` to a waiting `Runs.next`.
|
|
41
|
+
|
|
42
|
+
The event binds to the scheduler loop on the first wait. A put before
|
|
43
|
+
that first wait is not lost: the scheduler then waits the fallback
|
|
44
|
+
interval, as stock does.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self) -> None:
|
|
48
|
+
self._event: asyncio.Event | None = None
|
|
49
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
50
|
+
|
|
51
|
+
async def wait(self) -> None:
|
|
52
|
+
"""Wait until a run is put, or until the fallback interval passes."""
|
|
53
|
+
if self._event is None:
|
|
54
|
+
self._event = asyncio.Event()
|
|
55
|
+
self._loop = asyncio.get_running_loop()
|
|
56
|
+
with contextlib.suppress(TimeoutError):
|
|
57
|
+
async with asyncio.timeout(_POLL_INTERVAL):
|
|
58
|
+
await self._event.wait()
|
|
59
|
+
self._event.clear()
|
|
60
|
+
|
|
61
|
+
def notify(self) -> None:
|
|
62
|
+
"""Wake the scheduler; safe from any thread and after loop close."""
|
|
63
|
+
event, loop = self._event, self._loop
|
|
64
|
+
if event is None or loop is None:
|
|
65
|
+
return
|
|
66
|
+
# The loop can close during shutdown; the wakeup is then moot.
|
|
67
|
+
with contextlib.suppress(RuntimeError):
|
|
68
|
+
loop.call_soon_threadsafe(event.set)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
_WAKEUP = _Wakeup()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _wrap_next(
|
|
75
|
+
original: Callable[[bool, int], AsyncIterator[object]],
|
|
76
|
+
) -> Callable[[bool, int], AsyncIterator[object]]:
|
|
77
|
+
"""Replace the fixed poll sleep with a wait on `_WAKEUP`.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
A drop-in replacement for the upstream `Runs.next`.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
@functools.wraps(original)
|
|
84
|
+
async def next_runs(wait: bool, limit: int = 1) -> AsyncIterator[object]:
|
|
85
|
+
if wait:
|
|
86
|
+
await _WAKEUP.wait()
|
|
87
|
+
# `wait=False` skips the sleep and claims runs created before now,
|
|
88
|
+
# which includes the run that woke us.
|
|
89
|
+
async for item in original(False, limit):
|
|
90
|
+
yield item
|
|
91
|
+
|
|
92
|
+
setattr(next_runs, _PATCHED_MARKER, True)
|
|
93
|
+
return next_runs
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _wrap_put(
|
|
97
|
+
original: Callable[..., Awaitable[object]],
|
|
98
|
+
) -> Callable[..., Awaitable[object]]:
|
|
99
|
+
"""Notify `_WAKEUP` after each run insert.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
A drop-in replacement for the upstream `Runs.put`.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
@functools.wraps(original)
|
|
106
|
+
async def put(*args: object, **kwargs: object) -> object:
|
|
107
|
+
result = await original(*args, **kwargs)
|
|
108
|
+
_WAKEUP.notify()
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
setattr(put, _PATCHED_MARKER, True)
|
|
112
|
+
return put
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _has_expected_shape(runs: type) -> bool:
|
|
116
|
+
"""Check that `Runs.next` and `Runs.put` look like the pinned version.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
`True` when both are static methods with the expected signatures.
|
|
120
|
+
"""
|
|
121
|
+
next_attr = runs.__dict__.get("next")
|
|
122
|
+
put_attr = runs.__dict__.get("put")
|
|
123
|
+
if not isinstance(next_attr, staticmethod) or not isinstance(
|
|
124
|
+
put_attr, staticmethod
|
|
125
|
+
):
|
|
126
|
+
return False
|
|
127
|
+
next_fn, put_fn = next_attr.__func__, put_attr.__func__
|
|
128
|
+
params = list(inspect.signature(next_fn).parameters)
|
|
129
|
+
return (
|
|
130
|
+
inspect.isasyncgenfunction(next_fn)
|
|
131
|
+
and params[:2] == ["wait", "limit"]
|
|
132
|
+
and inspect.iscoroutinefunction(put_fn)
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def install_run_queue_wakeup() -> bool:
|
|
137
|
+
"""Patch the in-memory run queue to start new runs without the poll delay.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
`True` when the patch is active after the call, else `False`.
|
|
141
|
+
"""
|
|
142
|
+
try:
|
|
143
|
+
from langgraph_runtime_inmem.ops import Runs
|
|
144
|
+
except ImportError:
|
|
145
|
+
logger.debug("Run queue wakeup skipped: in-memory runtime not installed")
|
|
146
|
+
return False
|
|
147
|
+
if getattr(Runs.next, _PATCHED_MARKER, False):
|
|
148
|
+
return True
|
|
149
|
+
if not _has_expected_shape(Runs):
|
|
150
|
+
logger.debug("Run queue wakeup skipped: Runs.next/put changed upstream")
|
|
151
|
+
return False
|
|
152
|
+
wrappers = {"next": _wrap_next, "put": _wrap_put}
|
|
153
|
+
for name, wrap in wrappers.items():
|
|
154
|
+
setattr(Runs, name, staticmethod(wrap(getattr(Runs, name))))
|
|
155
|
+
logger.debug("Run queue wakeup installed")
|
|
156
|
+
return True
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Presence lock telling a launch whether other dcode sessions share its install.
|
|
2
|
+
|
|
3
|
+
A Python migration rebuilds the uv tool environment, which breaks every dcode
|
|
4
|
+
process — and every server it spawned — still running from the old one. Startup
|
|
5
|
+
auto-update may only migrate when no other session uses the installation, and
|
|
6
|
+
it cannot tell that from a process listing: shims, renamed symlinks, and the
|
|
7
|
+
server bootstrap all run under different names.
|
|
8
|
+
|
|
9
|
+
Instead, each dcode process of a non-editable install (editable installs never
|
|
10
|
+
auto-update) holds a shared `flock` on `SESSION_LOCK_FILE` for its lifetime,
|
|
11
|
+
and hands the descriptor to the servers it spawns so they hold
|
|
12
|
+
the same lock. A process asking "am I alone?" tries to take the lock
|
|
13
|
+
exclusively; that succeeds only when no other session's open file holds it.
|
|
14
|
+
The kernel drops a lock when its last descriptor closes, so a crashed session
|
|
15
|
+
can never leave a stale claim behind.
|
|
16
|
+
|
|
17
|
+
The answer is a snapshot. It cannot see sessions of versions from before this
|
|
18
|
+
lock existed, and nothing stops a session that launches while the migration
|
|
19
|
+
runs; both are accepted as rare and recoverable by a relaunch.
|
|
20
|
+
|
|
21
|
+
POSIX only. On Windows, or whenever the lock cannot be taken, a process is
|
|
22
|
+
never reported as alone, so the migration stays deferred to `dcode update`.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
import sys
|
|
30
|
+
from typing import TYPE_CHECKING
|
|
31
|
+
|
|
32
|
+
from deepagents_code._paths import PATHS
|
|
33
|
+
|
|
34
|
+
if sys.platform != "win32":
|
|
35
|
+
import fcntl
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
SESSION_LOCK_FILE: Path = PATHS.installation.locks_dir / "sessions.lock"
|
|
43
|
+
"""Advisory lock held shared by every registered dcode process of one installation.
|
|
44
|
+
|
|
45
|
+
Lives beside `update.lock`, outside the tool environment, so a migration that
|
|
46
|
+
rebuilds the environment keeps the same file. Carries no data.
|
|
47
|
+
|
|
48
|
+
Installation-scoped only, with no per-profile fallback: a profile-scoped lock
|
|
49
|
+
would hide sessions of other profiles, and over-reporting "alone" is the one
|
|
50
|
+
failure this lock must not have.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
_held_fd: int | None = None
|
|
54
|
+
"""Descriptor holding this process's shared lock, or `None` when not held."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def hold_session_lock() -> None:
|
|
58
|
+
"""Register this process as a running session for its lifetime.
|
|
59
|
+
|
|
60
|
+
Idempotent and fail-soft: when the lock cannot be taken, the process runs
|
|
61
|
+
unregistered and `is_sole_session` reports `False`. The descriptor is
|
|
62
|
+
non-inheritable, so the re-exec after an update drops it and the new
|
|
63
|
+
process registers itself again.
|
|
64
|
+
"""
|
|
65
|
+
global _held_fd # noqa: PLW0603 # one registration per process
|
|
66
|
+
if _held_fd is not None or sys.platform == "win32":
|
|
67
|
+
return
|
|
68
|
+
try:
|
|
69
|
+
SESSION_LOCK_FILE.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
70
|
+
fd = os.open(SESSION_LOCK_FILE, os.O_RDWR | os.O_CREAT, 0o600)
|
|
71
|
+
except OSError:
|
|
72
|
+
logger.warning(
|
|
73
|
+
"Could not open session lock %s", SESSION_LOCK_FILE, exc_info=True
|
|
74
|
+
)
|
|
75
|
+
return
|
|
76
|
+
try:
|
|
77
|
+
# Blocks only while another process is inside `is_sole_session`, the
|
|
78
|
+
# one place the lock is ever held exclusively, and only for an instant.
|
|
79
|
+
# Failing fast instead would leave this session invisible to that check.
|
|
80
|
+
fcntl.flock(fd, fcntl.LOCK_SH)
|
|
81
|
+
except OSError:
|
|
82
|
+
logger.warning(
|
|
83
|
+
"Could not take session lock %s", SESSION_LOCK_FILE, exc_info=True
|
|
84
|
+
)
|
|
85
|
+
os.close(fd)
|
|
86
|
+
return
|
|
87
|
+
_held_fd = fd
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def session_lock_fds() -> tuple[int, ...]:
|
|
91
|
+
"""Return descriptors a spawned server must inherit to count as this session.
|
|
92
|
+
|
|
93
|
+
`flock` locks belong to the open file description, so a child given the
|
|
94
|
+
descriptor holds the same lock: the server keeps the install marked in use
|
|
95
|
+
if it outlives its client, and does not count as a second session.
|
|
96
|
+
"""
|
|
97
|
+
return () if _held_fd is None else (_held_fd,)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def session_lock_unavailable() -> bool:
|
|
101
|
+
"""Return whether this POSIX process failed to register as a session.
|
|
102
|
+
|
|
103
|
+
Lets callers tell "other sessions are running" apart from "presence cannot
|
|
104
|
+
be known", which quitting other sessions would never fix.
|
|
105
|
+
"""
|
|
106
|
+
return sys.platform != "win32" and _held_fd is None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def is_sole_session() -> bool:
|
|
110
|
+
"""Return whether no other dcode session holds this installation.
|
|
111
|
+
|
|
112
|
+
Converting a `flock` is not atomic: the shared lock is released before the
|
|
113
|
+
exclusive one is tried, so two processes checking at once could each see
|
|
114
|
+
the other as absent. An exclusive lock on `sessions.check.lock` beside
|
|
115
|
+
`SESSION_LOCK_FILE` admits one checker at a time; a process that finds it
|
|
116
|
+
taken is not alone.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
`True` only when this process holds the session lock and could take
|
|
120
|
+
it exclusively. `False` when another session holds it, or when the
|
|
121
|
+
lock is unavailable and presence cannot be known.
|
|
122
|
+
"""
|
|
123
|
+
if _held_fd is None:
|
|
124
|
+
return False
|
|
125
|
+
check_file = SESSION_LOCK_FILE.with_name("sessions.check.lock")
|
|
126
|
+
try:
|
|
127
|
+
guard = os.open(check_file, os.O_RDWR | os.O_CREAT, 0o600)
|
|
128
|
+
except OSError:
|
|
129
|
+
logger.warning("Could not open %s", check_file, exc_info=True)
|
|
130
|
+
return False
|
|
131
|
+
try:
|
|
132
|
+
fcntl.flock(guard, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
133
|
+
except OSError:
|
|
134
|
+
return False
|
|
135
|
+
else:
|
|
136
|
+
return _probe_exclusive(_held_fd)
|
|
137
|
+
finally:
|
|
138
|
+
os.close(guard)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _probe_exclusive(fd: int) -> bool:
|
|
142
|
+
"""Try to take the session lock exclusively, then hold it shared again.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
Whether the exclusive attempt succeeded and the shared lock is back.
|
|
146
|
+
"""
|
|
147
|
+
try:
|
|
148
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
149
|
+
except BlockingIOError:
|
|
150
|
+
sole = False
|
|
151
|
+
except OSError:
|
|
152
|
+
logger.warning(
|
|
153
|
+
"Could not check session lock %s", SESSION_LOCK_FILE, exc_info=True
|
|
154
|
+
)
|
|
155
|
+
sole = False
|
|
156
|
+
else:
|
|
157
|
+
sole = True
|
|
158
|
+
# Re-take it on both branches: on Linux a failed upgrade attempt has already
|
|
159
|
+
# released the shared lock.
|
|
160
|
+
return _restore_shared(fd) and sole
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _restore_shared(fd: int) -> bool:
|
|
164
|
+
"""Hold the session lock shared again, or unregister on failure.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Whether this process is still registered.
|
|
168
|
+
"""
|
|
169
|
+
global _held_fd # noqa: PLW0603 # unregister a lock this process lost
|
|
170
|
+
try:
|
|
171
|
+
# Blocking is safe: the check lock is held, so no other process holds
|
|
172
|
+
# this lock exclusively.
|
|
173
|
+
fcntl.flock(fd, fcntl.LOCK_SH)
|
|
174
|
+
except OSError:
|
|
175
|
+
logger.warning(
|
|
176
|
+
"Could not restore the shared session lock %s; other dcode sessions "
|
|
177
|
+
"will not see this one",
|
|
178
|
+
SESSION_LOCK_FILE,
|
|
179
|
+
exc_info=True,
|
|
180
|
+
)
|
|
181
|
+
_held_fd = None
|
|
182
|
+
os.close(fd)
|
|
183
|
+
return False
|
|
184
|
+
return True
|
|
@@ -293,6 +293,11 @@ else:
|
|
|
293
293
|
|
|
294
294
|
|
|
295
295
|
def _rendered_text(widget: Widget) -> str | None:
|
|
296
|
+
# A widget that renders a custom visual (such as `FinalMarkdown`) can
|
|
297
|
+
# publish the text its selection offsets index into.
|
|
298
|
+
selectable = getattr(widget, "selectable_text", None)
|
|
299
|
+
if isinstance(selectable, str):
|
|
300
|
+
return selectable
|
|
296
301
|
visual = widget._render() # match Textual's get_selection path
|
|
297
302
|
if isinstance(visual, (Content, Text)):
|
|
298
303
|
return str(visual)
|