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,650 @@
|
|
|
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 Dex Agents CLI configuration: parsing, defaults, and session narrowing.
|
|
10
|
+
|
|
11
|
+
The rules are the shared contract in ``agents/cli/README.md``, checked by
|
|
12
|
+
``agents/conformance/cli_config.json``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import ipaddress
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
from collections.abc import Iterable, Iterator, Mapping, Sequence
|
|
21
|
+
from dataclasses import dataclass, field, replace
|
|
22
|
+
from importlib import resources
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from jsonschema import Draft202012Validator, ValidationError
|
|
26
|
+
from ruamel.yaml import YAML
|
|
27
|
+
from ruamel.yaml.error import YAMLError
|
|
28
|
+
|
|
29
|
+
#: ``dex.server``, ``connectors.mcp``, and ``media.store`` value that lets the CLI core choose.
|
|
30
|
+
AUTO = "auto"
|
|
31
|
+
#: ``workspace`` when the file omits it.
|
|
32
|
+
DEFAULT_WORKSPACE = "~/dex-agent-workspace"
|
|
33
|
+
#: The JSON Schema of the configuration file.
|
|
34
|
+
CONFIG_SCHEMA: dict[str, Any] = json.loads(
|
|
35
|
+
resources.files("dex_agents_cli_core.schemas").joinpath("config.schema.json").read_text("utf-8")
|
|
36
|
+
)
|
|
37
|
+
#: The JSON Schema of one session's settings.
|
|
38
|
+
SESSION_SETTINGS_SCHEMA: dict[str, Any] = json.loads(
|
|
39
|
+
resources.files("dex_agents_cli_core.schemas")
|
|
40
|
+
.joinpath("session-settings.schema.json")
|
|
41
|
+
.read_text("utf-8")
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
_CONFIG_VALIDATOR = Draft202012Validator(CONFIG_SCHEMA)
|
|
45
|
+
_SESSION_SETTINGS_VALIDATOR = Draft202012Validator(SESSION_SETTINGS_SCHEMA)
|
|
46
|
+
_SESSION_SETTINGS_KEYS = frozenset(SESSION_SETTINGS_SCHEMA["properties"])
|
|
47
|
+
_MAX_PORT = 65535
|
|
48
|
+
_MAX_IPV4_PART = 255
|
|
49
|
+
_URL_PARTS = re.compile(
|
|
50
|
+
r"https?://(?P<host>\[[^\]]*\]|[^:/]+)(?::(?P<port>[0-9]+))?(?:/.*)?", re.DOTALL
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ConfigError(Exception):
|
|
55
|
+
"""Report a rejected configuration file or session settings.
|
|
56
|
+
|
|
57
|
+
Attributes:
|
|
58
|
+
code: The stable error code from the conformance file: ``yaml``,
|
|
59
|
+
``schema``, ``invalid_url``, ``duplicate_tool``,
|
|
60
|
+
``duplicate_connection``, ``model_required``,
|
|
61
|
+
``widens_connectors``, or ``widens_local_tools``.
|
|
62
|
+
path: JSON Pointer of the first problem, ``""`` for the whole value.
|
|
63
|
+
keyword: For ``schema``, the JSON Schema keyword of the first error,
|
|
64
|
+
such as ``anyOf``; ``None`` otherwise.
|
|
65
|
+
schema_errors: For ``schema``, the instance location and keyword of
|
|
66
|
+
every schema error, nested ones included; empty otherwise.
|
|
67
|
+
|
|
68
|
+
Its message is ``<code> at "<pointer>"``, followed by
|
|
69
|
+
`` (schema keyword <keyword>)`` for a schema error, as in the TypeScript CLI core.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
code: str,
|
|
75
|
+
path: str,
|
|
76
|
+
*,
|
|
77
|
+
keyword: str | None = None,
|
|
78
|
+
schema_errors: frozenset[tuple[str, str]] = frozenset(),
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Create the error.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
code: The stable error code.
|
|
84
|
+
path: JSON Pointer of the first problem.
|
|
85
|
+
keyword: The failed JSON Schema keyword of a schema error.
|
|
86
|
+
schema_errors: Every schema error's location and keyword.
|
|
87
|
+
"""
|
|
88
|
+
message = f"{code} at {json.dumps(path)}"
|
|
89
|
+
super().__init__(message if keyword is None else f"{message} (schema keyword {keyword})")
|
|
90
|
+
self.code = code
|
|
91
|
+
self.path = path
|
|
92
|
+
self.keyword = keyword
|
|
93
|
+
self.schema_errors = schema_errors
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class ConnectorEntry:
|
|
98
|
+
"""One ``connectors.enabled`` entry in effective form.
|
|
99
|
+
|
|
100
|
+
Attributes:
|
|
101
|
+
connector: Connector ID, such as ``slack``.
|
|
102
|
+
connection: Connection name, or ``None`` for every connection.
|
|
103
|
+
tools: Snake-case operation names, or ``None`` for every operation.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
connector: str
|
|
107
|
+
connection: str | None
|
|
108
|
+
tools: tuple[str, ...] | None
|
|
109
|
+
|
|
110
|
+
def covers_connection(self, connector: str, connection: str) -> bool:
|
|
111
|
+
"""Report whether this entry names a connection.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
connector: Connector ID.
|
|
115
|
+
connection: Connection name.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
``True`` for the same connector and, when the entry names one, connection.
|
|
119
|
+
"""
|
|
120
|
+
return self.connector == connector and self.connection in (None, connection)
|
|
121
|
+
|
|
122
|
+
def allows_operation(self, operation: str) -> bool:
|
|
123
|
+
"""Report whether this entry allows an operation.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
operation: Operation name in either spelling.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
``True`` when the entry lists no tools or lists this one.
|
|
130
|
+
"""
|
|
131
|
+
return self.tools is None or canonical_operation_name(operation) in self.tools
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True)
|
|
135
|
+
class DexServerConfig:
|
|
136
|
+
"""The ``dex`` section.
|
|
137
|
+
|
|
138
|
+
Attributes:
|
|
139
|
+
server: ``auto`` or the Dex server URL.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
server: str = AUTO
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class ConnectorsConfig:
|
|
147
|
+
"""The ``connectors`` section.
|
|
148
|
+
|
|
149
|
+
Attributes:
|
|
150
|
+
mcp: ``auto`` or the loopback URL of the connectors MCP server.
|
|
151
|
+
enabled: The entries, or ``None`` for every authorized connection.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
mcp: str = AUTO
|
|
155
|
+
enabled: tuple[ConnectorEntry, ...] | None = None
|
|
156
|
+
|
|
157
|
+
def allows_tool(self, connector: str, connection: str, operation: str) -> bool:
|
|
158
|
+
"""Report whether a connector operation on one connection is enabled.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
connector: Connector ID.
|
|
162
|
+
connection: Connection name.
|
|
163
|
+
operation: Operation name in either spelling.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
``True`` when ``enabled`` is ``None`` or an entry covers the call.
|
|
167
|
+
"""
|
|
168
|
+
if self.enabled is None:
|
|
169
|
+
return True
|
|
170
|
+
return any(
|
|
171
|
+
entry.covers_connection(connector, connection) and entry.allows_operation(operation)
|
|
172
|
+
for entry in self.enabled
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass(frozen=True)
|
|
177
|
+
class ApprovalConfig:
|
|
178
|
+
"""The ``approval`` section.
|
|
179
|
+
|
|
180
|
+
Attributes:
|
|
181
|
+
always: Tool names whose every call waits for approval.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
always: tuple[str, ...] = ()
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass(frozen=True)
|
|
188
|
+
class ShellConfig:
|
|
189
|
+
"""The ``local_tools.shell`` section.
|
|
190
|
+
|
|
191
|
+
Attributes:
|
|
192
|
+
enabled: Whether shell commands are offered.
|
|
193
|
+
allow: Commands that run without approval, matched as whole text.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
enabled: bool = True
|
|
197
|
+
allow: tuple[str, ...] = ()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass(frozen=True)
|
|
201
|
+
class LocalToolsConfig:
|
|
202
|
+
"""The ``local_tools`` section.
|
|
203
|
+
|
|
204
|
+
Attributes:
|
|
205
|
+
files: Whether the file tools are offered.
|
|
206
|
+
shell: The shell tool.
|
|
207
|
+
web_fetch: Whether web fetching is offered.
|
|
208
|
+
browser: Whether the browser tools are offered.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
files: bool = True
|
|
212
|
+
shell: ShellConfig = field(default_factory=ShellConfig)
|
|
213
|
+
web_fetch: bool = True
|
|
214
|
+
browser: bool = True
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass(frozen=True)
|
|
218
|
+
class MediaConfig:
|
|
219
|
+
"""The ``media`` section: where the images of the sessions are stored.
|
|
220
|
+
|
|
221
|
+
Session settings never change it.
|
|
222
|
+
|
|
223
|
+
Attributes:
|
|
224
|
+
store: ``auto`` for the local directory ``~/.dex/agents/media``, or
|
|
225
|
+
``s3://<bucket>`` or ``s3://<bucket>/<prefix>``.
|
|
226
|
+
endpoint: The URL of an S3-compatible service, or ``None`` for AWS S3.
|
|
227
|
+
It is only set with an ``s3://`` store.
|
|
228
|
+
|
|
229
|
+
.. versionadded:: 0.5.0
|
|
230
|
+
"""
|
|
231
|
+
|
|
232
|
+
store: str = AUTO
|
|
233
|
+
endpoint: str | None = None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@dataclass(frozen=True)
|
|
237
|
+
class EffectiveConfig:
|
|
238
|
+
"""A configuration with every default applied: a file's, or one turn's.
|
|
239
|
+
|
|
240
|
+
``workspace`` and ``system_prompt_file`` keep their text as written; the
|
|
241
|
+
CLI core expands ``~`` and relative paths when it uses them.
|
|
242
|
+
|
|
243
|
+
Attributes:
|
|
244
|
+
model: The model ID.
|
|
245
|
+
system_prompt: The instructions, or ``None``.
|
|
246
|
+
system_prompt_file: The instructions file, or ``None``.
|
|
247
|
+
dex: The Dex server.
|
|
248
|
+
connectors: The connector tools.
|
|
249
|
+
approval: Extra approval.
|
|
250
|
+
workspace: The local tools' directory.
|
|
251
|
+
local_tools: The local tools.
|
|
252
|
+
harness_options: The command's own harness section, ``{}`` by default.
|
|
253
|
+
media: The media store of the images. Session settings never change it.
|
|
254
|
+
|
|
255
|
+
.. versionadded:: 0.5.0
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
model: str
|
|
259
|
+
system_prompt: str | None = None
|
|
260
|
+
system_prompt_file: str | None = None
|
|
261
|
+
dex: DexServerConfig = field(default_factory=DexServerConfig)
|
|
262
|
+
connectors: ConnectorsConfig = field(default_factory=ConnectorsConfig)
|
|
263
|
+
approval: ApprovalConfig = field(default_factory=ApprovalConfig)
|
|
264
|
+
workspace: str = DEFAULT_WORKSPACE
|
|
265
|
+
local_tools: LocalToolsConfig = field(default_factory=LocalToolsConfig)
|
|
266
|
+
harness_options: Mapping[str, Any] = field(default_factory=dict)
|
|
267
|
+
media: MediaConfig = field(default_factory=MediaConfig)
|
|
268
|
+
|
|
269
|
+
def to_json(self) -> dict[str, Any]:
|
|
270
|
+
"""Return the configuration in the conformance file's JSON shape.
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
A JSON object with lists for sequences and ``null`` for omitted values.
|
|
274
|
+
"""
|
|
275
|
+
enabled = self.connectors.enabled
|
|
276
|
+
return {
|
|
277
|
+
"model": self.model,
|
|
278
|
+
"system_prompt": self.system_prompt,
|
|
279
|
+
"system_prompt_file": self.system_prompt_file,
|
|
280
|
+
"dex": {"server": self.dex.server},
|
|
281
|
+
"connectors": {
|
|
282
|
+
"mcp": self.connectors.mcp,
|
|
283
|
+
"enabled": None
|
|
284
|
+
if enabled is None
|
|
285
|
+
else [
|
|
286
|
+
{
|
|
287
|
+
"connector": entry.connector,
|
|
288
|
+
"connection": entry.connection,
|
|
289
|
+
"tools": None if entry.tools is None else list(entry.tools),
|
|
290
|
+
}
|
|
291
|
+
for entry in enabled
|
|
292
|
+
],
|
|
293
|
+
},
|
|
294
|
+
"approval": {"always": list(self.approval.always)},
|
|
295
|
+
"workspace": self.workspace,
|
|
296
|
+
"local_tools": {
|
|
297
|
+
"files": self.local_tools.files,
|
|
298
|
+
"shell": {
|
|
299
|
+
"enabled": self.local_tools.shell.enabled,
|
|
300
|
+
"allow": list(self.local_tools.shell.allow),
|
|
301
|
+
},
|
|
302
|
+
"web_fetch": self.local_tools.web_fetch,
|
|
303
|
+
"browser": self.local_tools.browser,
|
|
304
|
+
},
|
|
305
|
+
"media": {"store": self.media.store, "endpoint": self.media.endpoint},
|
|
306
|
+
"harness_options": json.loads(json.dumps(self.harness_options)),
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@dataclass(frozen=True)
|
|
311
|
+
class TurnConfigResolution:
|
|
312
|
+
"""One turn's configuration after its session settings.
|
|
313
|
+
|
|
314
|
+
Attributes:
|
|
315
|
+
config: The turn's configuration.
|
|
316
|
+
warnings: JSON Pointers of the ignored top-level settings keys, sorted.
|
|
317
|
+
"""
|
|
318
|
+
|
|
319
|
+
config: EffectiveConfig
|
|
320
|
+
warnings: tuple[str, ...] = ()
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def parse_config_yaml(text: str) -> object:
|
|
324
|
+
"""Return the value of a configuration file's YAML 1.2 text.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
text: The file's text.
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
The parsed value; ``None`` for an empty file.
|
|
331
|
+
|
|
332
|
+
Raises:
|
|
333
|
+
ConfigError: ``yaml`` at ``""`` for text that is not one YAML 1.2
|
|
334
|
+
document with unique keys.
|
|
335
|
+
"""
|
|
336
|
+
yaml = YAML(typ="safe", pure=True)
|
|
337
|
+
yaml.version = (1, 2)
|
|
338
|
+
try:
|
|
339
|
+
return yaml.load(text)
|
|
340
|
+
except YAMLError as error:
|
|
341
|
+
raise ConfigError("yaml", "") from error
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def resolve_config(
|
|
345
|
+
config: object, harness_section: str, harness_default_model: str | None
|
|
346
|
+
) -> EffectiveConfig:
|
|
347
|
+
"""Return the effective configuration of a parsed configuration file.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
config: The parsed file; ``None`` is an empty file.
|
|
351
|
+
harness_section: The command's harness section, such as ``claude``.
|
|
352
|
+
harness_default_model: The command's default model, or ``None``.
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
The configuration with every default applied.
|
|
356
|
+
|
|
357
|
+
Raises:
|
|
358
|
+
ConfigError: ``schema``, ``invalid_url``, ``duplicate_tool``,
|
|
359
|
+
``duplicate_connection``, or ``model_required``, in the
|
|
360
|
+
conformance file's check order.
|
|
361
|
+
"""
|
|
362
|
+
if config is None:
|
|
363
|
+
config = {}
|
|
364
|
+
_raise_first_schema_error(_CONFIG_VALIDATOR, config)
|
|
365
|
+
assert isinstance(config, Mapping), "the schema admits only objects"
|
|
366
|
+
dex = config.get("dex", {})
|
|
367
|
+
connectors = config.get("connectors", {})
|
|
368
|
+
server = dex.get("server", AUTO)
|
|
369
|
+
mcp = connectors.get("mcp", AUTO)
|
|
370
|
+
_raise_invalid_url(server, "/dex/server")
|
|
371
|
+
_raise_invalid_url(mcp, "/connectors/mcp")
|
|
372
|
+
media = config.get("media", {})
|
|
373
|
+
endpoint = media.get("endpoint")
|
|
374
|
+
if endpoint is not None:
|
|
375
|
+
_raise_invalid_url(endpoint, "/media/endpoint")
|
|
376
|
+
enabled = connectors.get("enabled")
|
|
377
|
+
entries = None if enabled is None else _parse_connector_entries(enabled, None)
|
|
378
|
+
model = config.get("model", harness_default_model)
|
|
379
|
+
if model is None:
|
|
380
|
+
raise ConfigError("model_required", "/model")
|
|
381
|
+
local_tools = config.get("local_tools", {})
|
|
382
|
+
shell = local_tools.get("shell", {})
|
|
383
|
+
return EffectiveConfig(
|
|
384
|
+
model=model,
|
|
385
|
+
system_prompt=config.get("system_prompt"),
|
|
386
|
+
system_prompt_file=config.get("system_prompt_file"),
|
|
387
|
+
dex=DexServerConfig(server=server),
|
|
388
|
+
connectors=ConnectorsConfig(mcp=mcp, enabled=entries),
|
|
389
|
+
approval=ApprovalConfig(always=tuple(config.get("approval", {}).get("always", []))),
|
|
390
|
+
workspace=config.get("workspace", DEFAULT_WORKSPACE),
|
|
391
|
+
local_tools=LocalToolsConfig(
|
|
392
|
+
files=local_tools.get("files", True),
|
|
393
|
+
shell=ShellConfig(
|
|
394
|
+
enabled=shell.get("enabled", True), allow=tuple(shell.get("allow", []))
|
|
395
|
+
),
|
|
396
|
+
web_fetch=local_tools.get("web_fetch", True),
|
|
397
|
+
browser=local_tools.get("browser", True),
|
|
398
|
+
),
|
|
399
|
+
harness_options=json.loads(json.dumps(config.get(harness_section, {}))),
|
|
400
|
+
media=MediaConfig(store=media.get("store", AUTO), endpoint=endpoint),
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def resolve_turn_config(
|
|
405
|
+
effective: EffectiveConfig, session_settings: object
|
|
406
|
+
) -> TurnConfigResolution:
|
|
407
|
+
"""Return one turn's configuration: the file's, narrowed by the session settings.
|
|
408
|
+
|
|
409
|
+
Args:
|
|
410
|
+
effective: The file's effective configuration, which bounds the turn.
|
|
411
|
+
session_settings: The session's settings, or ``None`` for none.
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
The turn's configuration and the ignored settings keys.
|
|
415
|
+
|
|
416
|
+
Raises:
|
|
417
|
+
ConfigError: ``schema``, ``duplicate_tool``, ``duplicate_connection``,
|
|
418
|
+
``widens_connectors``, or ``widens_local_tools``. The whole
|
|
419
|
+
override is rejected.
|
|
420
|
+
"""
|
|
421
|
+
if session_settings is None:
|
|
422
|
+
return TurnConfigResolution(effective)
|
|
423
|
+
_raise_first_schema_error(_SESSION_SETTINGS_VALIDATOR, session_settings)
|
|
424
|
+
assert isinstance(session_settings, Mapping), "the schema admits only objects"
|
|
425
|
+
warnings = tuple(
|
|
426
|
+
_json_pointer([key])
|
|
427
|
+
for key in sorted(session_settings)
|
|
428
|
+
if key not in _SESSION_SETTINGS_KEYS
|
|
429
|
+
)
|
|
430
|
+
turn = effective
|
|
431
|
+
if "model" in session_settings:
|
|
432
|
+
turn = replace(turn, model=session_settings["model"])
|
|
433
|
+
if "system_prompt" in session_settings:
|
|
434
|
+
turn = replace(
|
|
435
|
+
turn, system_prompt=session_settings["system_prompt"], system_prompt_file=None
|
|
436
|
+
)
|
|
437
|
+
enabled = session_settings.get("connectors", {}).get("enabled")
|
|
438
|
+
if enabled is not None:
|
|
439
|
+
entries = _parse_connector_entries(enabled, effective.connectors.enabled)
|
|
440
|
+
turn = replace(turn, connectors=replace(turn.connectors, enabled=entries))
|
|
441
|
+
added = [
|
|
442
|
+
tool
|
|
443
|
+
for tool in dict.fromkeys(session_settings.get("approval", {}).get("always", []))
|
|
444
|
+
if tool not in effective.approval.always
|
|
445
|
+
]
|
|
446
|
+
turn = replace(turn, approval=ApprovalConfig(always=(*effective.approval.always, *added)))
|
|
447
|
+
local_tools = _narrow_local_tools(
|
|
448
|
+
effective.local_tools, session_settings.get("local_tools", {})
|
|
449
|
+
)
|
|
450
|
+
return TurnConfigResolution(replace(turn, local_tools=local_tools), warnings)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def canonical_operation_name(name: str) -> str:
|
|
454
|
+
"""Return an operation's snake-case name by the connectors library's lowerCamelToSnake rule.
|
|
455
|
+
|
|
456
|
+
A capital letter starts a new word after a lowercase letter or a digit, or
|
|
457
|
+
when it ends an acronym run before a lowercase letter that is not a plural
|
|
458
|
+
``s``. A snake-case name is returned unchanged.
|
|
459
|
+
|
|
460
|
+
Example:
|
|
461
|
+
>>> canonical_operation_name("listStoryIDs")
|
|
462
|
+
'list_story_ids'
|
|
463
|
+
|
|
464
|
+
Args:
|
|
465
|
+
name: A snake-case name or an operationId.
|
|
466
|
+
|
|
467
|
+
Returns:
|
|
468
|
+
The snake-case name.
|
|
469
|
+
"""
|
|
470
|
+
converted: list[str] = []
|
|
471
|
+
for index, character in enumerate(name):
|
|
472
|
+
if not _is_ascii_upper(character):
|
|
473
|
+
converted.append(character)
|
|
474
|
+
continue
|
|
475
|
+
previous = name[index - 1] if index > 0 else ""
|
|
476
|
+
following = name[index + 1] if index + 1 < len(name) else ""
|
|
477
|
+
after_following = name[index + 2] if index + 2 < len(name) else ""
|
|
478
|
+
is_after_lower_or_digit = _is_ascii_lower(previous) or previous.isdigit()
|
|
479
|
+
is_acronym_plural = following == "s" and not _is_ascii_lower(after_following)
|
|
480
|
+
is_end_of_acronym = (
|
|
481
|
+
_is_ascii_upper(previous) and _is_ascii_lower(following) and not is_acronym_plural
|
|
482
|
+
)
|
|
483
|
+
if is_after_lower_or_digit or is_end_of_acronym:
|
|
484
|
+
converted.append("_")
|
|
485
|
+
converted.append(character.lower())
|
|
486
|
+
return "".join(converted)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def is_shell_command_allowed(command: str, allow: Iterable[str]) -> bool:
|
|
490
|
+
"""Report whether a shell command runs without approval.
|
|
491
|
+
|
|
492
|
+
Args:
|
|
493
|
+
command: The command text as the agent sent it.
|
|
494
|
+
allow: The allow list.
|
|
495
|
+
|
|
496
|
+
Returns:
|
|
497
|
+
``True`` only when the whole text equals an entry; ``ls -la`` does not match ``ls``.
|
|
498
|
+
"""
|
|
499
|
+
return command in tuple(allow)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def schema_error_locations(errors: Iterable[ValidationError]) -> frozenset[tuple[str, str]]:
|
|
503
|
+
"""Return the instance location and keyword of every schema error, nested ones included.
|
|
504
|
+
|
|
505
|
+
Args:
|
|
506
|
+
errors: Errors from a validator.
|
|
507
|
+
|
|
508
|
+
Returns:
|
|
509
|
+
``(JSON Pointer, keyword)`` pairs.
|
|
510
|
+
"""
|
|
511
|
+
return frozenset(
|
|
512
|
+
(_json_pointer(error.absolute_path), str(error.validator)) for error in _walk(errors)
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _walk(errors: Iterable[ValidationError]) -> Iterator[ValidationError]:
|
|
517
|
+
for error in errors:
|
|
518
|
+
yield error
|
|
519
|
+
yield from _walk(error.context or [])
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _raise_first_schema_error(validator: Draft202012Validator, instance: object) -> None:
|
|
523
|
+
errors = list(validator.iter_errors(instance))
|
|
524
|
+
if errors:
|
|
525
|
+
first = errors[0]
|
|
526
|
+
raise ConfigError(
|
|
527
|
+
"schema",
|
|
528
|
+
_json_pointer(first.absolute_path),
|
|
529
|
+
keyword=str(first.validator),
|
|
530
|
+
schema_errors=schema_error_locations(errors),
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _raise_invalid_url(url: str, path: str) -> None:
|
|
535
|
+
if url == AUTO:
|
|
536
|
+
return
|
|
537
|
+
parts = _URL_PARTS.fullmatch(url)
|
|
538
|
+
assert parts is not None, "the schema admits only URLs of this shape"
|
|
539
|
+
host, port = parts["host"], parts["port"]
|
|
540
|
+
if port is not None and int(port) > _MAX_PORT:
|
|
541
|
+
raise ConfigError("invalid_url", path)
|
|
542
|
+
if host.startswith("["):
|
|
543
|
+
try:
|
|
544
|
+
ipaddress.IPv6Address(host[1:-1])
|
|
545
|
+
except ValueError as error:
|
|
546
|
+
raise ConfigError("invalid_url", path) from error
|
|
547
|
+
elif re.fullmatch(r"[0-9.]+", host):
|
|
548
|
+
# A leading zero means octal to a WHATWG parser, so only canonical decimals pass.
|
|
549
|
+
if any(
|
|
550
|
+
not re.fullmatch(r"0|[1-9][0-9]{0,2}", part) or int(part) > _MAX_IPV4_PART
|
|
551
|
+
for part in host.split(".")
|
|
552
|
+
):
|
|
553
|
+
raise ConfigError("invalid_url", path)
|
|
554
|
+
elif any(label.lower().startswith("xn--") for label in host.split(".")):
|
|
555
|
+
raise ConfigError("invalid_url", path)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _parse_connector_entries(
|
|
559
|
+
raw_entries: Sequence[str | Mapping[str, Any]],
|
|
560
|
+
bounds: tuple[ConnectorEntry, ...] | None,
|
|
561
|
+
) -> tuple[ConnectorEntry, ...]:
|
|
562
|
+
"""Parse entries in list order; ``bounds`` is the file's list when narrowing a session."""
|
|
563
|
+
parsed: list[ConnectorEntry] = []
|
|
564
|
+
for index, raw_entry in enumerate(raw_entries):
|
|
565
|
+
path = f"/connectors/enabled/{index}"
|
|
566
|
+
entry = _parse_connector_entry(raw_entry, path)
|
|
567
|
+
if any(_covers_same_connection(earlier, entry) for earlier in parsed):
|
|
568
|
+
raise ConfigError("duplicate_connection", path)
|
|
569
|
+
if bounds is not None and not any(_bounds_entry(bound, entry) for bound in bounds):
|
|
570
|
+
raise ConfigError("widens_connectors", path)
|
|
571
|
+
parsed.append(entry)
|
|
572
|
+
return tuple(parsed)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _parse_connector_entry(raw_entry: str | Mapping[str, Any], path: str) -> ConnectorEntry:
|
|
576
|
+
if isinstance(raw_entry, str):
|
|
577
|
+
key, tools = raw_entry, None
|
|
578
|
+
else:
|
|
579
|
+
((key, options),) = raw_entry.items()
|
|
580
|
+
tools = tuple(canonical_operation_name(name) for name in options["tools"])
|
|
581
|
+
if len(set(tools)) != len(tools):
|
|
582
|
+
raise ConfigError("duplicate_tool", path)
|
|
583
|
+
connector, _, connection = key.partition("/")
|
|
584
|
+
return ConnectorEntry(connector=connector, connection=connection or None, tools=tools)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _covers_same_connection(first: ConnectorEntry, second: ConnectorEntry) -> bool:
|
|
588
|
+
return first.connector == second.connector and (
|
|
589
|
+
first.connection is None
|
|
590
|
+
or second.connection is None
|
|
591
|
+
or first.connection == second.connection
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _bounds_entry(bound: ConnectorEntry, entry: ConnectorEntry) -> bool:
|
|
596
|
+
if bound.connector != entry.connector:
|
|
597
|
+
return False
|
|
598
|
+
if bound.connection is not None and bound.connection != entry.connection:
|
|
599
|
+
return False
|
|
600
|
+
if bound.tools is None:
|
|
601
|
+
return True
|
|
602
|
+
return entry.tools is not None and set(entry.tools) <= set(bound.tools)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _narrow_local_tools(
|
|
606
|
+
file_tools: LocalToolsConfig, session_tools: Mapping[str, Any]
|
|
607
|
+
) -> LocalToolsConfig:
|
|
608
|
+
"""Apply session local tool settings in check order, rejecting the first widening."""
|
|
609
|
+
files = _narrow_switch(file_tools.files, session_tools, "files", "/local_tools")
|
|
610
|
+
session_shell = session_tools.get("shell", {})
|
|
611
|
+
shell_enabled = _narrow_switch(
|
|
612
|
+
file_tools.shell.enabled, session_shell, "enabled", "/local_tools/shell"
|
|
613
|
+
)
|
|
614
|
+
allow = file_tools.shell.allow
|
|
615
|
+
if "allow" in session_shell:
|
|
616
|
+
for index, command in enumerate(session_shell["allow"]):
|
|
617
|
+
if command not in file_tools.shell.allow:
|
|
618
|
+
raise ConfigError("widens_local_tools", f"/local_tools/shell/allow/{index}")
|
|
619
|
+
allow = tuple(session_shell["allow"])
|
|
620
|
+
web_fetch = _narrow_switch(file_tools.web_fetch, session_tools, "web_fetch", "/local_tools")
|
|
621
|
+
browser = _narrow_switch(file_tools.browser, session_tools, "browser", "/local_tools")
|
|
622
|
+
return LocalToolsConfig(
|
|
623
|
+
files=files,
|
|
624
|
+
shell=ShellConfig(enabled=shell_enabled, allow=allow),
|
|
625
|
+
web_fetch=web_fetch,
|
|
626
|
+
browser=browser,
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _narrow_switch(
|
|
631
|
+
file_value: bool, session_section: Mapping[str, Any], switch: str, parent_path: str
|
|
632
|
+
) -> bool:
|
|
633
|
+
if switch not in session_section:
|
|
634
|
+
return file_value
|
|
635
|
+
session_value = bool(session_section[switch])
|
|
636
|
+
if session_value and not file_value:
|
|
637
|
+
raise ConfigError("widens_local_tools", f"{parent_path}/{switch}")
|
|
638
|
+
return session_value
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _is_ascii_upper(character: str) -> bool:
|
|
642
|
+
return character != "" and "A" <= character <= "Z"
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _is_ascii_lower(character: str) -> bool:
|
|
646
|
+
return character != "" and "a" <= character <= "z"
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _json_pointer(parts: Iterable[object]) -> str:
|
|
650
|
+
return "".join("/" + str(part).replace("~", "~0").replace("/", "~1") for part in parts)
|