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,136 @@
|
|
|
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 machine settings of a CLI command: paths and the environment variables it reads.
|
|
10
|
+
|
|
11
|
+
This is the only module that reads environment variables.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
from collections.abc import Mapping
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
#: The ``dexcli`` release a command downloads when none is installed.
|
|
22
|
+
DEXCLI_VERSION = "1.45.0"
|
|
23
|
+
#: Where ``dexcli`` releases are published; each tag is ``cli-v<version>``.
|
|
24
|
+
DEXCLI_RELEASE_BASE_URL = "https://github.com/superdurable/dex/releases/download"
|
|
25
|
+
#: The ports a running ``dexcli dev`` uses by default.
|
|
26
|
+
DEFAULT_DEV_DEX_PORT = 8801
|
|
27
|
+
DEFAULT_DEV_WEB_PORT = 8802
|
|
28
|
+
|
|
29
|
+
#: A ``dexcli`` executable to use instead of searching ``PATH``.
|
|
30
|
+
DEXCLI_ENVIRONMENT_VARIABLE = "DEXCLI"
|
|
31
|
+
#: The Dex port a command probes for, and passes to, ``dexcli dev``.
|
|
32
|
+
DEX_PORT_ENVIRONMENT_VARIABLE = "DEX_AGENTS_DEX_PORT"
|
|
33
|
+
#: The Dex Web port a command probes for, and passes to, ``dexcli dev``.
|
|
34
|
+
WEB_PORT_ENVIRONMENT_VARIABLE = "DEX_AGENTS_WEB_PORT"
|
|
35
|
+
#: A mirror of the ``dexcli`` release downloads.
|
|
36
|
+
DEXCLI_DOWNLOAD_URL_ENVIRONMENT_VARIABLE = "DEX_AGENTS_DEXCLI_DOWNLOAD_URL"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class CommandEnvironment:
|
|
41
|
+
"""The paths and machine settings one command run uses.
|
|
42
|
+
|
|
43
|
+
Attributes:
|
|
44
|
+
home: The home directory; ``~`` in the configuration means it.
|
|
45
|
+
dexcli: The ``dexcli`` executable from ``DEXCLI``, or ``None`` to search.
|
|
46
|
+
dev_dex_port: The Dex port of ``dexcli dev``; 8801 by default.
|
|
47
|
+
dev_web_port: The Dex Web port of ``dexcli dev``; 8802 by default.
|
|
48
|
+
has_custom_dev_ports: Whether either port came from the environment, so
|
|
49
|
+
a started ``dexcli dev`` must use exactly these ports.
|
|
50
|
+
dexcli_version: The ``dexcli`` release to download.
|
|
51
|
+
dexcli_release_base_url: Base URL of the release downloads.
|
|
52
|
+
search_path: The ``PATH`` directories, searched for ``dexcli``.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
home: Path
|
|
56
|
+
dexcli: Path | None = None
|
|
57
|
+
dev_dex_port: int = DEFAULT_DEV_DEX_PORT
|
|
58
|
+
dev_web_port: int = DEFAULT_DEV_WEB_PORT
|
|
59
|
+
has_custom_dev_ports: bool = False
|
|
60
|
+
dexcli_version: str = DEXCLI_VERSION
|
|
61
|
+
dexcli_release_base_url: str = DEXCLI_RELEASE_BASE_URL
|
|
62
|
+
search_path: tuple[Path, ...] = ()
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_environment(cls, environ: Mapping[str, str] | None = None) -> CommandEnvironment:
|
|
66
|
+
"""Read the settings from environment variables.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
environ: The variables; ``None`` reads ``os.environ``.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
The settings.
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
ValueError: If a port variable is not a port number.
|
|
76
|
+
"""
|
|
77
|
+
values = os.environ if environ is None else environ
|
|
78
|
+
dexcli = values.get(DEXCLI_ENVIRONMENT_VARIABLE)
|
|
79
|
+
dex_port = values.get(DEX_PORT_ENVIRONMENT_VARIABLE)
|
|
80
|
+
web_port = values.get(WEB_PORT_ENVIRONMENT_VARIABLE)
|
|
81
|
+
return cls(
|
|
82
|
+
home=Path(values.get("HOME") or Path.home()),
|
|
83
|
+
dexcli=Path(dexcli) if dexcli else None,
|
|
84
|
+
dev_dex_port=_parse_port(dex_port, DEFAULT_DEV_DEX_PORT),
|
|
85
|
+
dev_web_port=_parse_port(web_port, DEFAULT_DEV_WEB_PORT),
|
|
86
|
+
has_custom_dev_ports=bool(dex_port or web_port),
|
|
87
|
+
dexcli_release_base_url=values.get(DEXCLI_DOWNLOAD_URL_ENVIRONMENT_VARIABLE)
|
|
88
|
+
or DEXCLI_RELEASE_BASE_URL,
|
|
89
|
+
search_path=tuple(
|
|
90
|
+
Path(entry) for entry in values.get("PATH", "").split(os.pathsep) if entry
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def dex_directory(self) -> Path:
|
|
96
|
+
"""Return ``~/.dex``, which ``dexcli dev`` also uses."""
|
|
97
|
+
return self.home / ".dex"
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def agents_directory(self) -> Path:
|
|
101
|
+
"""Return ``~/.dex/agents``: configuration files, logs, and the server state file."""
|
|
102
|
+
return self.dex_directory / "agents"
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def bin_directory(self) -> Path:
|
|
106
|
+
"""Return ``~/.dex/bin``, where a downloaded ``dexcli`` lives."""
|
|
107
|
+
return self.dex_directory / "bin"
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def dev_server_state_path(self) -> Path:
|
|
111
|
+
"""Return the file that records the ``dexcli dev`` a command started."""
|
|
112
|
+
return self.agents_directory / "dexcli-dev.json"
|
|
113
|
+
|
|
114
|
+
def expand_path(self, text: str, relative_to: Path) -> Path:
|
|
115
|
+
"""Expand a configuration path: a leading ``~`` is home, a relative path is relative.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
text: The path as written.
|
|
119
|
+
relative_to: The directory of the configuration file.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
The absolute path.
|
|
123
|
+
"""
|
|
124
|
+
if text == "~" or text.startswith("~/"):
|
|
125
|
+
return self.home / text[2:]
|
|
126
|
+
path = Path(text)
|
|
127
|
+
return path if path.is_absolute() else relative_to / path
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _parse_port(text: str | None, default: int) -> int:
|
|
131
|
+
if not text:
|
|
132
|
+
return default
|
|
133
|
+
port = int(text)
|
|
134
|
+
if not 0 < port <= 65535:
|
|
135
|
+
raise ValueError(f"{text} is not a port number")
|
|
136
|
+
return port
|
|
@@ -0,0 +1,445 @@
|
|
|
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 harness plugin interface: what a harness command gives the shared CLI core."""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import getpass
|
|
14
|
+
import hashlib
|
|
15
|
+
import os
|
|
16
|
+
import socket
|
|
17
|
+
from collections.abc import Mapping, Sequence
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from enum import StrEnum
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Protocol
|
|
22
|
+
|
|
23
|
+
from dex_agents import AgentSessionFlow, DeferredToolExecution, MediaStore
|
|
24
|
+
from dex_agents.builtin_loop import ToolSource
|
|
25
|
+
from dex_agents_cli_core.config import EffectiveConfig
|
|
26
|
+
from dex_agents_cli_core.turns import (
|
|
27
|
+
ResolvedTurn,
|
|
28
|
+
TurnConfigResolver,
|
|
29
|
+
current_turn_config,
|
|
30
|
+
recorded_turn_settings,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
#: The permission every session Action requires in the session graph that the
|
|
34
|
+
#: command uploads for Dex Web. In Dex Web, choose it in Working as to answer a
|
|
35
|
+
#: CLI session's approvals and questions. Every CLI core uses this text.
|
|
36
|
+
SESSION_ACTION_PERMISSION = "dex-agents-cli.operate"
|
|
37
|
+
|
|
38
|
+
_FLOW_TYPE_SUFFIX_LENGTH = 6
|
|
39
|
+
_FLOW_TYPE_SUFFIX_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz"
|
|
40
|
+
_DEFAULTS_CONFIG_IDENTITY = "defaults"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class LocalToolGroup(StrEnum):
|
|
44
|
+
"""Name one group of local tools, as ``local_tools`` in the configuration switches them.
|
|
45
|
+
|
|
46
|
+
Attributes:
|
|
47
|
+
FILES: Read, write, edit, list, and search files in the workspace.
|
|
48
|
+
SHELL: Run shell commands in the workspace.
|
|
49
|
+
WEB_FETCH: Fetch web pages.
|
|
50
|
+
BROWSER: Drive a local browser and read its pages as text snapshots.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
FILES = "files"
|
|
54
|
+
SHELL = "shell"
|
|
55
|
+
WEB_FETCH = "web_fetch"
|
|
56
|
+
BROWSER = "browser"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AgentHarness(Protocol):
|
|
60
|
+
"""Describe one harness command and build its agent session Flow.
|
|
61
|
+
|
|
62
|
+
A harness package implements this and calls ``run_agent_cli`` with it from
|
|
63
|
+
its console script. The CLI core owns everything else: the configuration
|
|
64
|
+
file, the Dex server, connector and local tools, sessions, the Worker, and
|
|
65
|
+
the terminal chat.
|
|
66
|
+
|
|
67
|
+
A harness whose runner fixes its tools when it is built, such as one on
|
|
68
|
+
the OpenAI Agents SDK adapter, also sets the optional class attribute
|
|
69
|
+
``is_tool_set_fixed_at_build = True``. Such a runner offers only the tools
|
|
70
|
+
that the sources cache before the Flow is built: the tools the
|
|
71
|
+
configuration file enables and the connectors MCP server lists at
|
|
72
|
+
startup. When that listing fails, the command warns that the run has no
|
|
73
|
+
connector tools and that a restart brings them back.
|
|
74
|
+
|
|
75
|
+
Example:
|
|
76
|
+
>>> class ClaudeHarness:
|
|
77
|
+
... name = "claude-sdk"
|
|
78
|
+
... config_section = "claude"
|
|
79
|
+
... flow_type = "DexAgentsClaudeSdkCliSession"
|
|
80
|
+
... default_model = "claude-opus-5-5"
|
|
81
|
+
... replaced_local_tools = frozenset(
|
|
82
|
+
... {LocalToolGroup.FILES, LocalToolGroup.SHELL, LocalToolGroup.WEB_FETCH})
|
|
83
|
+
...
|
|
84
|
+
... def create_session_flow(self, context: HarnessContext) -> AgentSessionFlow:
|
|
85
|
+
... return claude_agent_session_flow(
|
|
86
|
+
... options_factory_for(context), flow_type=context.flow_type,
|
|
87
|
+
... tool_sources=context.tool_sources)
|
|
88
|
+
>>> raise SystemExit(run_agent_cli(ClaudeHarness()))
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def name(self) -> str:
|
|
93
|
+
"""Return the name: command ``dex-agents-<name>``, file ``~/.dex/agents/<name>.yaml``."""
|
|
94
|
+
...
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def config_section(self) -> str:
|
|
98
|
+
"""Return the harness section of the configuration file, such as ``claude``."""
|
|
99
|
+
...
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def flow_type(self) -> str:
|
|
103
|
+
"""Return the Flow type of the sessions. Keep it stable: sessions resume by it."""
|
|
104
|
+
...
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def default_model(self) -> str | None:
|
|
108
|
+
"""Return the model used when the file sets none, or ``None`` to require one."""
|
|
109
|
+
...
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def replaced_local_tools(self) -> frozenset[LocalToolGroup]:
|
|
113
|
+
"""Return the local tool groups the harness provides itself.
|
|
114
|
+
|
|
115
|
+
The CLI core leaves these groups out of ``HarnessContext.tool_sources``.
|
|
116
|
+
The harness switches its own tools on from ``config.local_tools`` and
|
|
117
|
+
gives them the same policy: reads run at once, writes and shell
|
|
118
|
+
commands wait for approval unless the shell allow list has the exact
|
|
119
|
+
command, and the working directory is the workspace.
|
|
120
|
+
"""
|
|
121
|
+
...
|
|
122
|
+
|
|
123
|
+
def create_session_flow(self, context: HarnessContext) -> AgentSessionFlow:
|
|
124
|
+
"""Build the agent session Flow from the effective configuration.
|
|
125
|
+
|
|
126
|
+
Called once per command run, before the Worker starts. Offer every
|
|
127
|
+
source in ``context.tool_sources`` to the model; a runner-based
|
|
128
|
+
adapter passes them as ``tool_sources``, and a built-in loop harness puts
|
|
129
|
+
them in ``AgentDefinition.tool_sources``. Choose each model call's
|
|
130
|
+
model and instructions with ``context.resolve_turn``. Pass
|
|
131
|
+
``context.media_store`` to an adapter that takes a media store, so
|
|
132
|
+
the model sees the images the user attaches.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
context: The configuration, workspace, and tool sources.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
The Flow, with ``context.flow_type`` as its type. The CLI core registers it.
|
|
139
|
+
"""
|
|
140
|
+
...
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True)
|
|
144
|
+
class HarnessContext:
|
|
145
|
+
"""Everything a harness needs to build its session Flow.
|
|
146
|
+
|
|
147
|
+
Attributes:
|
|
148
|
+
flow_type: The harness's Flow type.
|
|
149
|
+
config: The configuration file's effective configuration, with any
|
|
150
|
+
``--model`` applied. It bounds every turn.
|
|
151
|
+
workspace: The absolute workspace directory, which exists.
|
|
152
|
+
instructions: The file's ``system_prompt``, or the text of its
|
|
153
|
+
``system_prompt_file``, or ``None`` for the harness's default.
|
|
154
|
+
harness_options: The harness's own configuration section, ``{}`` when omitted.
|
|
155
|
+
tool_sources: The connector and local tool sources, which apply each
|
|
156
|
+
turn's session settings themselves. Offer all of them.
|
|
157
|
+
resolver: The turn configuration resolver behind ``resolve_turn``.
|
|
158
|
+
media_store: The store of the session's images, built from the
|
|
159
|
+
file's ``media`` section: the images the user attaches with
|
|
160
|
+
``/image`` and the images connector tools return. Pass it to the
|
|
161
|
+
adapter, such as ``media_store=context.media_store``, so its
|
|
162
|
+
runner reads them from the same store. A runner shows a user
|
|
163
|
+
message's images only when it implements
|
|
164
|
+
``build_user_message_with_images``; otherwise the chat refuses
|
|
165
|
+
them. The CLI core always sets it; ``None`` only in a context a
|
|
166
|
+
test builds without one.
|
|
167
|
+
|
|
168
|
+
.. versionadded:: 0.5.0
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
flow_type: str
|
|
172
|
+
config: EffectiveConfig
|
|
173
|
+
workspace: Path
|
|
174
|
+
instructions: str | None
|
|
175
|
+
harness_options: Mapping[str, Any]
|
|
176
|
+
tool_sources: Sequence[ToolSource]
|
|
177
|
+
resolver: TurnConfigResolver = field(repr=False)
|
|
178
|
+
media_store: MediaStore | None = field(default=None, repr=False)
|
|
179
|
+
|
|
180
|
+
def resolve_turn(self, session_settings: Mapping[str, Any] | None) -> ResolvedTurn:
|
|
181
|
+
"""Return a turn's model, instructions, and narrowed configuration.
|
|
182
|
+
|
|
183
|
+
Call it in each model call with the turn's session settings, such as
|
|
184
|
+
``AgentTurnContext.session_settings`` or a built-in loop ``resolve_model``
|
|
185
|
+
argument.
|
|
186
|
+
|
|
187
|
+
Example:
|
|
188
|
+
>>> turn = context.resolve_turn(instruction_context.session_settings)
|
|
189
|
+
>>> instructions = turn.instructions or "You are a helpful assistant."
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
session_settings: The turn's settings; ``None`` or ``{}`` for none.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
The turn's configuration and instructions.
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
ConfigError: If the settings fail the schema or widen the file, so
|
|
199
|
+
the model call fails with ``<code> at "<pointer>"``.
|
|
200
|
+
"""
|
|
201
|
+
return self.resolver.resolve(session_settings)
|
|
202
|
+
|
|
203
|
+
def current_turn(self) -> ResolvedTurn:
|
|
204
|
+
"""Return the turn whose model call or tool Step is running, or the file's outside both.
|
|
205
|
+
|
|
206
|
+
A harness that replaces local tool groups reads it in its own tools:
|
|
207
|
+
in an approval hook during the model call, and in the tool's own Step,
|
|
208
|
+
where it is the configuration of the turn that made the call, with
|
|
209
|
+
that turn's switches, shell allow list, and ``approval.always``. Use it
|
|
210
|
+
with ``is_local_tool_group_enabled``, ``is_shell_command_approval_required``,
|
|
211
|
+
and ``resolve_turn_tool_policy``.
|
|
212
|
+
|
|
213
|
+
Example:
|
|
214
|
+
>>> @function_tool(effect=ToolEffect.NON_IDEMPOTENT_WRITE)
|
|
215
|
+
... async def run_command(command: str) -> str:
|
|
216
|
+
... turn = context.current_turn()
|
|
217
|
+
... if not is_local_tool_group_enabled(turn, LocalToolGroup.SHELL):
|
|
218
|
+
... raise ToolExecutionError("shell commands are switched off for this session")
|
|
219
|
+
... return await run(command, cwd=context.workspace)
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
The turn's configuration and instructions.
|
|
223
|
+
"""
|
|
224
|
+
return current_turn_config() or self.resolver.resolve(None)
|
|
225
|
+
|
|
226
|
+
def resolve_tool_step_permissions(
|
|
227
|
+
self, call: DeferredToolExecution | None = None
|
|
228
|
+
) -> ResolvedTurn:
|
|
229
|
+
"""Return the permissions a replacement tool's own Step must apply.
|
|
230
|
+
|
|
231
|
+
They are the configuration of the turn that made the call: this
|
|
232
|
+
Worker's configuration file narrowed by the session settings that the
|
|
233
|
+
session recorded for that turn, with its local tool switches, shell
|
|
234
|
+
allow list, and ``approval.always``. A Worker other than the one that
|
|
235
|
+
built the call, with another file, applies its own file. Check them in
|
|
236
|
+
the tool Step before the tool takes any effect, and refuse the call
|
|
237
|
+
when they switch the tool off.
|
|
238
|
+
|
|
239
|
+
Example:
|
|
240
|
+
>>> @function_tool(effect=ToolEffect.NON_IDEMPOTENT_WRITE)
|
|
241
|
+
... async def run_command(command: str) -> str:
|
|
242
|
+
... permissions = context.resolve_tool_step_permissions()
|
|
243
|
+
... if not is_local_tool_group_enabled(permissions, LocalToolGroup.SHELL):
|
|
244
|
+
... raise ToolExecutionError("shell commands are switched off for this session")
|
|
245
|
+
... return await run(command, cwd=context.workspace)
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
call: The tool Step's call, for a runner's ``execute_deferred_tool_call``.
|
|
249
|
+
``None`` reads the tool Step or model call running in this
|
|
250
|
+
task, as a built-in loop function tool does.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
The turn's configuration and instructions.
|
|
254
|
+
|
|
255
|
+
Raises:
|
|
256
|
+
RuntimeError: If no call is given outside a tool Step and a model call.
|
|
257
|
+
ConfigError: If the recorded settings are rejected.
|
|
258
|
+
"""
|
|
259
|
+
if call is not None:
|
|
260
|
+
return self.resolver.resolve(recorded_turn_settings(call))
|
|
261
|
+
return current_turn_config() or _no_running_turn()
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _no_running_turn() -> ResolvedTurn:
|
|
265
|
+
raise RuntimeError(
|
|
266
|
+
"resolve_tool_step_permissions needs a tool Step or a model call; pass the call"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@dataclass(frozen=True, kw_only=True)
|
|
271
|
+
class SessionEnvironmentIdentity:
|
|
272
|
+
"""Name the execution environment a session's Steps must run in.
|
|
273
|
+
|
|
274
|
+
``namespace_session_flow_type`` hashes it into the session Flow type, and
|
|
275
|
+
the command records it in each new session's start input.
|
|
276
|
+
|
|
277
|
+
Example:
|
|
278
|
+
>>> identity = current_session_environment_identity(config_path, workspace)
|
|
279
|
+
>>> namespace_session_flow_type("DexAgentsClaudeSdkCliSession", identity)
|
|
280
|
+
'DexAgentsClaudeSdkCliSession-k3x9q0'
|
|
281
|
+
|
|
282
|
+
Attributes:
|
|
283
|
+
host_name: The host name.
|
|
284
|
+
user_id: The numeric user ID where the system has one, else the user name.
|
|
285
|
+
config_identity: The configuration file's real path, or ``defaults``
|
|
286
|
+
without a file.
|
|
287
|
+
workspace: The workspace's real path.
|
|
288
|
+
|
|
289
|
+
.. versionadded:: 0.5.0
|
|
290
|
+
"""
|
|
291
|
+
|
|
292
|
+
host_name: str
|
|
293
|
+
user_id: str
|
|
294
|
+
config_identity: str
|
|
295
|
+
workspace: str
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def current_session_environment_identity(
|
|
299
|
+
config_path: Path | None,
|
|
300
|
+
workspace: Path,
|
|
301
|
+
*,
|
|
302
|
+
user: str | None = None,
|
|
303
|
+
) -> SessionEnvironmentIdentity:
|
|
304
|
+
"""Return this process's execution environment for a configuration file and workspace.
|
|
305
|
+
|
|
306
|
+
Example:
|
|
307
|
+
>>> current_session_environment_identity(None, Path("~/w").expanduser())
|
|
308
|
+
SessionEnvironmentIdentity(host_name='laptop', user_id='501', ...)
|
|
309
|
+
|
|
310
|
+
Args:
|
|
311
|
+
config_path: The configuration file, or ``None`` when the command runs on defaults.
|
|
312
|
+
workspace: The workspace directory.
|
|
313
|
+
user: The user part; ``None`` uses this process's user.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
The host name, the user, the configuration file's real path or
|
|
317
|
+
``defaults``, and the workspace's real path.
|
|
318
|
+
|
|
319
|
+
.. versionadded:: 0.5.0
|
|
320
|
+
"""
|
|
321
|
+
return SessionEnvironmentIdentity(
|
|
322
|
+
host_name=socket.gethostname(),
|
|
323
|
+
user_id=_current_user() if user is None else user,
|
|
324
|
+
config_identity=(
|
|
325
|
+
_DEFAULTS_CONFIG_IDENTITY if config_path is None else os.path.realpath(config_path)
|
|
326
|
+
),
|
|
327
|
+
workspace=os.path.realpath(workspace),
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def namespace_session_flow_type(
|
|
332
|
+
harness_flow_type: str, identity: SessionEnvironmentIdentity
|
|
333
|
+
) -> str:
|
|
334
|
+
"""Return the session Flow type of a harness in one execution environment.
|
|
335
|
+
|
|
336
|
+
Commands with the same harness but another machine, user, configuration
|
|
337
|
+
file, or workspace register different Flow types against one Dex server.
|
|
338
|
+
Their Workers then never take each other's tool Steps, so an approved
|
|
339
|
+
write always runs with the workspace, tools, and connections it was
|
|
340
|
+
approved under. ``sessions`` and ``--resume`` use the same type.
|
|
341
|
+
|
|
342
|
+
The Flow type is ``<harness_flow_type>-<suffix>``. The suffix is 6
|
|
343
|
+
lowercase base36 characters (``0-9a-z``). Take the SHA-256 of the UTF-8
|
|
344
|
+
text ``"<host name>\\n<user ID>\\n<config identity>\\n<workspace>"``.
|
|
345
|
+
Read its first 8 bytes as an unsigned big-endian integer, reduce it
|
|
346
|
+
modulo 36**6, write it in base36, and left-pad it with ``0`` to 6
|
|
347
|
+
characters. The file's contents, such as tool settings, do not count, so
|
|
348
|
+
editing them keeps the sessions.
|
|
349
|
+
|
|
350
|
+
Example:
|
|
351
|
+
>>> identity = SessionEnvironmentIdentity(
|
|
352
|
+
... host_name="host", user_id="501", config_identity="defaults", workspace="/home/a/w")
|
|
353
|
+
>>> namespace_session_flow_type("Agent", identity)
|
|
354
|
+
'Agent-ujnoef'
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
harness_flow_type: The harness's ``flow_type``.
|
|
358
|
+
identity: The host, user, configuration file, and workspace.
|
|
359
|
+
|
|
360
|
+
Returns:
|
|
361
|
+
``<harness_flow_type>-<6 base36 characters>``.
|
|
362
|
+
|
|
363
|
+
.. versionadded:: 0.5.0
|
|
364
|
+
"""
|
|
365
|
+
text = (
|
|
366
|
+
f"{identity.host_name}\n{identity.user_id}\n"
|
|
367
|
+
f"{identity.config_identity}\n{identity.workspace}"
|
|
368
|
+
)
|
|
369
|
+
digest = hashlib.sha256(text.encode("utf-8")).digest()
|
|
370
|
+
radix = len(_FLOW_TYPE_SUFFIX_ALPHABET)
|
|
371
|
+
remaining = int.from_bytes(digest[:8], "big") % radix**_FLOW_TYPE_SUFFIX_LENGTH
|
|
372
|
+
characters: list[str] = []
|
|
373
|
+
for _ in range(_FLOW_TYPE_SUFFIX_LENGTH):
|
|
374
|
+
remaining, digit = divmod(remaining, radix)
|
|
375
|
+
characters.append(_FLOW_TYPE_SUFFIX_ALPHABET[digit])
|
|
376
|
+
return f"{harness_flow_type}-{''.join(reversed(characters))}"
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def session_flow_type(
|
|
380
|
+
harness_flow_type: str,
|
|
381
|
+
config_path: Path | None,
|
|
382
|
+
workspace: Path,
|
|
383
|
+
*,
|
|
384
|
+
user: str | None = None,
|
|
385
|
+
) -> str:
|
|
386
|
+
"""Return the Flow type a command registers: the harness's, namespaced by environment.
|
|
387
|
+
|
|
388
|
+
It is ``namespace_session_flow_type`` of
|
|
389
|
+
``current_session_environment_identity``; see there for the rule.
|
|
390
|
+
|
|
391
|
+
Example:
|
|
392
|
+
>>> session_flow_type("DexAgentsClaudeSdkCliSession", config_path, workspace)
|
|
393
|
+
'DexAgentsClaudeSdkCliSession-k3x9q0'
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
harness_flow_type: The harness's ``flow_type``.
|
|
397
|
+
config_path: The configuration file, or ``None`` when the command runs on defaults.
|
|
398
|
+
workspace: The workspace directory.
|
|
399
|
+
user: The user part; ``None`` uses this process's user.
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
``<harness_flow_type>-<6 base36 characters>``.
|
|
403
|
+
"""
|
|
404
|
+
return namespace_session_flow_type(
|
|
405
|
+
harness_flow_type, current_session_environment_identity(config_path, workspace, user=user)
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def session_start_environment(identity: SessionEnvironmentIdentity) -> dict[str, str]:
|
|
410
|
+
"""Return what a new session records about where the command runs.
|
|
411
|
+
|
|
412
|
+
The command passes it as ``AgentSessionStartInput.start_environment``.
|
|
413
|
+
``host_name``, ``user_id``, ``config_identity``, and ``workspace`` are the
|
|
414
|
+
values the Flow type suffix hashes. ``user_name`` is the user's name, left
|
|
415
|
+
out when the system cannot tell it.
|
|
416
|
+
|
|
417
|
+
Args:
|
|
418
|
+
identity: The environment the session's Flow type names.
|
|
419
|
+
|
|
420
|
+
Returns:
|
|
421
|
+
The string keys and values to record.
|
|
422
|
+
"""
|
|
423
|
+
environment = {
|
|
424
|
+
"host_name": identity.host_name,
|
|
425
|
+
"user_id": identity.user_id,
|
|
426
|
+
"config_identity": identity.config_identity,
|
|
427
|
+
"workspace": identity.workspace,
|
|
428
|
+
}
|
|
429
|
+
user_name = _current_user_name()
|
|
430
|
+
if user_name is not None:
|
|
431
|
+
environment["user_name"] = user_name
|
|
432
|
+
return environment
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _current_user() -> str:
|
|
436
|
+
get_user_id = getattr(os, "getuid", None)
|
|
437
|
+
return str(get_user_id()) if get_user_id is not None else getpass.getuser()
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _current_user_name() -> str | None:
|
|
441
|
+
try:
|
|
442
|
+
return getpass.getuser()
|
|
443
|
+
except (OSError, KeyError):
|
|
444
|
+
# A container may run as a user ID that has no name.
|
|
445
|
+
return None
|
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
"""Local tools: files and shell in the workspace, web fetch, and a headless browser."""
|
|
10
|
+
|
|
11
|
+
from dex_agents_cli_core.local_tools.browser import BrowserSessions, install_browser
|
|
12
|
+
from dex_agents_cli_core.local_tools.source import (
|
|
13
|
+
LOCAL_TOOL_DEFINITIONS,
|
|
14
|
+
LOCAL_TOOL_SOURCE_NAME,
|
|
15
|
+
LocalToolDefinition,
|
|
16
|
+
LocalToolSource,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"LOCAL_TOOL_DEFINITIONS",
|
|
21
|
+
"LOCAL_TOOL_SOURCE_NAME",
|
|
22
|
+
"BrowserSessions",
|
|
23
|
+
"LocalToolDefinition",
|
|
24
|
+
"LocalToolSource",
|
|
25
|
+
"install_browser",
|
|
26
|
+
]
|