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,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('"', '\\"') + '"'
|