dex-agents-deepagents-cli 0.6.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.
@@ -0,0 +1,443 @@
1
+ # Copyright (c) 2026 Super Durable, Inc.
2
+ #
3
+ # Licensed under the Sustainable Use License 1.0.
4
+ # You may not use this file except in compliance with the License.
5
+ # See the LICENSE file in the repository root.
6
+ #
7
+ # SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0
8
+
9
+ """The Deep Agents harness: a ``create_deep_agent`` agent on the Dex LangGraph adapter."""
10
+
11
+ from __future__ import annotations
12
+
13
+ import dataclasses
14
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import dex_agents_cli_core # noqa: F401 # first: it configures gRPC before anything imports it
20
+
21
+ # isort: split
22
+
23
+ from deepagents import create_deep_agent
24
+ from langchain.agents.middleware import (
25
+ AgentMiddleware,
26
+ ModelRequest,
27
+ ModelResponse,
28
+ TodoListMiddleware,
29
+ )
30
+ from langchain.chat_models import init_chat_model
31
+ from langchain_core.language_models import BaseChatModel
32
+ from langchain_core.messages import SystemMessage
33
+ from langchain_core.tools import BaseTool
34
+
35
+ from dex_agents import (
36
+ AgentSessionFlow,
37
+ AgentSessionOptions,
38
+ AgentTurnContext,
39
+ AgentTurnOutcome,
40
+ DeferredToolCall,
41
+ DeferredToolExecution,
42
+ DeferredToolOutcome,
43
+ ToolCallsRequested,
44
+ ToolEffect,
45
+ ToolExecutionError,
46
+ ToolPolicy,
47
+ )
48
+ from dex_agents.builtin_loop import ToolSource
49
+ from dex_agents_cli_core import (
50
+ CONNECTORS_TOOL_SOURCE_NAME,
51
+ ConfigError,
52
+ EffectiveConfig,
53
+ HarnessContext,
54
+ LocalToolGroup,
55
+ current_turn_config,
56
+ is_local_tool_group_enabled,
57
+ resolve_turn_tool_policy,
58
+ )
59
+ from dex_agents_deepagents_cli.connector_tool_names import PrefixedConnectorToolSource
60
+ from dex_agents_deepagents_cli.workspace_backend import (
61
+ bind_artifacts_session,
62
+ build_workspace_backend,
63
+ default_artifacts_directory,
64
+ remove_stale_session_artifacts,
65
+ )
66
+ from dex_agents_langgraph import (
67
+ CompiledGraphSource,
68
+ DurableToolCallMiddleware,
69
+ LangGraphAgentRunner,
70
+ )
71
+
72
+ #: The Flow type of the command's sessions, before the CLI core namespaces it.
73
+ DEEPAGENTS_CLI_FLOW_TYPE = "DexAgentsDeepAgentsCliSession"
74
+
75
+ #: The model the command uses when the configuration file sets none.
76
+ DEFAULT_DEEPAGENTS_MODEL = "openai:gpt-5.2"
77
+
78
+ #: The instructions the agent gets when the configuration sets none.
79
+ DEFAULT_INSTRUCTIONS = (
80
+ "You are a helpful assistant. Use your tools to work with the files and commands "
81
+ "in your workspace and with the connected services. Plan multi-step work with "
82
+ "write_todos."
83
+ )
84
+
85
+ #: The todo tool's name, as Deep Agents and LangChain name it.
86
+ WRITE_TODOS_TOOL_NAME = "write_todos"
87
+
88
+ #: Deep Agents' subagent tool. A subagent runs inside the tool Step of its call.
89
+ TASK_TOOL_NAME = "task"
90
+
91
+ #: Deep Agents' shell tool.
92
+ EXECUTE_TOOL_NAME = "execute"
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class DeepAgentsToolRule:
97
+ """How the command runs one of Deep Agents' own tools.
98
+
99
+ Attributes:
100
+ effect: The effect class of each call, which decides its retries.
101
+ groups: The local tool groups the turn must switch on, or the tool
102
+ is neither offered nor run.
103
+ """
104
+
105
+ effect: ToolEffect
106
+ groups: frozenset[LocalToolGroup] = frozenset()
107
+
108
+ def is_enabled(self, turn: EffectiveConfig) -> bool:
109
+ """Report whether the turn switches on every group the tool needs."""
110
+ return all(is_local_tool_group_enabled(turn, group) for group in self.groups)
111
+
112
+
113
+ _FILES = frozenset({LocalToolGroup.FILES})
114
+
115
+ #: The rule of every tool the agent's graph has. No tool waits for approval
116
+ #: unless ``approval.always`` names it, as in Deep Agents without ``interrupt_on``.
117
+ DEEP_AGENTS_TOOL_RULES: Mapping[str, DeepAgentsToolRule] = {
118
+ "ls": DeepAgentsToolRule(ToolEffect.READ, _FILES),
119
+ "read_file": DeepAgentsToolRule(ToolEffect.READ, _FILES),
120
+ "glob": DeepAgentsToolRule(ToolEffect.READ, _FILES),
121
+ "grep": DeepAgentsToolRule(ToolEffect.READ, _FILES),
122
+ "write_file": DeepAgentsToolRule(ToolEffect.IDEMPOTENT_WRITE, _FILES),
123
+ # A rerun of a replace_all edit can apply it twice.
124
+ "edit_file": DeepAgentsToolRule(ToolEffect.NON_IDEMPOTENT_WRITE, _FILES),
125
+ "delete": DeepAgentsToolRule(ToolEffect.IDEMPOTENT_WRITE, _FILES),
126
+ EXECUTE_TOOL_NAME: DeepAgentsToolRule(
127
+ ToolEffect.NON_IDEMPOTENT_WRITE, frozenset({LocalToolGroup.SHELL})
128
+ ),
129
+ # A subagent may use every file tool and execute, without their own Steps or approvals.
130
+ TASK_TOOL_NAME: DeepAgentsToolRule(
131
+ ToolEffect.NON_IDEMPOTENT_WRITE, frozenset({LocalToolGroup.FILES, LocalToolGroup.SHELL})
132
+ ),
133
+ WRITE_TODOS_TOOL_NAME: DeepAgentsToolRule(ToolEffect.READ),
134
+ }
135
+
136
+ #: Tools whose ``approval.always`` entry makes ``task`` wait too: a subagent calls them unseen.
137
+ _TASK_APPROVAL_TOOL_NAMES = frozenset(DEEP_AGENTS_TOOL_RULES)
138
+
139
+
140
+ class DeepAgentsHarness:
141
+ """The ``dex-agents-deepagents`` command: a Deep Agents agent in the terminal.
142
+
143
+ The agent comes from ``create_deep_agent`` with Deep Agents' own tools:
144
+ the file tools and ``execute`` on the workspace, ``write_todos``, and the
145
+ ``task`` subagent tool. The CLI core's connector, web fetch, and browser
146
+ tools come on top. The agent runs on the Dex LangGraph adapter, so every
147
+ model call is a ``RunModel`` Step and every tool call its own tool Step,
148
+ which runs Deep Agents' tool middleware once. As in Deep Agents without
149
+ ``interrupt_on``, its own tools run without approval unless
150
+ ``approval.always`` names them; connector writes always wait for one.
151
+ """
152
+
153
+ name = "deepagents"
154
+ config_section = "deepagents"
155
+ flow_type = DEEPAGENTS_CLI_FLOW_TYPE
156
+ default_model: str | None = DEFAULT_DEEPAGENTS_MODEL
157
+ replaced_local_tools: frozenset[LocalToolGroup] = frozenset(
158
+ {LocalToolGroup.FILES, LocalToolGroup.SHELL}
159
+ )
160
+
161
+ def create_session_flow(self, context: HarnessContext) -> AgentSessionFlow:
162
+ """Build the Deep Agents session Flow from the effective configuration.
163
+
164
+ Args:
165
+ context: The configuration, workspace, and tool sources.
166
+
167
+ Returns:
168
+ The Flow, with ``context.flow_type`` as its type.
169
+
170
+ It first deletes the artifacts of sessions that changed none of
171
+ them within ``SESSION_ARTIFACTS_RETENTION``.
172
+
173
+ Raises:
174
+ ConfigError: If the ``deepagents`` section has a key; it has no options yet.
175
+ """
176
+ validate_deepagents_section(context.harness_options)
177
+ remove_stale_session_artifacts(default_artifacts_directory())
178
+ file_model = context.config.model
179
+ workspace = context.workspace
180
+ runner = DeepAgentsAgentRunner(
181
+ context,
182
+ lambda: build_deep_agent(file_model, workspace),
183
+ tool_sources=deep_agents_tool_sources(context.tool_sources),
184
+ select_turn_chat_model=TurnChatModelSelector(file_model),
185
+ )
186
+ return AgentSessionFlow(
187
+ runner,
188
+ flow_type=context.flow_type,
189
+ options=AgentSessionOptions(
190
+ summary_attributes_indexed=context.is_session_summary_indexed
191
+ ),
192
+ )
193
+
194
+
195
+ def build_deep_agent(file_model: str, workspace: Path) -> Any:
196
+ """Build the command's Deep Agents graph.
197
+
198
+ Deep Agents' file tools and ``execute`` work on the workspace through
199
+ ``build_workspace_backend``. LangChain's ``TodoListMiddleware`` adds
200
+ ``write_todos``. The summarization is replaced by middleware that adds
201
+ nothing: it would make an extra model call with the file's model inside a
202
+ ``RunModel`` Step.
203
+
204
+ Args:
205
+ file_model: The configuration file's model; each turn may override it.
206
+ A subagent of ``task`` always uses it.
207
+ workspace: The absolute workspace directory.
208
+
209
+ Returns:
210
+ The compiled agent graph.
211
+ """
212
+ middleware: list[AgentMiddleware[Any, Any, Any]] = [
213
+ TodoListMiddleware(),
214
+ DurableToolCallMiddleware(),
215
+ TurnInstructionsMiddleware(workspace),
216
+ LocalToolSwitchesMiddleware(),
217
+ _WithoutSummarization(),
218
+ ]
219
+ return create_deep_agent(
220
+ deep_agents_chat_model(file_model),
221
+ backend=build_workspace_backend(workspace),
222
+ middleware=middleware,
223
+ )
224
+
225
+
226
+ def deep_agents_tool_sources(tool_sources: Sequence[ToolSource]) -> list[ToolSource]:
227
+ """Return the CLI core's tool sources, with connector tools that fit beside Deep Agents'.
228
+
229
+ Args:
230
+ tool_sources: ``HarnessContext.tool_sources``.
231
+
232
+ Returns:
233
+ The sources; the connectors source prefixes a tool whose name a Deep
234
+ Agents tool uses and asks approval for every write.
235
+ """
236
+ return [
237
+ PrefixedConnectorToolSource(source, DEEP_AGENTS_TOOL_RULES.keys())
238
+ if source.source_name == CONNECTORS_TOOL_SOURCE_NAME
239
+ else source
240
+ for source in tool_sources
241
+ ]
242
+
243
+
244
+ class DeepAgentsAgentRunner(LangGraphAgentRunner):
245
+ """The LangGraph runner with the CLI core's per-turn rules for Deep Agents' own tools.
246
+
247
+ Each call of a Deep Agents tool gets its effect from
248
+ ``DEEP_AGENTS_TOOL_RULES`` and waits for approval only when the turn's
249
+ ``approval.always`` names it. A call of a tool whose local tool group the
250
+ turn switches off runs at once and its tool Step refuses it.
251
+ """
252
+
253
+ def __init__(
254
+ self,
255
+ context: HarnessContext,
256
+ graph: CompiledGraphSource,
257
+ *,
258
+ tool_sources: Sequence[ToolSource],
259
+ select_turn_chat_model: Callable[[AgentTurnContext], BaseChatModel | None],
260
+ ) -> None:
261
+ """Create the runner, deferring every tool call before Deep Agents' middleware.
262
+
263
+ Args:
264
+ context: The harness context, for each turn's configuration.
265
+ graph: The graph, or the function that builds it on the Worker.
266
+ tool_sources: The connector and local tool sources.
267
+ select_turn_chat_model: Picks each turn's chat model.
268
+ """
269
+ super().__init__(
270
+ graph,
271
+ tool_sources=tool_sources,
272
+ media_store=context.media_store,
273
+ select_turn_chat_model=select_turn_chat_model,
274
+ should_defer_tool_calls_before_middleware=True,
275
+ )
276
+ self._context = context
277
+
278
+ async def run_model_step(self, turn: AgentTurnContext) -> AgentTurnOutcome:
279
+ """Run one model call and give each Deep Agents tool call the turn's policy."""
280
+ with bind_artifacts_session(turn.flow_id):
281
+ outcome = await super().run_model_step(turn)
282
+ if not isinstance(outcome, ToolCallsRequested):
283
+ return outcome
284
+ turn_config = self._context.resolve_turn(turn.session_settings)
285
+ return dataclasses.replace(
286
+ outcome,
287
+ tool_calls=[
288
+ _with_turn_policy(turn, turn_config, tool_call) for tool_call in outcome.tool_calls
289
+ ],
290
+ )
291
+
292
+ async def execute_deferred_tool_call(self, call: DeferredToolExecution) -> DeferredToolOutcome:
293
+ """Run one call, after refusing a Deep Agents tool its turn switches off here.
294
+
295
+ Raises:
296
+ ToolExecutionError: The call's turn switches the tool's local tool
297
+ group off on this Worker, so nothing runs.
298
+ """
299
+ rule = DEEP_AGENTS_TOOL_RULES.get(call.tool_call.tool_name)
300
+ if rule is not None and not rule.is_enabled(
301
+ self._context.resolve_tool_step_permissions(call)
302
+ ):
303
+ raise ToolExecutionError(
304
+ f"tool {call.tool_call.tool_name} is switched off for this session"
305
+ )
306
+ with bind_artifacts_session(call.flow_id):
307
+ return await super().execute_deferred_tool_call(call)
308
+
309
+
310
+ def _with_turn_policy(
311
+ turn: AgentTurnContext, turn_config: EffectiveConfig, tool_call: DeferredToolCall
312
+ ) -> DeferredToolCall:
313
+ # Sources rename colliding tools, so a rule's name is always a graph tool.
314
+ rule = DEEP_AGENTS_TOOL_RULES.get(tool_call.tool_name)
315
+ if rule is None:
316
+ return tool_call
317
+ if not rule.is_enabled(turn_config):
318
+ # Its tool Step only records the refusal, so nothing waits for approval.
319
+ policy = ToolPolicy(ToolEffect.READ, requires_approval=False)
320
+ else:
321
+ is_task_approval_required = tool_call.tool_name == TASK_TOOL_NAME and bool(
322
+ _TASK_APPROVAL_TOOL_NAMES & set(turn_config.approval.always)
323
+ )
324
+ policy = resolve_turn_tool_policy(
325
+ turn, tool_call.tool_name, rule.effect, is_task_approval_required, turn_config
326
+ )
327
+ return dataclasses.replace(tool_call, policy=policy)
328
+
329
+
330
+ def validate_deepagents_section(options: Mapping[str, Any]) -> None:
331
+ """Reject every key of the ``deepagents`` section, which has no options yet.
332
+
333
+ Args:
334
+ options: The section, ``{}`` when the file omits it.
335
+
336
+ Raises:
337
+ ConfigError: ``schema`` with the first key's JSON Pointer.
338
+ """
339
+ for key in sorted(options):
340
+ escaped = key.replace("~", "~0").replace("/", "~1")
341
+ raise ConfigError("schema", f"/deepagents/{escaped}", keyword="additionalProperties")
342
+
343
+
344
+ def deep_agents_chat_model(model: str) -> BaseChatModel:
345
+ """Build a chat model from a ``provider:model`` ID as Deep Agents does.
346
+
347
+ An ``openai:`` model uses the Responses API, Deep Agents' default.
348
+
349
+ Args:
350
+ model: The ID, such as ``openai:gpt-5.2`` or ``anthropic:claude-opus-5-5``.
351
+
352
+ Returns:
353
+ The chat model. Its provider reads its key from the standard environment variable.
354
+ """
355
+ if model.startswith("openai:"):
356
+ return init_chat_model(model, use_responses_api=True)
357
+ return init_chat_model(model)
358
+
359
+
360
+ class TurnChatModelSelector:
361
+ """Pick the running turn's chat model when it differs from the configuration file's."""
362
+
363
+ def __init__(self, file_model: str) -> None:
364
+ """Keep the model the agent was built with.
365
+
366
+ Args:
367
+ file_model: The configuration file's model.
368
+ """
369
+ self._file_model = file_model
370
+ self._models: dict[str, BaseChatModel] = {}
371
+
372
+ def __call__(self, turn: AgentTurnContext) -> BaseChatModel | None:
373
+ """Return the turn's chat model, built once per ID, or ``None`` for the file's model."""
374
+ turn_config = current_turn_config()
375
+ if turn_config is None or turn_config.model == self._file_model:
376
+ return None
377
+ if turn_config.model not in self._models:
378
+ self._models[turn_config.model] = deep_agents_chat_model(turn_config.model)
379
+ return self._models[turn_config.model]
380
+
381
+
382
+ class TurnInstructionsMiddleware(AgentMiddleware[Any, Any, Any]):
383
+ """Put the running turn's instructions and the workspace first in every system message."""
384
+
385
+ def __init__(self, workspace: Path | None = None) -> None:
386
+ """Name the workspace the file tools work in.
387
+
388
+ Args:
389
+ workspace: The absolute workspace directory; ``None`` names none.
390
+ """
391
+ super().__init__()
392
+ self._workspace = workspace
393
+
394
+ async def awrap_model_call(
395
+ self,
396
+ request: ModelRequest[Any],
397
+ handler: Callable[[ModelRequest[Any]], Awaitable[ModelResponse[Any]]],
398
+ ) -> ModelResponse[Any]:
399
+ """Prepend the turn's instructions, or the default ones, to the system message."""
400
+ turn = current_turn_config()
401
+ if turn is None:
402
+ return await handler(request)
403
+ sections = [turn.instructions or DEFAULT_INSTRUCTIONS]
404
+ if self._workspace is not None:
405
+ sections.append(
406
+ f"Your workspace is {self._workspace}. File tools take absolute paths "
407
+ "inside it, and execute runs commands in it."
408
+ )
409
+ if request.system_message is not None and request.system_message.text:
410
+ sections.append(request.system_message.text)
411
+ return await handler(request.override(system_message=SystemMessage("\n\n".join(sections))))
412
+
413
+
414
+ class LocalToolSwitchesMiddleware(AgentMiddleware[Any, Any, Any]):
415
+ """Leave out the Deep Agents tools whose local tool group the running turn switches off."""
416
+
417
+ async def awrap_model_call(
418
+ self,
419
+ request: ModelRequest[Any],
420
+ handler: Callable[[ModelRequest[Any]], Awaitable[ModelResponse[Any]]],
421
+ ) -> ModelResponse[Any]:
422
+ """Remove the switched-off tools from the model request."""
423
+ turn = current_turn_config()
424
+ if turn is None:
425
+ return await handler(request)
426
+ tools = [tool for tool in request.tools if _is_offered(tool, turn)]
427
+ if len(tools) == len(request.tools):
428
+ return await handler(request)
429
+ return await handler(request.override(tools=tools))
430
+
431
+
432
+ def _is_offered(tool: BaseTool | Mapping[str, Any], turn: EffectiveConfig) -> bool:
433
+ name = tool.name if isinstance(tool, BaseTool) else tool.get("name")
434
+ rule = DEEP_AGENTS_TOOL_RULES.get(name) if isinstance(name, str) else None
435
+ return rule is None or rule.is_enabled(turn)
436
+
437
+
438
+ class _WithoutSummarization(AgentMiddleware[Any, Any, Any]):
439
+ """Replace the summarization, which calls the file's model inside a ``RunModel`` Step."""
440
+
441
+ @property
442
+ def name(self) -> str:
443
+ return "SummarizationMiddleware"
File without changes