deep-agent-cli 0.1.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.
- agent/__init__.py +42 -0
- agent/attachments.py +303 -0
- agent/bootstrap.py +44 -0
- agent/cancel.py +107 -0
- agent/cli/__init__.py +5 -0
- agent/cli/app.py +1768 -0
- agent/cli/clipboard.py +224 -0
- agent/cli/commands.py +94 -0
- agent/cli/gitinfo.py +84 -0
- agent/cli/input.py +65 -0
- agent/cli/interactions.py +187 -0
- agent/cli/main.py +124 -0
- agent/cli/previews.py +710 -0
- agent/cli/rendering.py +770 -0
- agent/cli/session_controller.py +221 -0
- agent/cli/state.py +326 -0
- agent/config.example.yaml +76 -0
- agent/config.py +528 -0
- agent/control.py +171 -0
- agent/factory.py +232 -0
- agent/file_mutation.py +5 -0
- agent/llm.py +339 -0
- agent/middleware/__init__.py +9 -0
- agent/middleware/attachments.py +31 -0
- agent/middleware/cancel_tools.py +39 -0
- agent/middleware/pause.py +18 -0
- agent/middleware/recovery.py +65 -0
- agent/middleware/steering.py +35 -0
- agent/middleware/tool_arg_hints.py +128 -0
- agent/middleware/workspace_filesystem.py +38 -0
- agent/middleware/write_operation.py +60 -0
- agent/network.py +30 -0
- agent/permission.py +80 -0
- agent/runner.py +1393 -0
- agent/sandbox.py +699 -0
- agent/session.py +431 -0
- agent/session_lock.py +223 -0
- agent/session_runtime.py +209 -0
- agent/stream.py +168 -0
- agent/tools/__init__.py +9 -0
- agent/tools/examples.py +30 -0
- agent/tools/execute.py +73 -0
- agent/tools/human_input.py +170 -0
- agent/tools/human_interaction.py +101 -0
- agent/tools/web_search.py +131 -0
- deep_agent_cli-0.1.0.dist-info/METADATA +408 -0
- deep_agent_cli-0.1.0.dist-info/RECORD +50 -0
- deep_agent_cli-0.1.0.dist-info/WHEEL +4 -0
- deep_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
- deep_agent_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
agent/config.py
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Literal, Mapping
|
|
8
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
SAFE_INHERITED_ENV = (
|
|
13
|
+
"LANG",
|
|
14
|
+
"TERM",
|
|
15
|
+
"COLORTERM",
|
|
16
|
+
"NO_COLOR",
|
|
17
|
+
"TZ",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
_DEFAULT_CONFIG_NAME = "config.yaml"
|
|
21
|
+
DEFAULT_UI_TIMEZONE = "Asia/Shanghai"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConfigError(ValueError):
|
|
25
|
+
"""A user configuration error safe to display without a traceback."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def default_skills_dir() -> Path:
|
|
29
|
+
return Path.home() / ".deep-agent" / "skills"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class BindMount:
|
|
34
|
+
"""An explicit host-to-sandbox mount."""
|
|
35
|
+
|
|
36
|
+
source: Path
|
|
37
|
+
destination: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class ModelProfile:
|
|
42
|
+
"""Named OpenAI-compatible chat model profile."""
|
|
43
|
+
|
|
44
|
+
id: str
|
|
45
|
+
model: str
|
|
46
|
+
api_key: str = "sk-local"
|
|
47
|
+
base_url: str = "http://localhost:8000/v1"
|
|
48
|
+
input: tuple["InputKind", ...] = ("text",)
|
|
49
|
+
provider: Literal["qwen-responses", "openai-compatible"] = "qwen-responses"
|
|
50
|
+
# 0 = unknown, so the UI can hide the context meter instead of guessing.
|
|
51
|
+
context_window: int = 0
|
|
52
|
+
source: str = ""
|
|
53
|
+
stream_usage: bool = False
|
|
54
|
+
|
|
55
|
+
def supports_input(self, kind: "InputKind") -> bool:
|
|
56
|
+
return kind in self.input
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
InputKind = Literal["text", "image"]
|
|
60
|
+
_INPUT_KINDS = frozenset({"text", "image"})
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class SandboxConfig:
|
|
65
|
+
"""Bubblewrap policy. Defaults expose only the workspace and read-only runtimes."""
|
|
66
|
+
|
|
67
|
+
workspace: Path = field(default_factory=Path.cwd)
|
|
68
|
+
bwrap_path: str = "bwrap"
|
|
69
|
+
allow_unsandboxed: bool = False
|
|
70
|
+
timeout_seconds: int | None = None
|
|
71
|
+
max_output_bytes: int = 100_000
|
|
72
|
+
env_allowlist: tuple[str, ...] = ()
|
|
73
|
+
env_set: dict[str, str] = field(default_factory=dict)
|
|
74
|
+
extra_read_only_mounts: tuple[BindMount, ...] = ()
|
|
75
|
+
extra_read_write_mounts: tuple[BindMount, ...] = ()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _default_profiles() -> tuple[ModelProfile, ...]:
|
|
79
|
+
return (
|
|
80
|
+
ModelProfile(
|
|
81
|
+
id="default",
|
|
82
|
+
model="qwen3.5-plus",
|
|
83
|
+
api_key="sk-local",
|
|
84
|
+
base_url="http://localhost:8000/v1",
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class Settings:
|
|
91
|
+
llm_profiles: tuple[ModelProfile, ...] = field(default_factory=_default_profiles)
|
|
92
|
+
llm_default: str = "default"
|
|
93
|
+
sandbox: SandboxConfig = field(default_factory=SandboxConfig)
|
|
94
|
+
state_path: Path | None = None
|
|
95
|
+
config_dir: Path | None = None
|
|
96
|
+
source_path: Path | None = None
|
|
97
|
+
agent_instructions: str | None = None
|
|
98
|
+
ui_timezone: str = DEFAULT_UI_TIMEZONE
|
|
99
|
+
tavily_api_key: str | None = field(default=None, repr=False)
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def llm_model(self) -> str:
|
|
103
|
+
return self.active_profile.model
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def llm_api_key(self) -> str:
|
|
107
|
+
return self.active_profile.api_key
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def llm_base_url(self) -> str:
|
|
111
|
+
return self.active_profile.base_url
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def active_profile(self) -> ModelProfile:
|
|
115
|
+
return self.get_profile(self.llm_default)
|
|
116
|
+
|
|
117
|
+
def get_profile(self, id_or_prefix: str) -> ModelProfile:
|
|
118
|
+
key = id_or_prefix.strip()
|
|
119
|
+
if not key:
|
|
120
|
+
raise KeyError("model id is empty")
|
|
121
|
+
exact = next((item for item in self.llm_profiles if item.id == key), None)
|
|
122
|
+
if exact is not None:
|
|
123
|
+
return exact
|
|
124
|
+
matches = [item for item in self.llm_profiles if item.id.startswith(key)]
|
|
125
|
+
if len(matches) == 1:
|
|
126
|
+
return matches[0]
|
|
127
|
+
if not matches:
|
|
128
|
+
raise KeyError(f"Unknown model: {id_or_prefix}")
|
|
129
|
+
ids = ", ".join(item.id for item in matches)
|
|
130
|
+
raise KeyError(f"Ambiguous model prefix {id_or_prefix!r}: {ids}")
|
|
131
|
+
|
|
132
|
+
def list_profiles(self) -> tuple[ModelProfile, ...]:
|
|
133
|
+
return self.llm_profiles
|
|
134
|
+
|
|
135
|
+
@classmethod
|
|
136
|
+
def load(cls, path: str | Path | None = None, *, base_dir: Path | None = None) -> "Settings":
|
|
137
|
+
"""Load settings from a path, explicit base directory, or ~/.deep-agent/config.yaml."""
|
|
138
|
+
resolved = resolve_config_path(path, base_dir=base_dir)
|
|
139
|
+
if resolved is None:
|
|
140
|
+
return cls()
|
|
141
|
+
try:
|
|
142
|
+
data = _read_yaml(resolved)
|
|
143
|
+
if "llm" not in data:
|
|
144
|
+
raise ValueError("llm.models must define grouped model sources")
|
|
145
|
+
return cls.from_mapping(data, base_dir=resolved.parent, source_path=resolved)
|
|
146
|
+
except yaml.YAMLError as exc:
|
|
147
|
+
mark = getattr(exc, "problem_mark", None)
|
|
148
|
+
location = f" at line {mark.line + 1}, column {mark.column + 1}" if mark else ""
|
|
149
|
+
raise ConfigError(f"{resolved}: invalid YAML syntax{location}") from exc
|
|
150
|
+
except OSError as exc:
|
|
151
|
+
raise ConfigError(f"{resolved}: cannot read configuration ({exc.strerror or type(exc).__name__})") from exc
|
|
152
|
+
except ValueError as exc:
|
|
153
|
+
raise ConfigError(f"{resolved}: {exc}") from exc
|
|
154
|
+
|
|
155
|
+
@classmethod
|
|
156
|
+
def from_mapping(
|
|
157
|
+
cls,
|
|
158
|
+
data: Mapping[str, Any] | None,
|
|
159
|
+
*,
|
|
160
|
+
base_dir: Path | None = None,
|
|
161
|
+
source_path: Path | None = None,
|
|
162
|
+
) -> "Settings":
|
|
163
|
+
raw = dict(data or {})
|
|
164
|
+
root = (base_dir or Path.cwd()).expanduser().resolve()
|
|
165
|
+
llm = _section(raw, "llm")
|
|
166
|
+
agent = _section(raw, "agent")
|
|
167
|
+
ui = _section(raw, "ui")
|
|
168
|
+
paths = _section(raw, "paths")
|
|
169
|
+
sandbox_raw = _section(raw, "sandbox")
|
|
170
|
+
web_search = _section(raw, "web_search")
|
|
171
|
+
if "protected_workspace_paths" in sandbox_raw:
|
|
172
|
+
raise ValueError(
|
|
173
|
+
"sandbox.protected_workspace_paths was removed; move skills to "
|
|
174
|
+
"~/.deep-agent/skills and use explicit read-only mounts for other resources"
|
|
175
|
+
)
|
|
176
|
+
profiles, default_id = _llm_profiles_from_mapping(llm) if "llm" in raw else (_default_profiles(), "default")
|
|
177
|
+
instructions = agent.get("instructions")
|
|
178
|
+
if instructions is not None and not isinstance(instructions, str):
|
|
179
|
+
raise ValueError("agent.instructions must be a string or null")
|
|
180
|
+
timezone_name = ui.get("timezone", DEFAULT_UI_TIMEZONE)
|
|
181
|
+
if not isinstance(timezone_name, str) or not timezone_name.strip():
|
|
182
|
+
raise ValueError("ui.timezone must be a valid IANA time zone name")
|
|
183
|
+
try:
|
|
184
|
+
ZoneInfo(timezone_name)
|
|
185
|
+
except (ZoneInfoNotFoundError, ValueError) as exc:
|
|
186
|
+
raise ValueError(f"ui.timezone is invalid: {timezone_name}") from exc
|
|
187
|
+
result = cls(
|
|
188
|
+
llm_profiles=profiles,
|
|
189
|
+
llm_default=default_id,
|
|
190
|
+
sandbox=_sandbox_from_mapping(sandbox_raw, base_dir=root),
|
|
191
|
+
state_path=_optional_path(paths.get("state_path"), base_dir=root),
|
|
192
|
+
config_dir=_optional_path(paths.get("config_dir"), base_dir=root),
|
|
193
|
+
source_path=source_path.resolve() if source_path is not None else None,
|
|
194
|
+
agent_instructions=instructions,
|
|
195
|
+
ui_timezone=timezone_name,
|
|
196
|
+
tavily_api_key=os.environ.get("TAVILY_API_KEY") or _optional_secret(
|
|
197
|
+
web_search.get("tavily_api_key"), field_name="web_search.tavily_api_key"
|
|
198
|
+
),
|
|
199
|
+
)
|
|
200
|
+
if result.source_path is not None:
|
|
201
|
+
require_outside_workspace(result.source_path, result.sandbox.workspace, label="config file")
|
|
202
|
+
if result.config_dir is not None:
|
|
203
|
+
require_keybindings_outside_workspace(result.config_dir, result.sandbox.workspace)
|
|
204
|
+
return result
|
|
205
|
+
|
|
206
|
+
# Backward-compatible alias used by older call sites / docs.
|
|
207
|
+
@classmethod
|
|
208
|
+
def from_env(cls) -> "Settings":
|
|
209
|
+
return cls.load()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def resolve_config_path(
|
|
213
|
+
path: str | Path | None = None,
|
|
214
|
+
*,
|
|
215
|
+
base_dir: Path | None = None,
|
|
216
|
+
) -> Path | None:
|
|
217
|
+
if path is not None:
|
|
218
|
+
candidate = Path(path).expanduser()
|
|
219
|
+
if not candidate.is_file():
|
|
220
|
+
raise FileNotFoundError(f"config file not found: {candidate}")
|
|
221
|
+
return candidate.resolve()
|
|
222
|
+
env = os.environ.get("DEEP_AGENT_CONFIG")
|
|
223
|
+
if env:
|
|
224
|
+
candidate = Path(env).expanduser()
|
|
225
|
+
if not candidate.is_file():
|
|
226
|
+
raise FileNotFoundError(f"DEEP_AGENT_CONFIG not found: {candidate}")
|
|
227
|
+
return candidate.resolve()
|
|
228
|
+
root = base_dir.expanduser().resolve() if base_dir is not None else Path.home() / ".deep-agent"
|
|
229
|
+
candidate = root / _DEFAULT_CONFIG_NAME
|
|
230
|
+
return candidate.resolve() if candidate.is_file() else None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def require_outside_workspace(path: Path, workspace: Path, *, label: str) -> None:
|
|
234
|
+
"""Keep runtime configuration outside the agent's writable workspace."""
|
|
235
|
+
if path.expanduser().resolve().is_relative_to(workspace.expanduser().resolve()):
|
|
236
|
+
raise ValueError(f"{label} must be outside workspace: {path}")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def require_keybindings_outside_workspace(config_dir: Path, workspace: Path) -> None:
|
|
240
|
+
require_outside_workspace(config_dir, workspace, label="keybindings directory")
|
|
241
|
+
require_outside_workspace(config_dir / "keybindings.json", workspace, label="keybindings file")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _read_yaml(path: Path) -> dict[str, Any]:
|
|
245
|
+
loaded = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
246
|
+
if loaded is None:
|
|
247
|
+
return {}
|
|
248
|
+
if not isinstance(loaded, dict):
|
|
249
|
+
raise ValueError(f"config root must be a mapping: {path}")
|
|
250
|
+
return loaded
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _section(data: Mapping[str, Any], name: str) -> dict[str, Any]:
|
|
254
|
+
value = data.get(name) or {}
|
|
255
|
+
if not isinstance(value, dict):
|
|
256
|
+
raise ValueError(f"config.{name} must be a mapping")
|
|
257
|
+
return value
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _optional_path(value: Any, *, base_dir: Path) -> Path | None:
|
|
261
|
+
if value is None or value == "":
|
|
262
|
+
return None
|
|
263
|
+
path = Path(str(value)).expanduser()
|
|
264
|
+
if not path.is_absolute():
|
|
265
|
+
path = base_dir / path
|
|
266
|
+
return path.resolve()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _required_path(value: Any, *, base_dir: Path, default: Path) -> Path:
|
|
270
|
+
if value is None or value == "":
|
|
271
|
+
return default.expanduser().resolve()
|
|
272
|
+
path = Path(str(value)).expanduser()
|
|
273
|
+
if not path.is_absolute():
|
|
274
|
+
path = base_dir / path
|
|
275
|
+
return path.resolve()
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _workspace_path(value: Any, *, base_dir: Path) -> Path:
|
|
279
|
+
"""Resolve sandbox workspace.
|
|
280
|
+
|
|
281
|
+
``.`` / ``./`` / omitted → process cwd (start directory).
|
|
282
|
+
Other relative paths resolve against the config file directory.
|
|
283
|
+
Absolute paths are used as-is.
|
|
284
|
+
"""
|
|
285
|
+
if value is None or value == "":
|
|
286
|
+
return Path.cwd().resolve()
|
|
287
|
+
text = str(value).strip()
|
|
288
|
+
if text in {".", "./"}:
|
|
289
|
+
return Path.cwd().resolve()
|
|
290
|
+
return _required_path(value, base_dir=base_dir, default=Path.cwd())
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _as_bool(value: Any, *, field_name: str) -> bool:
|
|
294
|
+
if isinstance(value, bool):
|
|
295
|
+
return value
|
|
296
|
+
if isinstance(value, (int, float)) and value in {0, 1}:
|
|
297
|
+
return bool(value)
|
|
298
|
+
if isinstance(value, str):
|
|
299
|
+
normalized = value.strip().lower()
|
|
300
|
+
if normalized in {"1", "true", "yes", "on"}:
|
|
301
|
+
return True
|
|
302
|
+
if normalized in {"0", "false", "no", "off"}:
|
|
303
|
+
return False
|
|
304
|
+
raise ValueError(f"{field_name} must be a boolean, got {value!r}")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _as_positive_int(value: Any, *, field_name: str, default: int) -> int:
|
|
308
|
+
if value is None:
|
|
309
|
+
return default
|
|
310
|
+
try:
|
|
311
|
+
number = int(value)
|
|
312
|
+
except (TypeError, ValueError) as exc:
|
|
313
|
+
raise ValueError(f"{field_name} must be an integer, got {value!r}") from exc
|
|
314
|
+
if number <= 0:
|
|
315
|
+
raise ValueError(f"{field_name} must be positive, got {number}")
|
|
316
|
+
return number
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _as_str_tuple(value: Any, *, field_name: str) -> tuple[str, ...]:
|
|
320
|
+
if value is None:
|
|
321
|
+
return ()
|
|
322
|
+
if isinstance(value, str):
|
|
323
|
+
return tuple(part.strip() for part in value.split(",") if part.strip())
|
|
324
|
+
if isinstance(value, (list, tuple)):
|
|
325
|
+
return tuple(str(item).strip() for item in value if str(item).strip())
|
|
326
|
+
raise ValueError(f"{field_name} must be a list of strings, got {value!r}")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _as_str_dict(value: Any, *, field_name: str) -> dict[str, str]:
|
|
330
|
+
if value is None:
|
|
331
|
+
return {}
|
|
332
|
+
if not isinstance(value, Mapping):
|
|
333
|
+
raise ValueError(f"{field_name} must be a mapping, got {value!r}")
|
|
334
|
+
return {str(key): str(item) for key, item in value.items()}
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _as_mounts(value: Any, *, field_name: str, base_dir: Path) -> tuple[BindMount, ...]:
|
|
338
|
+
if value is None:
|
|
339
|
+
return ()
|
|
340
|
+
if not isinstance(value, list):
|
|
341
|
+
raise ValueError(f"{field_name} must be a list of mounts, got {value!r}")
|
|
342
|
+
mounts: list[BindMount] = []
|
|
343
|
+
for index, item in enumerate(value):
|
|
344
|
+
if not isinstance(item, Mapping):
|
|
345
|
+
raise ValueError(f"{field_name}[{index}] must be a mapping")
|
|
346
|
+
source = item.get("source")
|
|
347
|
+
destination = item.get("destination")
|
|
348
|
+
if source is None or destination is None:
|
|
349
|
+
raise ValueError(f"{field_name}[{index}] requires source and destination")
|
|
350
|
+
source_path = Path(str(source)).expanduser()
|
|
351
|
+
if not source_path.is_absolute():
|
|
352
|
+
source_path = (base_dir / source_path).resolve()
|
|
353
|
+
else:
|
|
354
|
+
source_path = source_path.resolve()
|
|
355
|
+
mounts.append(BindMount(source=source_path, destination=str(destination)))
|
|
356
|
+
return tuple(mounts)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _llm_profiles_from_mapping(llm: Mapping[str, Any]) -> tuple[tuple[ModelProfile, ...], str]:
|
|
360
|
+
legacy_fields = {"model", "api_key", "base_url", "input", "provider", "context_window", "source", "stream_usage"}
|
|
361
|
+
unsupported = legacy_fields.intersection(llm)
|
|
362
|
+
if unsupported:
|
|
363
|
+
field = sorted(unsupported)[0]
|
|
364
|
+
raise ValueError(f"llm.{field} is unsupported; configure it under llm.models.<source>")
|
|
365
|
+
models_raw = llm.get("models")
|
|
366
|
+
if not isinstance(models_raw, Mapping) or not models_raw:
|
|
367
|
+
raise ValueError("llm.models must be a non-empty mapping of grouped model sources")
|
|
368
|
+
return _grouped_llm_profiles(llm, models_raw)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _grouped_llm_profiles(
|
|
372
|
+
llm: Mapping[str, Any], groups: Mapping[str, Any],
|
|
373
|
+
) -> tuple[tuple[ModelProfile, ...], str]:
|
|
374
|
+
profiles: list[ModelProfile] = []
|
|
375
|
+
for source_key, group in groups.items():
|
|
376
|
+
if not isinstance(source_key, str) or not source_key.strip() or "/" in source_key:
|
|
377
|
+
raise ValueError("llm.models source keys must be non-empty strings containing no slash")
|
|
378
|
+
source = source_key.strip()
|
|
379
|
+
if not isinstance(group, Mapping) or not isinstance(group.get("models"), Mapping) or not group["models"]:
|
|
380
|
+
raise ValueError(f"llm.models.{source}.models must be a non-empty mapping")
|
|
381
|
+
for model_key, item in group["models"].items():
|
|
382
|
+
if not isinstance(model_key, str) or not model_key.strip() or "/" in model_key:
|
|
383
|
+
raise ValueError(f"llm.models.{source}.models keys must be non-empty strings containing no slash")
|
|
384
|
+
name = model_key.strip()
|
|
385
|
+
field = f"llm.models.{source}.models.{name}"
|
|
386
|
+
if not isinstance(item, Mapping):
|
|
387
|
+
raise ValueError(f"{field} must be a mapping")
|
|
388
|
+
def inherited(key: str) -> Any:
|
|
389
|
+
return item.get(key, group.get(key))
|
|
390
|
+
model_name = item.get("model", name)
|
|
391
|
+
if not isinstance(model_name, str) or not model_name.strip():
|
|
392
|
+
raise ValueError(f"{field}.model must be a non-empty string")
|
|
393
|
+
profiles.append(ModelProfile(
|
|
394
|
+
id=f"{source}/{name}",
|
|
395
|
+
model=model_name.strip(),
|
|
396
|
+
api_key=_optional_secret(
|
|
397
|
+
_model_text(inherited("api_key"), field_name=f"{field}.api_key", default="sk-local"),
|
|
398
|
+
field_name=f"{field}.api_key",
|
|
399
|
+
) or "sk-local",
|
|
400
|
+
base_url=_model_text(inherited("base_url"), field_name=f"{field}.base_url", default="http://localhost:8000/v1"),
|
|
401
|
+
input=_model_inputs(inherited("input"), field_name=f"{field}.input"),
|
|
402
|
+
provider=_model_provider(inherited("provider"), field_name=f"{field}.provider"),
|
|
403
|
+
context_window=_model_context_window(inherited("context_window"), field_name=f"{field}.context_window"),
|
|
404
|
+
source=source,
|
|
405
|
+
stream_usage=_model_stream_usage(inherited("stream_usage"), field_name=f"{field}.stream_usage"),
|
|
406
|
+
))
|
|
407
|
+
default = llm.get("default")
|
|
408
|
+
if not isinstance(default, str) or not default.strip():
|
|
409
|
+
raise ValueError("llm.default is required for grouped models (source/model)")
|
|
410
|
+
default_id = default.strip()
|
|
411
|
+
if default_id not in {item.id for item in profiles}:
|
|
412
|
+
raise ValueError(f"llm.default must name a configured source/model; unknown: {default_id}")
|
|
413
|
+
return tuple(profiles), default_id
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _model_text(value: Any, *, field_name: str, default: str) -> str:
|
|
417
|
+
if value is None:
|
|
418
|
+
return default
|
|
419
|
+
if not isinstance(value, str) or not value.strip():
|
|
420
|
+
raise ValueError(f"{field_name} must be a non-empty string")
|
|
421
|
+
return value.strip()
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
_ENV_REFERENCE = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$")
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _optional_secret(value: Any, *, field_name: str) -> str | None:
|
|
428
|
+
if value is None or value == "":
|
|
429
|
+
return None
|
|
430
|
+
if not isinstance(value, str) or not value.strip():
|
|
431
|
+
raise ValueError(f"{field_name} must be a non-empty string or null")
|
|
432
|
+
secret = value.strip()
|
|
433
|
+
match = _ENV_REFERENCE.fullmatch(secret)
|
|
434
|
+
if match:
|
|
435
|
+
variable = match.group(1)
|
|
436
|
+
secret = os.environ.get(variable, "").strip()
|
|
437
|
+
if not secret:
|
|
438
|
+
raise ValueError(f"{field_name} references missing environment variable {variable}")
|
|
439
|
+
return secret
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _model_inputs(value: Any, *, field_name: str) -> tuple[InputKind, ...]:
|
|
443
|
+
if value is None:
|
|
444
|
+
return ("text",)
|
|
445
|
+
if not isinstance(value, (list, tuple)) or not value:
|
|
446
|
+
raise ValueError(f"{field_name} must be a non-empty list")
|
|
447
|
+
result = tuple(str(item).strip().lower() for item in value)
|
|
448
|
+
if any(not item for item in result):
|
|
449
|
+
raise ValueError(f"{field_name} contains an empty input kind")
|
|
450
|
+
if len(set(result)) != len(result):
|
|
451
|
+
raise ValueError(f"{field_name} contains duplicate input kinds")
|
|
452
|
+
unknown = sorted(set(result) - _INPUT_KINDS)
|
|
453
|
+
if unknown:
|
|
454
|
+
raise ValueError(f"{field_name} contains unsupported input kinds: {', '.join(unknown)}")
|
|
455
|
+
if "text" not in result:
|
|
456
|
+
raise ValueError(f"{field_name} must include text")
|
|
457
|
+
return result # type: ignore[return-value]
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _model_provider(value: Any, *, field_name: str) -> Literal["qwen-responses", "openai-compatible"]:
|
|
461
|
+
provider = str(value or "qwen-responses").strip()
|
|
462
|
+
if provider not in {"qwen-responses", "openai-compatible"}:
|
|
463
|
+
raise ValueError(f"{field_name} must be qwen-responses or openai-compatible")
|
|
464
|
+
return provider # type: ignore[return-value]
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _model_stream_usage(value: Any, *, field_name: str) -> bool:
|
|
468
|
+
if value is None:
|
|
469
|
+
return False
|
|
470
|
+
if not isinstance(value, bool):
|
|
471
|
+
raise ValueError(f"{field_name} must be true or false")
|
|
472
|
+
return value
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _model_context_window(value: Any, *, field_name: str) -> int:
|
|
476
|
+
"""Accepts a plain token count, or a `128k` / `1.5m` shorthand. 0 means unknown."""
|
|
477
|
+
if value is None or isinstance(value, bool):
|
|
478
|
+
return 0
|
|
479
|
+
if isinstance(value, int):
|
|
480
|
+
tokens = value
|
|
481
|
+
elif isinstance(value, str):
|
|
482
|
+
text = value.strip().lower().replace("_", "")
|
|
483
|
+
if not text:
|
|
484
|
+
return 0
|
|
485
|
+
multiplier = 1
|
|
486
|
+
if text.endswith("k"):
|
|
487
|
+
multiplier, text = 1_000, text[:-1]
|
|
488
|
+
elif text.endswith("m"):
|
|
489
|
+
multiplier, text = 1_000_000, text[:-1]
|
|
490
|
+
try:
|
|
491
|
+
tokens = int(float(text.strip()) * multiplier)
|
|
492
|
+
except ValueError:
|
|
493
|
+
raise ValueError(f"{field_name} must be a token count like 128000, 128k or 1m") from None
|
|
494
|
+
else:
|
|
495
|
+
raise ValueError(f"{field_name} must be a token count like 128000, 128k or 1m")
|
|
496
|
+
if tokens < 0:
|
|
497
|
+
raise ValueError(f"{field_name} must not be negative")
|
|
498
|
+
return tokens
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _sandbox_from_mapping(data: Mapping[str, Any], *, base_dir: Path) -> SandboxConfig:
|
|
502
|
+
workspace = _workspace_path(data.get("workspace"), base_dir=base_dir)
|
|
503
|
+
return SandboxConfig(
|
|
504
|
+
workspace=workspace,
|
|
505
|
+
bwrap_path=str(data.get("bwrap_path") or "bwrap"),
|
|
506
|
+
allow_unsandboxed=_as_bool(
|
|
507
|
+
data.get("allow_unsandboxed", False), field_name="sandbox.allow_unsandboxed",
|
|
508
|
+
),
|
|
509
|
+
timeout_seconds=(
|
|
510
|
+
_as_positive_int(data["timeout_seconds"], field_name="sandbox.timeout_seconds", default=0)
|
|
511
|
+
if data.get("timeout_seconds") is not None else None
|
|
512
|
+
),
|
|
513
|
+
max_output_bytes=_as_positive_int(
|
|
514
|
+
data.get("max_output_bytes"), field_name="sandbox.max_output_bytes", default=100_000,
|
|
515
|
+
),
|
|
516
|
+
env_allowlist=_as_str_tuple(data.get("env_allowlist"), field_name="sandbox.env_allowlist"),
|
|
517
|
+
env_set=_as_str_dict(data.get("env_set"), field_name="sandbox.env_set"),
|
|
518
|
+
extra_read_only_mounts=_as_mounts(
|
|
519
|
+
data.get("extra_read_only_mounts"),
|
|
520
|
+
field_name="sandbox.extra_read_only_mounts",
|
|
521
|
+
base_dir=base_dir,
|
|
522
|
+
),
|
|
523
|
+
extra_read_write_mounts=_as_mounts(
|
|
524
|
+
data.get("extra_read_write_mounts"),
|
|
525
|
+
field_name="sandbox.extra_read_write_mounts",
|
|
526
|
+
base_dir=base_dir,
|
|
527
|
+
),
|
|
528
|
+
)
|
agent/control.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Run-scoped control surface shared by the TUI, graph worker, and tool threads."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections import deque
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from itertools import count
|
|
7
|
+
import threading
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
from langgraph.runtime import RunControl
|
|
12
|
+
|
|
13
|
+
from agent.cancel import ToolCancelContext, clear_cancel_context, set_cancel_context
|
|
14
|
+
|
|
15
|
+
EventEmitter = Callable[[str, dict[str, Any]], None]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class QueuedMessage:
|
|
20
|
+
text: str
|
|
21
|
+
mode: str # "steer" | "followUp"
|
|
22
|
+
order: int
|
|
23
|
+
id: str = field(default_factory=lambda: str(uuid4()))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RunController:
|
|
27
|
+
"""Thread-safe steering / follow-up queues, cancel state, and active tool registry."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, *, on_control_event: EventEmitter | None = None) -> None:
|
|
30
|
+
self._lock = threading.RLock()
|
|
31
|
+
self._steering: deque[QueuedMessage] = deque()
|
|
32
|
+
self._follow_ups: deque[QueuedMessage] = deque()
|
|
33
|
+
self._queue_order = count()
|
|
34
|
+
self._pause_requested = False
|
|
35
|
+
self._cancel_event = threading.Event()
|
|
36
|
+
self._run_control: RunControl | None = None
|
|
37
|
+
self._active_tools: dict[str, ToolCancelContext] = {}
|
|
38
|
+
self._defer_steering = False
|
|
39
|
+
self._on_control_event = on_control_event
|
|
40
|
+
|
|
41
|
+
def begin_run(self) -> RunControl:
|
|
42
|
+
with self._lock:
|
|
43
|
+
self._cancel_event.clear()
|
|
44
|
+
self._run_control = RunControl()
|
|
45
|
+
self._active_tools.clear()
|
|
46
|
+
return self._run_control
|
|
47
|
+
|
|
48
|
+
def end_run(self) -> None:
|
|
49
|
+
with self._lock:
|
|
50
|
+
self._run_control = None
|
|
51
|
+
self._active_tools.clear()
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def run_control(self) -> RunControl | None:
|
|
55
|
+
with self._lock:
|
|
56
|
+
return self._run_control
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def cancel_requested(self) -> bool:
|
|
60
|
+
return self._cancel_event.is_set()
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def pause_requested(self) -> bool:
|
|
64
|
+
with self._lock:
|
|
65
|
+
return self._pause_requested
|
|
66
|
+
|
|
67
|
+
def request_pause(self) -> None:
|
|
68
|
+
with self._lock:
|
|
69
|
+
self._pause_requested = True
|
|
70
|
+
|
|
71
|
+
def clear_pause(self) -> None:
|
|
72
|
+
with self._lock:
|
|
73
|
+
self._pause_requested = False
|
|
74
|
+
|
|
75
|
+
def set_event_handler(self, handler: EventEmitter | None) -> None:
|
|
76
|
+
with self._lock:
|
|
77
|
+
self._on_control_event = handler
|
|
78
|
+
|
|
79
|
+
def set_defer_steering(self, value: bool) -> None:
|
|
80
|
+
with self._lock:
|
|
81
|
+
self._defer_steering = value
|
|
82
|
+
|
|
83
|
+
def steer(self, text: str) -> QueuedMessage:
|
|
84
|
+
with self._lock:
|
|
85
|
+
message = QueuedMessage(text=text, mode="steer", order=next(self._queue_order))
|
|
86
|
+
self._steering.append(message)
|
|
87
|
+
self._emit("steering_queued", {"content": text, "mode": "steer", "id": message.id})
|
|
88
|
+
return message
|
|
89
|
+
|
|
90
|
+
def follow_up(self, text: str) -> QueuedMessage:
|
|
91
|
+
with self._lock:
|
|
92
|
+
message = QueuedMessage(text=text, mode="followUp", order=next(self._queue_order))
|
|
93
|
+
self._follow_ups.append(message)
|
|
94
|
+
self._emit("steering_queued", {"content": text, "mode": "followUp", "id": message.id})
|
|
95
|
+
return message
|
|
96
|
+
|
|
97
|
+
def pop_steering(self) -> str | None:
|
|
98
|
+
"""Consume one steering message at a model-safe boundary (pi one-at-a-time)."""
|
|
99
|
+
with self._lock:
|
|
100
|
+
if self._defer_steering or self._cancel_event.is_set():
|
|
101
|
+
return None
|
|
102
|
+
if not self._steering:
|
|
103
|
+
return None
|
|
104
|
+
message = self._steering.popleft()
|
|
105
|
+
self._emit("steering_applied", {"content": message.text, "id": message.id})
|
|
106
|
+
return message.text
|
|
107
|
+
|
|
108
|
+
def pending_steering_count(self) -> int:
|
|
109
|
+
with self._lock:
|
|
110
|
+
return len(self._steering)
|
|
111
|
+
|
|
112
|
+
def pending_follow_up_count(self) -> int:
|
|
113
|
+
with self._lock:
|
|
114
|
+
return len(self._follow_ups)
|
|
115
|
+
|
|
116
|
+
def pop_follow_up(self) -> str | None:
|
|
117
|
+
with self._lock:
|
|
118
|
+
if not self._follow_ups:
|
|
119
|
+
return None
|
|
120
|
+
return self._follow_ups.popleft().text
|
|
121
|
+
|
|
122
|
+
def take_unapplied(self) -> list[QueuedMessage]:
|
|
123
|
+
"""Alt+Up: reclaim steering/follow-up that has not entered a checkpoint yet."""
|
|
124
|
+
with self._lock:
|
|
125
|
+
messages = sorted((*self._steering, *self._follow_ups), key=lambda item: item.order)
|
|
126
|
+
self._steering.clear()
|
|
127
|
+
self._follow_ups.clear()
|
|
128
|
+
return messages
|
|
129
|
+
|
|
130
|
+
def cancel(self) -> None:
|
|
131
|
+
with self._lock:
|
|
132
|
+
self._cancel_event.set()
|
|
133
|
+
self._pause_requested = False
|
|
134
|
+
control = self._run_control
|
|
135
|
+
tools = list(self._active_tools.values())
|
|
136
|
+
self._emit("run_cancelling", {})
|
|
137
|
+
if control is not None:
|
|
138
|
+
control.request_drain("cancelled")
|
|
139
|
+
for ctx in tools:
|
|
140
|
+
ctx.request_cancel()
|
|
141
|
+
|
|
142
|
+
def register_tool(self, ctx: ToolCancelContext) -> None:
|
|
143
|
+
with self._lock:
|
|
144
|
+
self._active_tools[ctx.tool_call_id or ctx.token] = ctx
|
|
145
|
+
|
|
146
|
+
def unregister_tool(self, ctx: ToolCancelContext) -> None:
|
|
147
|
+
with self._lock:
|
|
148
|
+
self._active_tools.pop(ctx.tool_call_id or ctx.token, None)
|
|
149
|
+
|
|
150
|
+
def open_tool_context(self, *, tool_name: str, tool_call_id: str) -> ToolCancelContext:
|
|
151
|
+
ctx = ToolCancelContext(
|
|
152
|
+
tool_name=tool_name,
|
|
153
|
+
tool_call_id=tool_call_id,
|
|
154
|
+
cancel_event=self._cancel_event,
|
|
155
|
+
)
|
|
156
|
+
if self.cancel_requested:
|
|
157
|
+
ctx.request_cancel()
|
|
158
|
+
self.register_tool(ctx)
|
|
159
|
+
set_cancel_context(ctx)
|
|
160
|
+
return ctx
|
|
161
|
+
|
|
162
|
+
def close_tool_context(self, ctx: ToolCancelContext) -> None:
|
|
163
|
+
self.unregister_tool(ctx)
|
|
164
|
+
clear_cancel_context()
|
|
165
|
+
|
|
166
|
+
def _emit(self, event_type: str, payload: dict[str, Any]) -> None:
|
|
167
|
+
if self._on_control_event is not None:
|
|
168
|
+
try:
|
|
169
|
+
self._on_control_event(event_type, payload)
|
|
170
|
+
except Exception: # noqa: BLE001
|
|
171
|
+
pass
|