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.
- dex_agents_cli_core/__init__.py +101 -0
- dex_agents_cli_core/chat.py +538 -0
- dex_agents_cli_core/cli.py +671 -0
- dex_agents_cli_core/config.py +650 -0
- dex_agents_cli_core/config_file.py +210 -0
- dex_agents_cli_core/connectors.py +531 -0
- dex_agents_cli_core/dex_server.py +345 -0
- dex_agents_cli_core/dexcli_install.py +314 -0
- dex_agents_cli_core/environment.py +136 -0
- dex_agents_cli_core/harness.py +445 -0
- dex_agents_cli_core/local_tools/__init__.py +26 -0
- dex_agents_cli_core/local_tools/browser.py +235 -0
- dex_agents_cli_core/local_tools/files.py +512 -0
- dex_agents_cli_core/local_tools/shell.py +85 -0
- dex_agents_cli_core/local_tools/source.py +458 -0
- dex_agents_cli_core/local_tools/web.py +98 -0
- dex_agents_cli_core/py.typed +0 -0
- dex_agents_cli_core/schemas/__init__.py +9 -0
- dex_agents_cli_core/schemas/config.schema.json +315 -0
- dex_agents_cli_core/schemas/session-settings.schema.json +153 -0
- dex_agents_cli_core/tool_policy.py +179 -0
- dex_agents_cli_core/turns.py +235 -0
- dex_agents_cli_core-0.5.0.dist-info/METADATA +479 -0
- dex_agents_cli_core-0.5.0.dist-info/RECORD +26 -0
- dex_agents_cli_core-0.5.0.dist-info/WHEEL +4 -0
- dex_agents_cli_core-0.5.0.dist-info/licenses/LICENSE +105 -0
|
@@ -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."""
|