e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
178 lines
6.6 KiB
Python
178 lines
6.6 KiB
Python
"""Subagent settings — the consult budget and per-backend permission knobs.
|
|
|
|
Stored as ``data/user/settings/subagent.json`` (same convention as the other
|
|
runtime settings files). Everything has a safe default so the feature works the
|
|
moment a CLI is detected, with no configuration step required.
|
|
|
|
* ``consult_budget`` — the user-facing "max rounds": the maximum number of times
|
|
DeepTutor may put a question to the subagent in one turn. Enforced
|
|
authoritatively inside the consult tool; the chat loop's own round budget is
|
|
only a safety ceiling.
|
|
* per-backend ``permission_mode`` / ``sandbox`` / ``approval`` — how the local
|
|
agent runs non-interactively. The defaults are chosen to never stall waiting
|
|
for an approval prompt (a headless agent that blocks on approval would hang
|
|
the turn forever, since we wait unconditionally). A cautious user can dial
|
|
these back; ``extra_args`` is an escape hatch for anything not modelled here.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
import json
|
|
import logging
|
|
from typing import Any
|
|
|
|
from deeptutor.services.path_service import get_path_service
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_SETTINGS_FILE = "subagent.json"
|
|
|
|
DEFAULT_CONSULT_BUDGET = 5
|
|
CONSULT_BUDGET_MIN = 1
|
|
CONSULT_BUDGET_MAX = 12
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class BackendConfig:
|
|
enabled: bool = True
|
|
# Model + reasoning effort the agent runs with. Empty = the CLI's own
|
|
# default. Set from the /settings page; the option lists are synced live
|
|
# from the CLI (models/efforts change over time). CC: --model / --effort;
|
|
# Codex: -m / -c model_reasoning_effort.
|
|
model: str = ""
|
|
effort: str = ""
|
|
# Instruction injected so the agent knows it's being consulted
|
|
# programmatically (be concise, self-contained, don't ask follow-ups).
|
|
# CC: --append-system-prompt. Empty = none.
|
|
system_prompt: str = ""
|
|
# Claude Code: --permission-mode value. "bypassPermissions" never stalls and
|
|
# lets the agent act autonomously on the user's own machine (the explicit
|
|
# trust model of "connect my local CLI"). Codex ignores this field.
|
|
permission_mode: str = "bypassPermissions"
|
|
# Codex: --sandbox / approval_policy. "workspace-write" + "never" runs
|
|
# autonomously within the working dir without blocking on approvals.
|
|
sandbox: str = "workspace-write"
|
|
approval: str = "never"
|
|
# Codex: allow model-run shell commands network access (workspace-write is
|
|
# offline by default). The built-in web_search tool is unaffected.
|
|
network_access: bool = False
|
|
# Codex: --ephemeral — don't persist the session under ~/.codex/sessions.
|
|
ephemeral: bool = False
|
|
# Forward image attachments from the chat turn to the agent (CC image input
|
|
# / Codex -i). Off by default — the user opts in per backend.
|
|
forward_images: bool = False
|
|
extra_args: list[str] = field(default_factory=list)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class SubagentSettings:
|
|
consult_budget: int = DEFAULT_CONSULT_BUDGET
|
|
backends: dict[str, BackendConfig] = field(default_factory=dict)
|
|
|
|
def backend(self, kind: str) -> BackendConfig:
|
|
return self.backends.get(kind, BackendConfig())
|
|
|
|
def to_dict(self) -> dict[str, Any]:
|
|
return {
|
|
"consult_budget": self.consult_budget,
|
|
"backends": {kind: _backend_to_dict(cfg) for kind, cfg in self.backends.items()},
|
|
}
|
|
|
|
|
|
def _backend_to_dict(cfg: BackendConfig) -> dict[str, Any]:
|
|
return {
|
|
"enabled": cfg.enabled,
|
|
"model": cfg.model,
|
|
"effort": cfg.effort,
|
|
"system_prompt": cfg.system_prompt,
|
|
"permission_mode": cfg.permission_mode,
|
|
"sandbox": cfg.sandbox,
|
|
"approval": cfg.approval,
|
|
"network_access": cfg.network_access,
|
|
"ephemeral": cfg.ephemeral,
|
|
"forward_images": cfg.forward_images,
|
|
"extra_args": list(cfg.extra_args),
|
|
}
|
|
|
|
|
|
def _coerce_budget(value: Any) -> int:
|
|
try:
|
|
n = int(value)
|
|
except (TypeError, ValueError):
|
|
return DEFAULT_CONSULT_BUDGET
|
|
return max(CONSULT_BUDGET_MIN, min(CONSULT_BUDGET_MAX, n))
|
|
|
|
|
|
def _coerce_backend(raw: Any) -> BackendConfig:
|
|
base = BackendConfig()
|
|
if not isinstance(raw, dict):
|
|
return base
|
|
extra = raw.get("extra_args")
|
|
return BackendConfig(
|
|
enabled=bool(raw.get("enabled", base.enabled)),
|
|
model=str(raw.get("model") or "").strip(),
|
|
effort=str(raw.get("effort") or "").strip(),
|
|
system_prompt=str(raw.get("system_prompt") or ""),
|
|
permission_mode=str(raw.get("permission_mode") or base.permission_mode),
|
|
sandbox=str(raw.get("sandbox") or base.sandbox),
|
|
approval=str(raw.get("approval") or base.approval),
|
|
network_access=bool(raw.get("network_access", base.network_access)),
|
|
ephemeral=bool(raw.get("ephemeral", base.ephemeral)),
|
|
forward_images=bool(raw.get("forward_images", base.forward_images)),
|
|
extra_args=[str(a) for a in extra] if isinstance(extra, list) else [],
|
|
)
|
|
|
|
|
|
def _settings_path():
|
|
return get_path_service().get_settings_file(_SETTINGS_FILE)
|
|
|
|
|
|
def settings_from_dict(raw: Any) -> SubagentSettings:
|
|
"""Coerce an arbitrary payload (file contents or an API body) into settings."""
|
|
backends_raw = raw.get("backends") if isinstance(raw, dict) else None
|
|
backends: dict[str, BackendConfig] = {}
|
|
if isinstance(backends_raw, dict):
|
|
for kind, cfg in backends_raw.items():
|
|
backends[str(kind)] = _coerce_backend(cfg)
|
|
return SubagentSettings(
|
|
consult_budget=_coerce_budget(raw.get("consult_budget") if isinstance(raw, dict) else None),
|
|
backends=backends,
|
|
)
|
|
|
|
|
|
def load_subagent_settings() -> SubagentSettings:
|
|
"""Read ``subagent.json``, falling back to defaults on any problem."""
|
|
path = _settings_path()
|
|
if not path.exists():
|
|
return SubagentSettings()
|
|
try:
|
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
logger.warning("failed to read %s; using defaults", path, exc_info=True)
|
|
return SubagentSettings()
|
|
return settings_from_dict(raw)
|
|
|
|
|
|
def save_subagent_settings(settings: SubagentSettings) -> None:
|
|
path = _settings_path()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(settings.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8")
|
|
|
|
|
|
def get_consult_budget() -> int:
|
|
return load_subagent_settings().consult_budget
|
|
|
|
|
|
__all__ = [
|
|
"BackendConfig",
|
|
"SubagentSettings",
|
|
"DEFAULT_CONSULT_BUDGET",
|
|
"CONSULT_BUDGET_MIN",
|
|
"CONSULT_BUDGET_MAX",
|
|
"settings_from_dict",
|
|
"load_subagent_settings",
|
|
"save_subagent_settings",
|
|
"get_consult_budget",
|
|
]
|