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.
Files changed (50) hide show
  1. agent/__init__.py +42 -0
  2. agent/attachments.py +303 -0
  3. agent/bootstrap.py +44 -0
  4. agent/cancel.py +107 -0
  5. agent/cli/__init__.py +5 -0
  6. agent/cli/app.py +1768 -0
  7. agent/cli/clipboard.py +224 -0
  8. agent/cli/commands.py +94 -0
  9. agent/cli/gitinfo.py +84 -0
  10. agent/cli/input.py +65 -0
  11. agent/cli/interactions.py +187 -0
  12. agent/cli/main.py +124 -0
  13. agent/cli/previews.py +710 -0
  14. agent/cli/rendering.py +770 -0
  15. agent/cli/session_controller.py +221 -0
  16. agent/cli/state.py +326 -0
  17. agent/config.example.yaml +76 -0
  18. agent/config.py +528 -0
  19. agent/control.py +171 -0
  20. agent/factory.py +232 -0
  21. agent/file_mutation.py +5 -0
  22. agent/llm.py +339 -0
  23. agent/middleware/__init__.py +9 -0
  24. agent/middleware/attachments.py +31 -0
  25. agent/middleware/cancel_tools.py +39 -0
  26. agent/middleware/pause.py +18 -0
  27. agent/middleware/recovery.py +65 -0
  28. agent/middleware/steering.py +35 -0
  29. agent/middleware/tool_arg_hints.py +128 -0
  30. agent/middleware/workspace_filesystem.py +38 -0
  31. agent/middleware/write_operation.py +60 -0
  32. agent/network.py +30 -0
  33. agent/permission.py +80 -0
  34. agent/runner.py +1393 -0
  35. agent/sandbox.py +699 -0
  36. agent/session.py +431 -0
  37. agent/session_lock.py +223 -0
  38. agent/session_runtime.py +209 -0
  39. agent/stream.py +168 -0
  40. agent/tools/__init__.py +9 -0
  41. agent/tools/examples.py +30 -0
  42. agent/tools/execute.py +73 -0
  43. agent/tools/human_input.py +170 -0
  44. agent/tools/human_interaction.py +101 -0
  45. agent/tools/web_search.py +131 -0
  46. deep_agent_cli-0.1.0.dist-info/METADATA +408 -0
  47. deep_agent_cli-0.1.0.dist-info/RECORD +50 -0
  48. deep_agent_cli-0.1.0.dist-info/WHEEL +4 -0
  49. deep_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  50. 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