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,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
+ ]