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,671 @@
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 command line of every harness command: ``init``, ``run``, ``connectors``, ``sessions``."""
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import asyncio
15
+ import contextlib
16
+ import importlib
17
+ import logging
18
+ import secrets
19
+ import sys
20
+ import webbrowser
21
+ from collections.abc import Sequence
22
+ from dataclasses import dataclass, replace
23
+ from datetime import datetime, timedelta
24
+ from pathlib import Path
25
+
26
+ import httpx2
27
+ from dex import (
28
+ AsyncClient,
29
+ AsyncWorker,
30
+ BlobCacheConfig,
31
+ ClientOptions,
32
+ FlowNotFoundError,
33
+ FlowServiceTlsOptions,
34
+ FlowStatus,
35
+ PullWorkerOptions,
36
+ Registry,
37
+ WorkerOptions,
38
+ open_blob_cache,
39
+ )
40
+
41
+ from dex_agents import (
42
+ AgentSessionClient,
43
+ LocalDirectoryMediaStore,
44
+ MediaStore,
45
+ session_flow_definition_document,
46
+ )
47
+ from dex_agents.builtin_loop import ToolSource
48
+ from dex_agents_cli_core.chat import Terminal, TerminalChat
49
+ from dex_agents_cli_core.config import AUTO, ConnectorEntry, EffectiveConfig
50
+ from dex_agents_cli_core.config_file import (
51
+ ConfigFileError,
52
+ LoadedConfig,
53
+ ask_config_values,
54
+ default_config_path,
55
+ default_config_values,
56
+ defaults_without_file,
57
+ load_config_file,
58
+ write_config_file,
59
+ )
60
+ from dex_agents_cli_core.connectors import (
61
+ ConnectorCatalogTool,
62
+ ConnectorsMcpToolSource,
63
+ ConnectorsMcpUnavailableError,
64
+ read_connector_catalog,
65
+ )
66
+ from dex_agents_cli_core.dex_server import DexServerEndpoint, DexServerError, resolve_dex_server
67
+ from dex_agents_cli_core.environment import CommandEnvironment
68
+ from dex_agents_cli_core.harness import (
69
+ SESSION_ACTION_PERMISSION,
70
+ AgentHarness,
71
+ HarnessContext,
72
+ LocalToolGroup,
73
+ SessionEnvironmentIdentity,
74
+ current_session_environment_identity,
75
+ namespace_session_flow_type,
76
+ session_flow_type,
77
+ session_start_environment,
78
+ )
79
+ from dex_agents_cli_core.local_tools import LocalToolSource
80
+ from dex_agents_cli_core.turns import TurnConfigResolver, with_turn_config
81
+
82
+ #: The Dex Web page where connections are authorized.
83
+ CONNECTORS_WEB_PATH = "/v1/connectors"
84
+ #: The directory under ``~/.dex/agents`` that ``media.store: auto`` keeps images in.
85
+ MEDIA_DIRECTORY = "media"
86
+ _BLOB_CACHE_BYTES = 64 * 1024 * 1024
87
+ _WORKER_REGISTRATION_TIMEOUT_SECONDS = 30.0
88
+ # How long the command waits for the session graph upload before it logs a failure.
89
+ _SESSION_FLOW_DEFINITION_UPLOAD_TIMEOUT_SECONDS = 10.0
90
+ _SESSION_LIST_PAGE_SIZE = 50
91
+ _SESSION_START_TIMEOUT_SECONDS = 60.0
92
+ _SESSION_START_POLL_SECONDS = 0.1
93
+ _LOGGER = logging.getLogger(__name__)
94
+
95
+
96
+ class CommandError(Exception):
97
+ """Report a usage or configuration error; the command prints it and exits with code 2."""
98
+
99
+
100
+ class CommandFailure(Exception):
101
+ """Report a failure at run time; the command prints it and exits with code 1."""
102
+
103
+
104
+ @dataclass(frozen=True)
105
+ class _Arguments:
106
+ command: str
107
+ config_path: Path | None
108
+ is_yes: bool
109
+ resume_session_id: str | None
110
+ model: str | None
111
+
112
+
113
+ def run_agent_cli(harness: AgentHarness, argv: Sequence[str] | None = None) -> int:
114
+ """Run a harness command: parse arguments, then run its subcommand.
115
+
116
+ Call it from a harness package's console script:
117
+
118
+ Example:
119
+ >>> def run() -> None:
120
+ ... raise SystemExit(run_agent_cli(ClaudeHarness()))
121
+
122
+ Args:
123
+ harness: The command's harness.
124
+ argv: The arguments without the program name; ``None`` reads ``sys.argv``.
125
+
126
+ Returns:
127
+ The exit code: 0 on success, 1 for a failure at run time, such as a
128
+ Dex server or an explicit connectors MCP server that is unavailable,
129
+ and 2 for a usage or configuration error.
130
+ """
131
+ arguments = _parse_arguments(harness, argv)
132
+ try:
133
+ environment = CommandEnvironment.from_environment()
134
+ except ValueError as error:
135
+ print(f"{command_name(harness)}: {error}", file=sys.stderr)
136
+ return 2
137
+ _configure_logging(environment, harness)
138
+ try:
139
+ match arguments.command:
140
+ case "init":
141
+ return _init(arguments, environment, harness)
142
+ case "sessions":
143
+ return asyncio.run(_list_sessions(arguments, environment, harness))
144
+ case "connectors":
145
+ return asyncio.run(_list_connectors(arguments, environment, harness))
146
+ case _:
147
+ return asyncio.run(_run(arguments, environment, harness))
148
+ except (CommandError, ConfigFileError) as error:
149
+ print(f"{command_name(harness)}: {error}", file=sys.stderr)
150
+ return 2
151
+ except (CommandFailure, DexServerError) as error:
152
+ print(f"{command_name(harness)}: {error}", file=sys.stderr)
153
+ return 1
154
+
155
+
156
+ def command_name(harness: AgentHarness) -> str:
157
+ """Return a harness's command name, ``dex-agents-<name>``.
158
+
159
+ Args:
160
+ harness: The harness.
161
+
162
+ Returns:
163
+ The console script name.
164
+ """
165
+ return f"dex-agents-{harness.name}"
166
+
167
+
168
+ def _parse_arguments(harness: AgentHarness, argv: Sequence[str] | None) -> _Arguments:
169
+ parser = argparse.ArgumentParser(
170
+ prog=command_name(harness),
171
+ description="Chat with a durable Dex agent in the terminal.",
172
+ )
173
+ parser.add_argument(
174
+ "command",
175
+ nargs="?",
176
+ default="run",
177
+ choices=("run", "init", "connectors", "sessions"),
178
+ help="run (the default) chats; init writes the configuration file; connectors lists "
179
+ "the connector tools; sessions lists this agent's sessions",
180
+ )
181
+ parser.add_argument("-c", "--config", type=Path, default=None, help="configuration file")
182
+ parser.add_argument(
183
+ "-y", "--yes", action="store_true", help="write and use the defaults instead of asking"
184
+ )
185
+ parser.add_argument("-r", "--resume", metavar="SESSION_ID", help="continue a session")
186
+ parser.add_argument("-m", "--model", help="override the configured model")
187
+ namespace = parser.parse_args(argv)
188
+ return _Arguments(
189
+ command=namespace.command,
190
+ config_path=namespace.config,
191
+ is_yes=namespace.yes,
192
+ resume_session_id=namespace.resume,
193
+ model=namespace.model,
194
+ )
195
+
196
+
197
+ def _configure_logging(environment: CommandEnvironment, harness: AgentHarness) -> None:
198
+ environment.agents_directory.mkdir(parents=True, exist_ok=True)
199
+ log_path = environment.agents_directory / f"{harness.name}.log"
200
+ handler = logging.FileHandler(log_path, encoding="utf-8")
201
+ handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
202
+ root = logging.getLogger()
203
+ root.handlers[:] = [handler]
204
+ root.setLevel(logging.INFO)
205
+
206
+
207
+ def _is_interactive() -> bool:
208
+ return sys.stdin.isatty() and sys.stdout.isatty()
209
+
210
+
211
+ def _init(arguments: _Arguments, environment: CommandEnvironment, harness: AgentHarness) -> int:
212
+ path = arguments.config_path or default_config_path(environment, harness)
213
+ if arguments.is_yes or not _is_interactive():
214
+ values = default_config_values(harness)
215
+ else:
216
+ values = ask_config_values(harness, _ask)
217
+ write_config_file(path, values, harness)
218
+ print(f"Wrote {path}.")
219
+ return 0
220
+
221
+
222
+ def _load_config(
223
+ arguments: _Arguments, environment: CommandEnvironment, harness: AgentHarness
224
+ ) -> LoadedConfig:
225
+ """Load the file, or create it first as ``H`` in the plan describes."""
226
+ path = arguments.config_path or default_config_path(environment, harness)
227
+ if path.exists():
228
+ return load_config_file(path, harness)
229
+ if arguments.config_path is not None:
230
+ raise CommandError(f"{path} does not exist")
231
+ if arguments.is_yes:
232
+ loaded = write_config_file(path, default_config_values(harness), harness)
233
+ print(f"Wrote the default configuration to {path}.")
234
+ return loaded
235
+ if _is_interactive():
236
+ print(f"No configuration at {path}; answer a few questions to create it.")
237
+ loaded = write_config_file(path, ask_config_values(harness, _ask), harness)
238
+ print(f"Wrote {path}.")
239
+ return loaded
240
+ print(
241
+ f"No configuration at {path}; using the defaults. "
242
+ f"Run `{command_name(harness)} init` to create one."
243
+ )
244
+ return defaults_without_file(harness, environment.home)
245
+
246
+
247
+ def _ask(question: str, default: str) -> str:
248
+ shown = f" [{default}]" if default else ""
249
+ try:
250
+ answer = input(f"{question}{shown}: ")
251
+ except EOFError:
252
+ answer = ""
253
+ return answer if answer.strip() else default
254
+
255
+
256
+ async def _run(
257
+ arguments: _Arguments, environment: CommandEnvironment, harness: AgentHarness
258
+ ) -> int:
259
+ loaded = _load_config(arguments, environment, harness)
260
+ config = _with_model_override(loaded.config, arguments)
261
+ media_store = _build_media_store(config, environment)
262
+ prompt_text = _read_system_prompt_file(config, loaded, environment)
263
+ workspace = environment.expand_path(config.workspace, loaded.directory)
264
+ workspace.mkdir(parents=True, exist_ok=True)
265
+ resolver = TurnConfigResolver(
266
+ config,
267
+ config.system_prompt if config.system_prompt is not None else prompt_text,
268
+ _report_ignored_settings,
269
+ )
270
+ endpoint = await _resolve_server(config, environment)
271
+ connectors = await _connect_connectors(config, endpoint, resolver, harness, media_store)
272
+ local_tools = _local_tool_source(config, workspace, harness, resolver)
273
+ tool_sources: list[ToolSource] = [
274
+ source for source in (connectors, local_tools) if source is not None
275
+ ]
276
+ identity = current_session_environment_identity(loaded.path, workspace)
277
+ flow_type = namespace_session_flow_type(harness.flow_type, identity)
278
+ context = HarnessContext(
279
+ flow_type=flow_type,
280
+ config=config,
281
+ workspace=workspace.resolve(),
282
+ instructions=resolver.file_instructions,
283
+ harness_options=config.harness_options,
284
+ tool_sources=tuple(tool_sources),
285
+ resolver=resolver,
286
+ media_store=media_store,
287
+ )
288
+ flow = with_turn_config(harness.create_session_flow(context), resolver)
289
+ if flow.get_flow_type() != flow_type:
290
+ raise CommandError(f"the harness built Flow type {flow.get_flow_type()}, not {flow_type}")
291
+ registry = Registry([flow], allow_async_handlers=True)
292
+ cache_directory = environment.agents_directory / "cache" / harness.name
293
+ cache_directory.mkdir(parents=True, exist_ok=True)
294
+ blob_cache = open_blob_cache(BlobCacheConfig(str(cache_directory), _BLOB_CACHE_BYTES))
295
+ tls = FlowServiceTlsOptions() if endpoint.is_tls else None
296
+ client = AsyncClient(
297
+ registry, blob_cache, ClientOptions(server_address=endpoint.address, flow_service_tls=tls)
298
+ )
299
+ worker = AsyncWorker(
300
+ registry,
301
+ blob_cache,
302
+ WorkerOptions(
303
+ server_address=endpoint.address,
304
+ flow_service_tls=tls,
305
+ pull=PullWorkerOptions(stop_drain_timeout=timedelta(seconds=5)),
306
+ ),
307
+ )
308
+ serving = asyncio.create_task(worker.start())
309
+ terminal = Terminal()
310
+ try:
311
+ await worker.wait_pull_registered(timeout=_WORKER_REGISTRATION_TIMEOUT_SECONDS)
312
+ await _upload_session_flow_definition(client, flow_type)
313
+ sessions = AgentSessionClient(client, flow)
314
+ session_id = await _open_session(
315
+ client, sessions, arguments.resume_session_id, harness, flow_type, identity
316
+ )
317
+ print(f"Session {session_id}")
318
+ if endpoint.web_url is not None:
319
+ print(f"Dex Web: {endpoint.web_url}/v1/flows/{session_id}?view=session")
320
+ terminal.start()
321
+ chat = TerminalChat(
322
+ client,
323
+ sessions,
324
+ session_id,
325
+ terminal,
326
+ command_name(harness),
327
+ media_store=media_store,
328
+ home=environment.home,
329
+ )
330
+ is_failed = not await chat.run()
331
+ finally:
332
+ with contextlib.suppress(RuntimeError):
333
+ terminal.stop()
334
+ await worker.stop()
335
+ with contextlib.suppress(asyncio.CancelledError):
336
+ await serving
337
+ if local_tools is not None:
338
+ await local_tools.browser.close()
339
+ await client.close()
340
+ blob_cache.close()
341
+ return 1 if is_failed else 0
342
+
343
+
344
+ def _with_model_override(config: EffectiveConfig, arguments: _Arguments) -> EffectiveConfig:
345
+ return config if arguments.model is None else replace(config, model=arguments.model)
346
+
347
+
348
+ def _report_on_standard_error(line: str) -> None:
349
+ print(line, file=sys.stderr)
350
+
351
+
352
+ def _report_ignored_settings(warnings: Sequence[str]) -> None:
353
+ print(f"Session settings keys ignored: {', '.join(warnings)}", file=sys.stderr)
354
+
355
+
356
+ def _build_media_store(config: EffectiveConfig, environment: CommandEnvironment) -> MediaStore:
357
+ """Build the store of ``media.store``: ``~/.dex/agents/media`` for auto, else the S3 bucket."""
358
+ media = config.media
359
+ try:
360
+ if media.store == AUTO:
361
+ return LocalDirectoryMediaStore(
362
+ (environment.agents_directory / MEDIA_DIRECTORY).absolute()
363
+ )
364
+ try:
365
+ # Imported only for an s3 store, so the default store needs no boto3.
366
+ s3 = importlib.import_module("dex_agents.s3")
367
+ except ImportError as error:
368
+ raise CommandError(
369
+ f"media.store {media.store} needs the S3 support of dex-agents; install it "
370
+ "into this command's environment with: pip install 'dex-agents[s3]'"
371
+ ) from error
372
+ store: MediaStore = s3.S3MediaStore.from_store_uri(
373
+ media.store,
374
+ endpoint_url=media.endpoint,
375
+ force_path_style=media.endpoint is not None,
376
+ )
377
+ return store
378
+ except ValueError as error:
379
+ raise CommandError(f"media.store {media.store}: {error}") from error
380
+
381
+
382
+ def _read_system_prompt_file(
383
+ config: EffectiveConfig, loaded: LoadedConfig, environment: CommandEnvironment
384
+ ) -> str | None:
385
+ if config.system_prompt_file is None:
386
+ return None
387
+ path = environment.expand_path(config.system_prompt_file, loaded.directory)
388
+ try:
389
+ return path.read_text(encoding="utf-8")
390
+ except (OSError, UnicodeDecodeError) as error:
391
+ raise CommandError(f"cannot read system_prompt_file {path}: {error}") from error
392
+
393
+
394
+ async def _resolve_server(
395
+ config: EffectiveConfig, environment: CommandEnvironment
396
+ ) -> DexServerEndpoint:
397
+ return await resolve_dex_server(config.dex.server, environment, report=print)
398
+
399
+
400
+ def _connectors_mcp_url(config: EffectiveConfig, endpoint: DexServerEndpoint) -> str | None:
401
+ if config.connectors.mcp != AUTO:
402
+ return config.connectors.mcp
403
+ return endpoint.connectors_mcp_url
404
+
405
+
406
+ async def _connect_connectors(
407
+ config: EffectiveConfig,
408
+ endpoint: DexServerEndpoint,
409
+ resolver: TurnConfigResolver,
410
+ harness: AgentHarness,
411
+ media_store: MediaStore,
412
+ ) -> ConnectorsMcpToolSource | None:
413
+ """Check the connectors MCP server and return its source, or ``None`` for no connector tools.
414
+
415
+ An unavailable ``auto`` server only warns: the source lists it again on
416
+ every turn, so a server that is still starting comes alive. A harness
417
+ whose tool set is fixed at build time gets no connector tools in this run
418
+ then, and the warning says to restart it. An explicit URL that is
419
+ unavailable fails the command with exit code 1.
420
+ """
421
+ if config.connectors.enabled == ():
422
+ return None
423
+ url = _connectors_mcp_url(config, endpoint)
424
+ if url is None:
425
+ print(
426
+ "Connector tools are off: connectors.mcp is auto, which needs dex.server: auto. "
427
+ "Set connectors.mcp to the MCP server's URL."
428
+ )
429
+ return None
430
+ is_tool_set_fixed = _is_tool_set_fixed_at_build(harness)
431
+ try:
432
+ catalog = await read_connector_catalog(url)
433
+ except ConnectorsMcpUnavailableError as error:
434
+ message = f"The connectors MCP server is unavailable: {error}"
435
+ if config.connectors.mcp != AUTO:
436
+ raise CommandFailure(message) from error
437
+ if is_tool_set_fixed:
438
+ print(f"{message}. {_restart_for_connector_tools(harness)}")
439
+ else:
440
+ print(f"{message}. Continuing without connector tools until it answers.")
441
+ return ConnectorsMcpToolSource(
442
+ url,
443
+ resolver,
444
+ is_available=False,
445
+ report=_report_on_standard_error,
446
+ media_store=media_store,
447
+ )
448
+ _report_unauthorized(config, catalog, endpoint.web_url)
449
+ source = ConnectorsMcpToolSource(
450
+ url, resolver, report=_report_on_standard_error, media_store=media_store
451
+ )
452
+ await source.list_file_enabled_tools()
453
+ if is_tool_set_fixed and not source.is_available:
454
+ restart = _restart_for_connector_tools(harness)
455
+ print(f"The connectors MCP server could not be listed. {restart}")
456
+ return source
457
+
458
+
459
+ def _is_tool_set_fixed_at_build(harness: AgentHarness) -> bool:
460
+ return getattr(harness, "is_tool_set_fixed_at_build", False) is True
461
+
462
+
463
+ def _restart_for_connector_tools(harness: AgentHarness) -> str:
464
+ return (
465
+ f"{command_name(harness)} fixes its tools when it starts, so this run has no connector "
466
+ "tools. Restart it once the connectors MCP server answers."
467
+ )
468
+
469
+
470
+ def _report_unauthorized(
471
+ config: EffectiveConfig, catalog: Sequence[ConnectorCatalogTool], web_url: str | None
472
+ ) -> None:
473
+ missing = [
474
+ entry
475
+ for entry in config.connectors.enabled or ()
476
+ if not any(_entry_has_tool(entry, tool) for tool in catalog)
477
+ ]
478
+ if not missing:
479
+ return
480
+ page = f"{web_url}{CONNECTORS_WEB_PATH}" if web_url else "the Dex Web Connectors page"
481
+ names = ", ".join(_entry_text(entry) for entry in missing)
482
+ print(f"No authorized connection for {names}. Authorize it on {page}.")
483
+ if web_url is not None and _is_interactive():
484
+ webbrowser.open(f"{web_url}{CONNECTORS_WEB_PATH}")
485
+
486
+
487
+ def _entry_has_tool(entry: ConnectorEntry, tool: ConnectorCatalogTool) -> bool:
488
+ identity = tool.identity
489
+ return identity is not None and entry.covers_connection(identity.connector, identity.connection)
490
+
491
+
492
+ def _entry_text(entry: ConnectorEntry) -> str:
493
+ return entry.connector if entry.connection is None else f"{entry.connector}/{entry.connection}"
494
+
495
+
496
+ def _local_tool_source(
497
+ config: EffectiveConfig,
498
+ workspace: Path,
499
+ harness: AgentHarness,
500
+ resolver: TurnConfigResolver,
501
+ ) -> LocalToolSource | None:
502
+ switches = {
503
+ LocalToolGroup.FILES: config.local_tools.files,
504
+ LocalToolGroup.SHELL: config.local_tools.shell.enabled,
505
+ LocalToolGroup.WEB_FETCH: config.local_tools.web_fetch,
506
+ LocalToolGroup.BROWSER: config.local_tools.browser,
507
+ }
508
+ groups = frozenset(
509
+ group
510
+ for group, is_enabled in switches.items()
511
+ if is_enabled and group not in harness.replaced_local_tools
512
+ )
513
+ return LocalToolSource(workspace, groups, resolver) if groups else None
514
+
515
+
516
+ async def _upload_session_flow_definition(client: AsyncClient, flow_type: str) -> None:
517
+ """Upload the session graph so Dex Web shows the session without ``--flow-rendering-dir``.
518
+
519
+ The Client uploads it after the Worker registered, not ``WorkerOptions.flow_definitions``,
520
+ because the Worker's upload runs inside ``start`` with a two-minute deadline. The outcome
521
+ and any failure go to the log file only.
522
+ """
523
+ document = session_flow_definition_document(flow_type, SESSION_ACTION_PERMISSION)
524
+ try:
525
+ (result,) = await asyncio.wait_for(
526
+ client.upload_flow_definitions([document]),
527
+ _SESSION_FLOW_DEFINITION_UPLOAD_TIMEOUT_SECONDS,
528
+ )
529
+ except Exception as error: # Any failure only loses the Dex Web graph of this session.
530
+ _LOGGER.warning(
531
+ "session Flow definition upload failed; Dex Web shows this session only with "
532
+ "--flow-rendering-dir: flowType=%s %s",
533
+ flow_type,
534
+ _describe_upload_error(error),
535
+ )
536
+ return
537
+ _LOGGER.info(
538
+ "session Flow definition %s: flowType=%s storedDigest=%s",
539
+ result.outcome.value,
540
+ flow_type,
541
+ result.stored_digest,
542
+ )
543
+
544
+
545
+ def _describe_upload_error(error: BaseException) -> str:
546
+ """Return the error's type and text, or its type alone when the text is empty."""
547
+ text = str(error)
548
+ return f"{type(error).__name__}: {text}" if text else type(error).__name__
549
+
550
+
551
+ async def _open_session(
552
+ client: AsyncClient,
553
+ sessions: AgentSessionClient,
554
+ resume_session_id: str | None,
555
+ harness: AgentHarness,
556
+ flow_type: str,
557
+ identity: SessionEnvironmentIdentity,
558
+ ) -> str:
559
+ if resume_session_id is not None:
560
+ try:
561
+ info = await client.describe_flow(resume_session_id)
562
+ except FlowNotFoundError as error:
563
+ raise CommandError(
564
+ f"session {resume_session_id} does not exist; list sessions with "
565
+ f"{command_name(harness)} sessions"
566
+ ) from error
567
+ if info.flow_type != flow_type:
568
+ raise CommandError(
569
+ f"session {resume_session_id} is a {info.flow_type} session, not {flow_type}; "
570
+ "resume it as the user, with the configuration file and workspace, that started it"
571
+ )
572
+ if info.status is not FlowStatus.RUNNING:
573
+ raise CommandError(
574
+ f"session {resume_session_id} is {info.status.value}, so it cannot continue"
575
+ )
576
+ return resume_session_id
577
+ session_id = f"{harness.name}-{datetime.now():%Y%m%d-%H%M%S}-{secrets.token_hex(3)}"
578
+ await sessions.start_session(session_id, start_environment=session_start_environment(identity))
579
+ loop = asyncio.get_running_loop()
580
+ deadline = loop.time() + _SESSION_START_TIMEOUT_SECONDS
581
+ while (await sessions.get_agent_snapshot(session_id)).state is None:
582
+ if loop.time() > deadline:
583
+ raise CommandError(f"session {session_id} did not start; is the Worker running?")
584
+ await asyncio.sleep(_SESSION_START_POLL_SECONDS)
585
+ return session_id
586
+
587
+
588
+ async def _list_sessions(
589
+ arguments: _Arguments, environment: CommandEnvironment, harness: AgentHarness
590
+ ) -> int:
591
+ loaded = _load_config(arguments, environment, harness)
592
+ endpoint = await _resolve_server(loaded.config, environment)
593
+ workspace = environment.expand_path(loaded.config.workspace, loaded.directory)
594
+ flow_type = session_flow_type(harness.flow_type, loaded.path, workspace)
595
+ blob_cache = open_blob_cache(
596
+ BlobCacheConfig(str(environment.agents_directory / "cache" / "sessions"), _BLOB_CACHE_BYTES)
597
+ )
598
+ tls = FlowServiceTlsOptions() if endpoint.is_tls else None
599
+ client = AsyncClient(
600
+ Registry([], allow_async_handlers=True),
601
+ blob_cache,
602
+ ClientOptions(server_address=endpoint.address, flow_service_tls=tls),
603
+ )
604
+ try:
605
+ page = await client.search_flows(f"FlowType = {_quote(flow_type)}", _SESSION_LIST_PAGE_SIZE)
606
+ finally:
607
+ await client.close()
608
+ blob_cache.close()
609
+ if not page.flows:
610
+ print("No sessions yet.")
611
+ return 0
612
+ for entry in page.flows:
613
+ started = entry.started_at.astimezone().strftime("%Y-%m-%d %H:%M")
614
+ print(f"{entry.flow_id} {entry.status.name.lower()} started {started}")
615
+ print(f"Resume one with: {command_name(harness)} --resume <session-id>")
616
+ return 0
617
+
618
+
619
+ async def _list_connectors(
620
+ arguments: _Arguments, environment: CommandEnvironment, harness: AgentHarness
621
+ ) -> int:
622
+ loaded = _load_config(arguments, environment, harness)
623
+ config = loaded.config
624
+ endpoint = await _resolve_server(config, environment)
625
+ url = _connectors_mcp_url(config, endpoint)
626
+ if url is None:
627
+ raise CommandError("connectors.mcp is auto, which needs dex.server: auto")
628
+ try:
629
+ catalog = await read_connector_catalog(url)
630
+ except ConnectorsMcpUnavailableError as error:
631
+ print(f"The connectors MCP server is unavailable: {error}")
632
+ return 1
633
+ print(f"Connector tools at {url}:")
634
+ if not catalog:
635
+ print(" none; authorize a connection in Dex Web first")
636
+ for tool in catalog:
637
+ identity = tool.identity
638
+ where = (
639
+ f"{identity.connector}/{identity.connection} {identity.operation or '(no operation)'}"
640
+ if identity
641
+ else "unknown connector"
642
+ )
643
+ is_enabled = config.connectors.enabled is None or (
644
+ identity is not None and identity.is_enabled_by(config.connectors)
645
+ )
646
+ print(f" {tool.name} ({where}) {'enabled' if is_enabled else 'not enabled'}")
647
+ if endpoint.web_url is not None:
648
+ await _print_connections(endpoint.web_url)
649
+ return 0
650
+
651
+
652
+ async def _print_connections(web_url: str) -> None:
653
+ try:
654
+ async with httpx2.AsyncClient(timeout=10.0) as http:
655
+ response = await http.get(f"{web_url}/api/v2/connector-connections")
656
+ connections = response.json().get("connections", []) if response.is_success else []
657
+ except (httpx2.HTTPError, ValueError) as error:
658
+ _LOGGER.warning("cannot list the Dex Web connections: %s", error)
659
+ return
660
+ print(f"Connections in Dex Web ({web_url}{CONNECTORS_WEB_PATH}):")
661
+ if not connections:
662
+ print(" none")
663
+ for connection in connections:
664
+ print(
665
+ f" {connection.get('connectorId')}/{connection.get('connectionName')} "
666
+ f"{connection.get('status')}"
667
+ )
668
+
669
+
670
+ def _quote(text: str) -> str:
671
+ return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"'