dex-agents-cli-core 0.5.0__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,458 @@
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 local tool source: file, shell, web fetch, and browser tools as Dex tool Steps."""
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from collections.abc import Mapping, Sequence
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from dex_agents import (
20
+ AgentTurnContext,
21
+ DeferredToolCall,
22
+ DeferredToolExecution,
23
+ ToolEffect,
24
+ ToolExecutionError,
25
+ ToolExecutionResult,
26
+ ToolTaskPoll,
27
+ ToolTaskPollOutcome,
28
+ )
29
+ from dex_agents.builtin_loop import SourceToolDefinition
30
+ from dex_agents_cli_core.config import EffectiveConfig
31
+ from dex_agents_cli_core.harness import LocalToolGroup
32
+ from dex_agents_cli_core.local_tools.browser import BrowserSessions
33
+ from dex_agents_cli_core.local_tools.files import WorkspaceFiles
34
+ from dex_agents_cli_core.local_tools.shell import (
35
+ DEFAULT_SHELL_TIMEOUT_SECONDS,
36
+ MAX_SHELL_TIMEOUT_SECONDS,
37
+ run_shell_command,
38
+ )
39
+ from dex_agents_cli_core.local_tools.web import fetch_web_page
40
+ from dex_agents_cli_core.tool_policy import (
41
+ disabled_tool_call,
42
+ is_local_tool_group_enabled,
43
+ is_shell_command_approval_required,
44
+ raise_if_disabled,
45
+ resolve_turn_tool_policy,
46
+ )
47
+ from dex_agents_cli_core.turns import (
48
+ TurnConfigResolver,
49
+ current_turn_config,
50
+ recorded_turn_settings,
51
+ )
52
+
53
+ #: The local tool source's ``source_name``; stored calls are routed by it.
54
+ LOCAL_TOOL_SOURCE_NAME = "dex_local_tools"
55
+ SHELL_TOOL_NAME = "run_shell_command"
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class LocalToolDefinition:
60
+ """One local tool as the model sees it, with its group and default policy.
61
+
62
+ Attributes:
63
+ name: Tool name.
64
+ description: Tool description for the model.
65
+ input_schema: JSON Schema of the arguments.
66
+ group: The ``local_tools`` group that switches it.
67
+ effect: Its effect class.
68
+ requires_approval: Whether every call waits for approval by default.
69
+ """
70
+
71
+ name: str
72
+ description: str
73
+ input_schema: Mapping[str, Any] = field(repr=False)
74
+ group: LocalToolGroup
75
+ effect: ToolEffect
76
+ requires_approval: bool
77
+
78
+
79
+ def _schema(properties: dict[str, Any], required: Sequence[str]) -> dict[str, Any]:
80
+ return {
81
+ "type": "object",
82
+ "properties": properties,
83
+ "required": list(required),
84
+ "additionalProperties": False,
85
+ }
86
+
87
+
88
+ _PATH = {"type": "string", "description": "Path relative to the workspace."}
89
+ _SELECTOR = {
90
+ "type": "string",
91
+ "description": (
92
+ 'A Playwright selector, such as text=Sign in, role=button[name="Submit"], '
93
+ "or a CSS selector."
94
+ ),
95
+ }
96
+
97
+ #: Every local tool, in the order the model sees them.
98
+ LOCAL_TOOL_DEFINITIONS: tuple[LocalToolDefinition, ...] = (
99
+ LocalToolDefinition(
100
+ name="read_file",
101
+ description="Read a text file in the workspace. Returns its lines.",
102
+ input_schema=_schema(
103
+ {
104
+ "path": _PATH,
105
+ "offset": {"type": "integer", "minimum": 0, "description": "First line, from 0."},
106
+ "limit": {"type": "integer", "minimum": 1, "description": "Most lines to read."},
107
+ },
108
+ ["path"],
109
+ ),
110
+ group=LocalToolGroup.FILES,
111
+ effect=ToolEffect.READ,
112
+ requires_approval=False,
113
+ ),
114
+ LocalToolDefinition(
115
+ name="list_directory",
116
+ description="List a directory in the workspace. Directories end with /.",
117
+ input_schema=_schema({"path": _PATH}, []),
118
+ group=LocalToolGroup.FILES,
119
+ effect=ToolEffect.READ,
120
+ requires_approval=False,
121
+ ),
122
+ LocalToolDefinition(
123
+ name="search_files",
124
+ description="Find lines matching a regular expression in the workspace's files.",
125
+ input_schema=_schema(
126
+ {"pattern": {"type": "string", "description": "A regular expression."}, "path": _PATH},
127
+ ["pattern"],
128
+ ),
129
+ group=LocalToolGroup.FILES,
130
+ effect=ToolEffect.READ,
131
+ requires_approval=False,
132
+ ),
133
+ LocalToolDefinition(
134
+ name="write_file",
135
+ description="Create or replace a file in the workspace with the given text.",
136
+ input_schema=_schema({"path": _PATH, "content": {"type": "string"}}, ["path", "content"]),
137
+ group=LocalToolGroup.FILES,
138
+ effect=ToolEffect.IDEMPOTENT_WRITE,
139
+ requires_approval=True,
140
+ ),
141
+ LocalToolDefinition(
142
+ name="edit_file",
143
+ description="Replace the one occurrence of old_text with new_text in a workspace file.",
144
+ input_schema=_schema(
145
+ {"path": _PATH, "old_text": {"type": "string"}, "new_text": {"type": "string"}},
146
+ ["path", "old_text", "new_text"],
147
+ ),
148
+ group=LocalToolGroup.FILES,
149
+ effect=ToolEffect.NON_IDEMPOTENT_WRITE,
150
+ requires_approval=True,
151
+ ),
152
+ LocalToolDefinition(
153
+ name=SHELL_TOOL_NAME,
154
+ description=(
155
+ "Run a shell command with /bin/sh in the workspace. Returns the exit code and output."
156
+ ),
157
+ input_schema=_schema(
158
+ {
159
+ "command": {"type": "string"},
160
+ "timeout_seconds": {
161
+ "type": "integer",
162
+ "minimum": 1,
163
+ "maximum": MAX_SHELL_TIMEOUT_SECONDS,
164
+ "description": f"Defaults to {DEFAULT_SHELL_TIMEOUT_SECONDS}.",
165
+ },
166
+ },
167
+ ["command"],
168
+ ),
169
+ group=LocalToolGroup.SHELL,
170
+ effect=ToolEffect.NON_IDEMPOTENT_WRITE,
171
+ requires_approval=True,
172
+ ),
173
+ LocalToolDefinition(
174
+ name="fetch_web_page",
175
+ description="Fetch a web page with GET. Returns its text.",
176
+ input_schema=_schema({"url": {"type": "string"}}, ["url"]),
177
+ group=LocalToolGroup.WEB_FETCH,
178
+ effect=ToolEffect.READ,
179
+ requires_approval=False,
180
+ ),
181
+ LocalToolDefinition(
182
+ name="browser_navigate",
183
+ description="Open a URL in this session's browser page. Returns a text snapshot.",
184
+ input_schema=_schema({"url": {"type": "string"}}, ["url"]),
185
+ group=LocalToolGroup.BROWSER,
186
+ effect=ToolEffect.READ,
187
+ requires_approval=False,
188
+ ),
189
+ LocalToolDefinition(
190
+ name="browser_snapshot",
191
+ description="Read the browser page as a text accessibility snapshot.",
192
+ input_schema=_schema({}, []),
193
+ group=LocalToolGroup.BROWSER,
194
+ effect=ToolEffect.READ,
195
+ requires_approval=False,
196
+ ),
197
+ LocalToolDefinition(
198
+ name="browser_click",
199
+ description="Click an element of the browser page. Returns a text snapshot.",
200
+ input_schema=_schema({"selector": _SELECTOR}, ["selector"]),
201
+ group=LocalToolGroup.BROWSER,
202
+ effect=ToolEffect.NON_IDEMPOTENT_WRITE,
203
+ requires_approval=True,
204
+ ),
205
+ LocalToolDefinition(
206
+ name="browser_fill",
207
+ description="Type text into a field of the browser page. Returns a text snapshot.",
208
+ input_schema=_schema(
209
+ {"selector": _SELECTOR, "value": {"type": "string"}}, ["selector", "value"]
210
+ ),
211
+ group=LocalToolGroup.BROWSER,
212
+ effect=ToolEffect.NON_IDEMPOTENT_WRITE,
213
+ requires_approval=True,
214
+ ),
215
+ LocalToolDefinition(
216
+ name="browser_submit_form",
217
+ description="Submit the form that contains an element. Returns a text snapshot.",
218
+ input_schema=_schema({"selector": _SELECTOR}, ["selector"]),
219
+ group=LocalToolGroup.BROWSER,
220
+ effect=ToolEffect.NON_IDEMPOTENT_WRITE,
221
+ requires_approval=True,
222
+ ),
223
+ )
224
+ _DEFINITIONS_BY_NAME = {definition.name: definition for definition in LOCAL_TOOL_DEFINITIONS}
225
+
226
+
227
+ class LocalToolSource:
228
+ """Offer the local tools as a ``ToolSource``, so every call is its own Dex tool Step.
229
+
230
+ Reads run at once. Writes, browser clicks, fills, and submits wait for
231
+ approval. A shell command waits for approval unless the turn's allow list
232
+ has its exact text; it is always a single-attempt write, so a Worker that
233
+ dies during it never runs it again. The tools a turn offers follow its
234
+ ``local_tools`` switches, and tools outside a model call's turn are never
235
+ offered.
236
+ """
237
+
238
+ def __init__(
239
+ self,
240
+ workspace: Path,
241
+ groups: frozenset[LocalToolGroup],
242
+ resolver: TurnConfigResolver,
243
+ browser: BrowserSessions | None = None,
244
+ ) -> None:
245
+ """Offer some groups of local tools.
246
+
247
+ Args:
248
+ workspace: The existing workspace directory.
249
+ groups: The groups this source offers; a harness may replace the others.
250
+ resolver: The turn configuration resolver.
251
+ browser: The browser pages; ``None`` creates them on first use.
252
+ """
253
+ self._workspace = workspace
254
+ self._files = WorkspaceFiles(workspace)
255
+ self._groups = groups
256
+ self._resolver = resolver
257
+ self._browser = browser or BrowserSessions()
258
+ # Until the first model call lists, the file's tools are cached for runners built before it.
259
+ self._last_listing = self._enabled_tools(resolver.file_config)
260
+
261
+ @property
262
+ def source_name(self) -> str:
263
+ """Return ``dex_local_tools``."""
264
+ return LOCAL_TOOL_SOURCE_NAME
265
+
266
+ @property
267
+ def browser(self) -> BrowserSessions:
268
+ """Return the browser pages, so the command can close them on exit."""
269
+ return self._browser
270
+
271
+ def declared_tool_names(self) -> frozenset[str]:
272
+ """Return the names of every tool this source can offer."""
273
+ return frozenset(
274
+ definition.name
275
+ for definition in LOCAL_TOOL_DEFINITIONS
276
+ if definition.group in self._groups
277
+ )
278
+
279
+ def cached_tools(self) -> tuple[LocalToolDefinition, ...]:
280
+ """Return the last listing, or the tools the configuration file enables before it."""
281
+ return self._last_listing
282
+
283
+ async def list_tools(self) -> Sequence[SourceToolDefinition]:
284
+ """Return the tools the running turn enables; none outside a model call."""
285
+ turn_config = current_turn_config()
286
+ if turn_config is None:
287
+ return ()
288
+ self._last_listing = self._enabled_tools(turn_config)
289
+ return self._last_listing
290
+
291
+ def _enabled_tools(self, config: EffectiveConfig) -> tuple[LocalToolDefinition, ...]:
292
+ return tuple(
293
+ definition
294
+ for definition in LOCAL_TOOL_DEFINITIONS
295
+ if definition.group in self._groups
296
+ and is_local_tool_group_enabled(config, definition.group)
297
+ )
298
+
299
+ def deferred_tool_call(
300
+ self,
301
+ turn: AgentTurnContext,
302
+ *,
303
+ tool_call_id: str,
304
+ tool_name: str,
305
+ arguments: str,
306
+ listed_tool: SourceToolDefinition,
307
+ ) -> DeferredToolCall:
308
+ """Build a call with the tool's policy, the turn's allow list, and ``approval.always``.
309
+
310
+ A tool its turn switches off, or that this source does not offer,
311
+ becomes a call that its tool Step refuses.
312
+ """
313
+ definition = _DEFINITIONS_BY_NAME.get(tool_name)
314
+ turn_config = self._resolver.resolve_turn(turn)
315
+ if (
316
+ definition is None
317
+ or definition.group not in self._groups
318
+ or not is_local_tool_group_enabled(turn_config, definition.group)
319
+ ):
320
+ return disabled_tool_call(tool_call_id, tool_name, arguments)
321
+ requires_approval = definition.requires_approval
322
+ if tool_name == SHELL_TOOL_NAME:
323
+ command = _shell_command_or_none(arguments)
324
+ requires_approval = command is None or is_shell_command_approval_required(
325
+ turn_config, command
326
+ )
327
+ return DeferredToolCall(
328
+ tool_call_id=tool_call_id,
329
+ tool_name=tool_name,
330
+ arguments=arguments,
331
+ policy=resolve_turn_tool_policy(
332
+ turn, tool_name, definition.effect, requires_approval, turn_config
333
+ ),
334
+ )
335
+
336
+ async def execute_deferred_tool_call(self, call: DeferredToolExecution) -> ToolExecutionResult:
337
+ """Run one approved local tool call, after authorizing it again on this Worker.
338
+
339
+ The call may run on another Worker than the one that built it, with
340
+ its own configuration file. Here the tool's group must be offered and
341
+ switched on for the turn, and a shell command that was built without
342
+ approval must still be in the allow list; otherwise nothing runs.
343
+ """
344
+ raise_if_disabled(call)
345
+ record = call.tool_call
346
+ turn = current_turn_config() or self._resolver.resolve(recorded_turn_settings(call))
347
+ definition = _DEFINITIONS_BY_NAME.get(record.tool_name)
348
+ if (
349
+ definition is None
350
+ or definition.group not in self._groups
351
+ or not is_local_tool_group_enabled(turn, definition.group)
352
+ ):
353
+ raise ToolExecutionError(f"tool {record.tool_name} is switched off for this session")
354
+ if record.tool_name == SHELL_TOOL_NAME and not record.requires_approval:
355
+ command = _shell_command_or_none(record.arguments)
356
+ if command is None or is_shell_command_approval_required(turn, command):
357
+ raise ToolExecutionError(
358
+ "the command is no longer in the shell allow list, and it was not approved; "
359
+ "it did not run"
360
+ )
361
+ arguments = _read_arguments(record.arguments)
362
+ output = await self._run(record.tool_name, arguments, call.flow_id)
363
+ return ToolExecutionResult(output=output)
364
+
365
+ async def poll_tool_task(self, poll: ToolTaskPoll) -> ToolTaskPollOutcome:
366
+ """Refuse a poll: local tools never start tasks."""
367
+ raise ToolExecutionError(f"{poll.tool_call.tool_name} does not run as a task")
368
+
369
+ async def _run(self, tool_name: str, arguments: Mapping[str, Any], session_id: str) -> str:
370
+ files = self._files
371
+ match tool_name:
372
+ case "read_file":
373
+ return files.read_file(
374
+ _text(arguments, "path"),
375
+ _optional_int(arguments, "offset") or 0,
376
+ _optional_int(arguments, "limit"),
377
+ )
378
+ case "list_directory":
379
+ return files.list_directory(_optional_text(arguments, "path") or ".")
380
+ case "search_files":
381
+ return files.search_files(
382
+ _text(arguments, "pattern"), _optional_text(arguments, "path") or "."
383
+ )
384
+ case "write_file":
385
+ return files.write_file(_text(arguments, "path"), _text(arguments, "content"))
386
+ case "edit_file":
387
+ return files.edit_file(
388
+ _text(arguments, "path"),
389
+ _text(arguments, "old_text"),
390
+ _text(arguments, "new_text"),
391
+ )
392
+ case "run_shell_command":
393
+ timeout = _optional_int(arguments, "timeout_seconds")
394
+ return await run_shell_command(
395
+ _text(arguments, "command"),
396
+ self._workspace,
397
+ min(timeout or DEFAULT_SHELL_TIMEOUT_SECONDS, MAX_SHELL_TIMEOUT_SECONDS),
398
+ )
399
+ case "fetch_web_page":
400
+ return await fetch_web_page(_text(arguments, "url"))
401
+ case "browser_navigate":
402
+ return await self._browser.open_page(session_id, _text(arguments, "url"))
403
+ case "browser_snapshot":
404
+ return await self._browser.snapshot(session_id)
405
+ case "browser_click":
406
+ return await self._browser.click(session_id, _text(arguments, "selector"))
407
+ case "browser_fill":
408
+ return await self._browser.fill(
409
+ session_id, _text(arguments, "selector"), _text(arguments, "value")
410
+ )
411
+ case "browser_submit_form":
412
+ return await self._browser.submit(session_id, _text(arguments, "selector"))
413
+ raise ToolExecutionError(f"unknown local tool {tool_name}")
414
+
415
+
416
+ def _shell_command_or_none(arguments: str) -> str | None:
417
+ """Return a shell call's command; ``None`` for arguments the call itself will refuse."""
418
+ try:
419
+ parsed = json.loads(arguments or "{}")
420
+ except json.JSONDecodeError:
421
+ return None
422
+ command = parsed.get("command") if isinstance(parsed, dict) else None
423
+ return command if isinstance(command, str) else None
424
+
425
+
426
+ def _read_arguments(arguments: str) -> dict[str, Any]:
427
+ try:
428
+ parsed = json.loads(arguments or "{}")
429
+ except json.JSONDecodeError as error:
430
+ raise ToolExecutionError("arguments are not valid JSON") from error
431
+ if not isinstance(parsed, dict):
432
+ raise ToolExecutionError("arguments must be a JSON object")
433
+ return parsed
434
+
435
+
436
+ def _text(arguments: Mapping[str, Any], name: str) -> str:
437
+ value = arguments.get(name)
438
+ if not isinstance(value, str):
439
+ raise ToolExecutionError(f"argument {name} must be a string")
440
+ return value
441
+
442
+
443
+ def _optional_text(arguments: Mapping[str, Any], name: str) -> str | None:
444
+ value = arguments.get(name)
445
+ if value is None:
446
+ return None
447
+ if not isinstance(value, str):
448
+ raise ToolExecutionError(f"argument {name} must be a string")
449
+ return value
450
+
451
+
452
+ def _optional_int(arguments: Mapping[str, Any], name: str) -> int | None:
453
+ value = arguments.get(name)
454
+ if value is None:
455
+ return None
456
+ if isinstance(value, bool) or not isinstance(value, int):
457
+ raise ToolExecutionError(f"argument {name} must be an integer")
458
+ return int(value)
@@ -0,0 +1,98 @@
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 web fetch tool."""
10
+
11
+ from __future__ import annotations
12
+
13
+ from html.parser import HTMLParser
14
+ from urllib.parse import urlsplit
15
+
16
+ import httpx2
17
+
18
+ from dex_agents import ToolExecutionError
19
+
20
+ _FETCH_TIMEOUT_SECONDS = 30.0
21
+ _MAX_FETCHED_CHARACTERS = 100_000
22
+ _SKIPPED_ELEMENTS = frozenset({"script", "style", "noscript", "template"})
23
+ _BLOCK_ELEMENTS = frozenset(
24
+ {"p", "div", "br", "li", "tr", "h1", "h2", "h3", "h4", "h5", "h6", "section", "article"}
25
+ )
26
+
27
+
28
+ async def fetch_web_page(url: str) -> str:
29
+ """Fetch a page with GET and return its text; HTML is reduced to its visible text.
30
+
31
+ Args:
32
+ url: An ``http`` or ``https`` URL.
33
+
34
+ Returns:
35
+ The final URL, status, content type, and text.
36
+
37
+ Raises:
38
+ ToolExecutionError: If the URL is not http or https, or the request fails.
39
+ """
40
+ if urlsplit(url).scheme not in ("http", "https"):
41
+ raise ToolExecutionError(f"{url} is not an http or https URL")
42
+ try:
43
+ async with httpx2.AsyncClient(
44
+ follow_redirects=True, timeout=_FETCH_TIMEOUT_SECONDS
45
+ ) as client:
46
+ response = await client.get(url)
47
+ except httpx2.HTTPError as error:
48
+ raise ToolExecutionError(f"cannot fetch {url}: {error}") from error
49
+ content_type = response.headers.get("content-type", "")
50
+ text = response.text
51
+ if "html" in content_type:
52
+ text = html_to_text(text)
53
+ if len(text) > _MAX_FETCHED_CHARACTERS:
54
+ omitted = len(text) - _MAX_FETCHED_CHARACTERS
55
+ text = f"{text[:_MAX_FETCHED_CHARACTERS]}\n... ({omitted} more characters)"
56
+ return (
57
+ f"url: {response.url}\nstatus: {response.status_code}\n"
58
+ f"content-type: {content_type}\n\n{text}"
59
+ )
60
+
61
+
62
+ def html_to_text(html: str) -> str:
63
+ """Return the visible text of an HTML document, one block per line.
64
+
65
+ Args:
66
+ html: The document.
67
+
68
+ Returns:
69
+ Its text without scripts, styles, or markup.
70
+ """
71
+ parser = _TextExtractor()
72
+ parser.feed(html)
73
+ parser.close()
74
+ lines = (" ".join(line.split()) for line in "".join(parser.parts).splitlines())
75
+ return "\n".join(line for line in lines if line)
76
+
77
+
78
+ class _TextExtractor(HTMLParser):
79
+ def __init__(self) -> None:
80
+ super().__init__(convert_charrefs=True)
81
+ self.parts: list[str] = []
82
+ self._skipped_depth = 0
83
+
84
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
85
+ if tag in _SKIPPED_ELEMENTS:
86
+ self._skipped_depth += 1
87
+ elif tag in _BLOCK_ELEMENTS:
88
+ self.parts.append("\n")
89
+
90
+ def handle_endtag(self, tag: str) -> None:
91
+ if tag in _SKIPPED_ELEMENTS and self._skipped_depth > 0:
92
+ self._skipped_depth -= 1
93
+ elif tag in _BLOCK_ELEMENTS:
94
+ self.parts.append("\n")
95
+
96
+ def handle_data(self, data: str) -> None:
97
+ if self._skipped_depth == 0:
98
+ self.parts.append(data)
File without changes
@@ -0,0 +1,9 @@
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 JSON Schemas of the configuration file and session settings, copied from agents/cli."""