6fc5ad6be9
Align native context management with the Claude Code / Codex model: entry-capped tool results, history frozen below the threshold, one high-quality summary at the wall — instead of the old pipeline that microcompacted old messages from 60% usage and hid everything past 40 messages behind a snip marker with no summary. - context pipeline: history below the hard threshold is never rewritten (model quality and prompt-cache prefixes depend on byte-identical old messages); the 60% tool-aware microcompact and the 40-message history snip move to an emergency-only fallback used under overflow pressure when the summarizer is unavailable or circuit-broken. - durable compaction (was a stub): at usage >= context_guard.hard_threshold (now 0.90, soft_threshold removed) the old span is folded into a 9-section summary via the new HistoryCompactor.summarize_runtime_history, keeping the system head, the seed user request verbatim on every round (injected session-memory/artifact messages shift the stale base_prefix_len, so the fold start is structure-aware), and a pairing-safe recent tail. A previous summary stays foldable, so exactly one summary exists at a time and rounds chain. - token accounting anchors on the provider-reported prompt size of the latest request (max with the local estimate). - reactive_compaction.circuit_breaker_failures (previously unread) now stops repeated summarizer failures; provider overflow errors retry through the same pipeline, summary-first. - tool-result budget clip keeps head and tail instead of tail-chopping. - chat-side transcripts get the same treatment: new MemoryManager.maybe_compact_session_history wires the threshold-gated maybe_compact_session into secretary, office_ui dispatcher, and context_loader before prompt building, closing the unbounded-growth path; dead no-op compactor entries (maybe_compact_after_message, should_compact_prompt) removed. Verified by 13 new tests (history sanctity below threshold, multi-round single-summary/seed-verbatim/chain invariants, breaker, emergency fallback, provider-overflow end-to-end recovery) plus a live-provider probe: multi-round compaction with the model completing correctly from summarized context. Full suite: 1859 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1818 lines
69 KiB
Python
1818 lines
69 KiB
Python
"""Configuration management for OPC system."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import tempfile
|
|
import unicodedata
|
|
from pathlib import Path
|
|
from typing import Any, Literal
|
|
|
|
import yaml
|
|
from pydantic import AliasChoices, BaseModel, Field, field_validator
|
|
|
|
from opc.core.company_tools import COMPANY_APPROVAL_EXEMPT_TOOL_NAMES
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Paths
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _find_project_root() -> Path:
|
|
"""Walk up from cwd looking for pyproject.toml to locate the project root."""
|
|
cwd = Path.cwd()
|
|
for parent in [cwd, *cwd.parents]:
|
|
if (parent / "pyproject.toml").exists():
|
|
return parent
|
|
return cwd
|
|
|
|
|
|
def get_opc_home() -> Path:
|
|
"""Return the OPC data directory.
|
|
|
|
Resolution order:
|
|
1. $OPC_HOME environment variable (explicit override)
|
|
2. {project_root}/.opc (project-local, the default)
|
|
"""
|
|
env = os.environ.get("OPC_HOME")
|
|
if env:
|
|
return Path(env)
|
|
return _find_project_root() / ".opc"
|
|
|
|
|
|
def get_default_workplace_root() -> Path:
|
|
"""Return the default shared workplace root beside the OpenOPC repo."""
|
|
project_root = _find_project_root()
|
|
return project_root.parent / f"{project_root.name}_workplace"
|
|
|
|
|
|
def get_project_workplace(project_id: str) -> Path:
|
|
project = str(project_id or "default").strip() or "default"
|
|
return get_default_workplace_root() / project
|
|
|
|
|
|
def get_project_config_dir(project_path: Path | None = None) -> Path:
|
|
if project_path:
|
|
return project_path / ".opc"
|
|
return get_opc_home()
|
|
|
|
|
|
def _atomic_write_text(path: Path, content: str) -> None:
|
|
"""Write text via fsync and same-directory atomic replace."""
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp_path: Path | None = None
|
|
try:
|
|
with tempfile.NamedTemporaryFile(
|
|
"w",
|
|
encoding="utf-8",
|
|
dir=path.parent,
|
|
prefix=f".{path.name}.",
|
|
suffix=".tmp",
|
|
delete=False,
|
|
) as f:
|
|
tmp_path = Path(f.name)
|
|
f.write(content)
|
|
f.flush()
|
|
os.fsync(f.fileno())
|
|
os.replace(tmp_path, path)
|
|
except Exception:
|
|
if tmp_path is not None:
|
|
try:
|
|
tmp_path.unlink(missing_ok=True)
|
|
except Exception:
|
|
pass
|
|
raise
|
|
|
|
|
|
def _atomic_write_yaml(path: Path, data: Any) -> None:
|
|
_atomic_write_text(path, yaml.dump(data, default_flow_style=False))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Company organization config layout
|
|
# ---------------------------------------------------------------------------
|
|
|
|
COMPANY_INDEX_FILENAME = "company_index.yaml"
|
|
COMPANY_ORGS_DIRNAME = "company_orgs"
|
|
COMPANY_ORG_KIND = "opc_org_architecture"
|
|
COMPANY_INDEX_SCHEMA_VERSION = 1
|
|
COMPANY_ORG_SCHEMA_VERSION = 2
|
|
DEFAULT_ORGANIZATION_ID = "corporate"
|
|
_COMPANY_ORG_ID_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
|
|
_COMPANY_ORG_FILE_RE = re.compile(r"^(?:company|org)_([a-z0-9_-]{1,64})_config\.yaml$")
|
|
_ORG_STRUCTURE_KEYS = ("roles", "employees", "escalation_rules")
|
|
_ORG_RUNTIME_KEYS = (
|
|
"runtime_policies",
|
|
"talent_templates",
|
|
"teams",
|
|
"team_runtime",
|
|
"installed_packages",
|
|
"role_serial_queue_enabled",
|
|
)
|
|
_ORG_BEARING_KEYS = (
|
|
"company",
|
|
*_ORG_STRUCTURE_KEYS,
|
|
*_ORG_RUNTIME_KEYS,
|
|
)
|
|
|
|
|
|
def company_index_path(config_dir: Path) -> Path:
|
|
return Path(config_dir) / COMPANY_INDEX_FILENAME
|
|
|
|
|
|
def company_orgs_dir(config_dir: Path) -> Path:
|
|
return Path(config_dir) / COMPANY_ORGS_DIRNAME
|
|
|
|
|
|
def slugify_organization_name(name: Any, *, fallback: str = "org") -> str:
|
|
"""Return a safe file id from a user-facing organization name."""
|
|
raw = str(name or "").strip()
|
|
normalized = unicodedata.normalize("NFKD", raw)
|
|
ascii_text = normalized.encode("ascii", "ignore").decode("ascii")
|
|
lowered = ascii_text.lower()
|
|
lowered = re.sub(r"\s+", "_", lowered)
|
|
lowered = re.sub(r"[^a-z0-9_-]+", "_", lowered)
|
|
lowered = re.sub(r"_+", "_", lowered).strip("_-")
|
|
if not lowered:
|
|
lowered = str(fallback or "org").strip().lower()
|
|
lowered = re.sub(r"[^a-z0-9_-]+", "_", lowered)
|
|
lowered = re.sub(r"_+", "_", lowered).strip("_-") or "org"
|
|
return lowered[:64].strip("_-") or "org"
|
|
|
|
|
|
def validate_organization_id(value: Any) -> str:
|
|
org_id = str(value or "").strip()
|
|
if not _COMPANY_ORG_ID_RE.match(org_id):
|
|
raise ValueError(f"Invalid organization_id: {org_id!r}")
|
|
return org_id
|
|
|
|
|
|
def company_org_filename(organization_id: Any) -> str:
|
|
return f"org_{validate_organization_id(organization_id)}_config.yaml"
|
|
|
|
|
|
def organization_id_from_company_org_filename(path: Path) -> str | None:
|
|
match = _COMPANY_ORG_FILE_RE.match(Path(path).name)
|
|
return match.group(1) if match else None
|
|
|
|
|
|
def company_org_path(config_dir: Path, organization_id: Any) -> Path:
|
|
return company_orgs_dir(config_dir) / company_org_filename(organization_id)
|
|
|
|
|
|
def company_org_relative_path(organization_id: Any) -> str:
|
|
return f"{COMPANY_ORGS_DIRNAME}/{company_org_filename(organization_id)}"
|
|
|
|
|
|
def _read_yaml_file(path: Path) -> dict[str, Any]:
|
|
with open(path, encoding="utf-8") as f:
|
|
data = yaml.safe_load(f) or {}
|
|
if not isinstance(data, dict):
|
|
raise ValueError(f"Expected YAML mapping in {path}")
|
|
return data
|
|
|
|
|
|
def _write_yaml_preserving_unicode(path: Path, data: Any) -> None:
|
|
_atomic_write_text(
|
|
path,
|
|
yaml.dump(data, default_flow_style=False, sort_keys=False, allow_unicode=True),
|
|
)
|
|
|
|
|
|
def read_company_index(config_dir: Path) -> str | None:
|
|
path = company_index_path(config_dir)
|
|
if not path.exists():
|
|
return None
|
|
data = _read_yaml_file(path)
|
|
schema_version = int(data.get("schema_version", COMPANY_INDEX_SCHEMA_VERSION) or COMPANY_INDEX_SCHEMA_VERSION)
|
|
if schema_version > COMPANY_INDEX_SCHEMA_VERSION:
|
|
raise ValueError(
|
|
f"{COMPANY_INDEX_FILENAME} schema_version {schema_version} is not supported by this version of OpenOPC"
|
|
)
|
|
active_id = data.get("active_organization_id") or DEFAULT_ORGANIZATION_ID
|
|
return validate_organization_id(active_id)
|
|
|
|
|
|
def write_company_index(config_dir: Path, organization_id: Any) -> None:
|
|
org_id = validate_organization_id(organization_id)
|
|
_write_yaml_preserving_unicode(
|
|
company_index_path(config_dir),
|
|
{
|
|
"schema_version": COMPANY_INDEX_SCHEMA_VERSION,
|
|
"active_organization_id": org_id,
|
|
},
|
|
)
|
|
|
|
|
|
def write_company_org_payload(config_dir: Path, organization_id: Any, payload: dict[str, Any]) -> Path:
|
|
org_id = validate_organization_id(organization_id)
|
|
path = company_org_path(config_dir, org_id)
|
|
payload = dict(payload or {})
|
|
raw_employees = list(payload.get("employees", []) or [])
|
|
if raw_employees:
|
|
from opc.core.employee_registry import load_employee_registry, write_employee_registry
|
|
|
|
opc_home = Path(config_dir).parent
|
|
existing = load_employee_registry(opc_home, org_id)
|
|
write_employee_registry(opc_home, org_id, [*existing, *raw_employees])
|
|
payload["organization_id"] = org_id
|
|
payload.setdefault("schema_version", COMPANY_ORG_SCHEMA_VERSION)
|
|
payload.setdefault("kind", COMPANY_ORG_KIND)
|
|
payload["employees"] = []
|
|
payload["talent_templates"] = []
|
|
payload["metadata"] = {
|
|
**dict(payload.get("metadata", {}) or {}),
|
|
"organization_config_file": company_org_relative_path(org_id),
|
|
}
|
|
_write_yaml_preserving_unicode(path, payload)
|
|
return path
|
|
|
|
|
|
def list_company_org_config_paths(config_dir: Path) -> list[Path]:
|
|
orgs_dir = company_orgs_dir(config_dir)
|
|
if not orgs_dir.is_dir():
|
|
return []
|
|
return sorted(path for path in orgs_dir.glob("org_*_config.yaml") if organization_id_from_company_org_filename(path))
|
|
|
|
|
|
def allocate_organization_id(config_dir: Path, organization_name: Any, *, preferred_id: Any = "") -> str:
|
|
base = str(preferred_id or "").strip()
|
|
if base and _COMPANY_ORG_ID_RE.match(base):
|
|
candidate = base
|
|
else:
|
|
candidate = slugify_organization_name(organization_name)
|
|
existing = {
|
|
org_id
|
|
for path in list_company_org_config_paths(config_dir)
|
|
for org_id in [organization_id_from_company_org_filename(path)]
|
|
if org_id
|
|
}
|
|
if candidate not in existing:
|
|
return candidate
|
|
suffix = 2
|
|
while True:
|
|
tail = f"_{suffix}"
|
|
stem = candidate[: max(1, 64 - len(tail))].rstrip("_-") or "org"
|
|
next_id = f"{stem}{tail}"
|
|
if next_id not in existing:
|
|
return next_id
|
|
suffix += 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config Models
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class LLMConfig(BaseModel):
|
|
default_model: str = "anthropic/claude-sonnet-4-20250514"
|
|
api_base: str = ""
|
|
api_key: str = ""
|
|
api_key_env: str = ""
|
|
routing: dict[str, str] = Field(default_factory=dict)
|
|
fallback: dict[str, Any] = Field(default_factory=dict)
|
|
temperature: float = 0.3
|
|
max_tokens: int = 32768
|
|
# Total input context window (tokens) for the active model. Set this when
|
|
# the model is not mapped in litellm (e.g. proxy/self-hosted models like
|
|
# doubao/minimax/glm), so the context-usage ring and compaction thresholds
|
|
# have a real denominator. 0 = auto-detect via litellm; unmapped models
|
|
# fall back to 128000. Optional per-model overrides keyed by model name
|
|
# take precedence over the scalar value.
|
|
context_window: int = 0
|
|
context_window_overrides: dict[str, int] = Field(default_factory=dict)
|
|
|
|
|
|
ExternalAgentApprovalMode = Literal["user-settings", "auto", "full-auto"]
|
|
_EXTERNAL_AGENT_APPROVAL_MODES = {"user-settings", "auto", "full-auto"}
|
|
_LEGACY_EXTERNAL_AGENT_APPROVAL_MODE_MIGRATIONS = {
|
|
"delegate": "auto",
|
|
"bypass": "auto",
|
|
}
|
|
_LEGACY_OPENCODE_DEFAULT_MODEL = "opencode/minimax-m2.5-free"
|
|
|
|
|
|
def _migrate_agent_config_approval_modes(path: Path, data: Any) -> Any:
|
|
"""Rewrite pre-three-mode external-agent approval config once on load."""
|
|
if not isinstance(data, dict):
|
|
return data
|
|
external_agents = data.get("external_agents")
|
|
if not isinstance(external_agents, dict):
|
|
return data
|
|
|
|
changed = False
|
|
for agent_name, agent_data in external_agents.items():
|
|
if agent_name == "preferred_order" or not isinstance(agent_data, dict):
|
|
continue
|
|
raw_mode = agent_data.get("approval_mode")
|
|
mode = str(raw_mode or "").strip().lower()
|
|
if mode in _EXTERNAL_AGENT_APPROVAL_MODES:
|
|
continue
|
|
migrated = _LEGACY_EXTERNAL_AGENT_APPROVAL_MODE_MIGRATIONS.get(mode)
|
|
if not migrated:
|
|
continue
|
|
agent_data["approval_mode"] = migrated
|
|
changed = True
|
|
|
|
if changed:
|
|
_atomic_write_yaml(path, data)
|
|
return data
|
|
|
|
|
|
DEFAULT_EXTERNAL_AGENT_STARTUP_TIMEOUT_SECONDS = 300
|
|
|
|
|
|
def _migrate_agent_config_external_agent_defaults(path: Path, data: Any) -> Any:
|
|
"""Repair legacy external-agent defaults that break local CLI config."""
|
|
if not isinstance(data, dict):
|
|
return data
|
|
external_agents = data.get("external_agents")
|
|
if not isinstance(external_agents, dict):
|
|
return data
|
|
|
|
changed = False
|
|
opencode = external_agents.get("opencode")
|
|
if isinstance(opencode, dict) and str(opencode.get("model") or "").strip() == _LEGACY_OPENCODE_DEFAULT_MODEL:
|
|
opencode["model"] = ""
|
|
changed = True
|
|
|
|
if changed:
|
|
_atomic_write_yaml(path, data)
|
|
return data
|
|
|
|
|
|
class ExternalAgentConfig(BaseModel):
|
|
enabled: bool = True
|
|
command: str = ""
|
|
workspace_base: str = ""
|
|
extra_args: list[str] = Field(default_factory=list)
|
|
model: str = ""
|
|
model_flag: str = ""
|
|
session_mode: str = "auto"
|
|
session_id: str = ""
|
|
new_session_flag: str = ""
|
|
resume_session_flag: str = ""
|
|
run_mode: str = "batch"
|
|
interactive_timeout_seconds: int = 900
|
|
idle_timeout_seconds: int = 900
|
|
startup_timeout_seconds: int = DEFAULT_EXTERNAL_AGENT_STARTUP_TIMEOUT_SECONDS
|
|
status_heartbeat_seconds: int = 30
|
|
approval_mode: ExternalAgentApprovalMode = "auto"
|
|
show_thinking: bool = False
|
|
|
|
|
|
class AgentsConfig(BaseModel):
|
|
preferred_order: list[str] = Field(default_factory=lambda: ["claude_code", "cursor", "codex", "opencode"])
|
|
agents: dict[str, ExternalAgentConfig] = Field(default_factory=lambda: {
|
|
"claude_code": ExternalAgentConfig(command="claude", run_mode="interactive", approval_mode="full-auto"),
|
|
"cursor": ExternalAgentConfig(command="cursor-agent", run_mode="interactive", approval_mode="full-auto"),
|
|
"codex": ExternalAgentConfig(command="codex", run_mode="interactive"),
|
|
"opencode": ExternalAgentConfig(
|
|
command="opencode",
|
|
model_flag="--model",
|
|
run_mode="interactive",
|
|
approval_mode="full-auto",
|
|
show_thinking=True,
|
|
),
|
|
})
|
|
native_subagents: dict[str, "NativeSubagentProfileConfig"] = Field(default_factory=dict)
|
|
|
|
|
|
class RoleRuntimePolicyConfig(BaseModel):
|
|
execution_strategy: str = "auto"
|
|
allowed_downstream_roles: list[str] = Field(default_factory=list)
|
|
review_role: str | None = None
|
|
default_turn_type: str = "work"
|
|
shell_timeout_override: int | None = None
|
|
setup_env_type: str | None = None
|
|
coordination_hints: dict[str, Any] = Field(default_factory=dict)
|
|
signal_capabilities: list[str] = Field(default_factory=list)
|
|
parallelism_constraints: list[str] = Field(default_factory=list)
|
|
gate_preferences: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
class CommunicationPolicyConfig(BaseModel):
|
|
default_mode: str = "dm"
|
|
blocking_default: bool = False
|
|
meeting_required_for: list[str] = Field(default_factory=list)
|
|
allow_broadcast: bool = True
|
|
|
|
|
|
class MemoryPolicyConfig(BaseModel):
|
|
include_role_memory: bool = True
|
|
include_project_memory: bool = True
|
|
include_decision_log: bool = True
|
|
include_artifact_index: bool = True
|
|
recent_history_lines: int = 12
|
|
|
|
|
|
class HandoffPolicyConfig(BaseModel):
|
|
require_structured_handoff: bool = True
|
|
require_ack: bool = False
|
|
include_risks: bool = True
|
|
include_open_questions: bool = True
|
|
|
|
|
|
class ArtifactPolicyConfig(BaseModel):
|
|
enforce_contract: bool = False
|
|
require_artifact_index: bool = True
|
|
required_kinds: list[str] = Field(default_factory=list)
|
|
|
|
|
|
class ReviewPolicyConfig(BaseModel):
|
|
enable_work_item_gates: bool = False
|
|
strict_gate_inference: bool = False
|
|
require_reviewer_role: bool = True
|
|
allow_human_override: bool = True
|
|
|
|
|
|
def _default_gate_harness_blocker_map() -> dict[str, str]:
|
|
return {
|
|
"workspace_mismatch": "rework_same_work_item",
|
|
"missing_required_artifact": "rework_same_work_item",
|
|
"verification_missing": "rework_same_work_item",
|
|
"dependency_not_ready": "rework_same_work_item",
|
|
"cross_work_item_conflict": "replan",
|
|
"external_secret_missing": "await_user_decision",
|
|
"permission_missing": "escalate",
|
|
"environment_capability_gap": "pass_with_constraints",
|
|
"quality_gap": "rework_same_work_item",
|
|
"goal_env_mismatch": "replan",
|
|
"unresolved_user_visible_risk": "pass_with_constraints",
|
|
}
|
|
|
|
|
|
class GateHarnessPolicyConfig(BaseModel):
|
|
enabled: bool = True
|
|
decision_mode: Literal["rule_first", "agent_first", "hybrid"] = "agent_first"
|
|
default_degrade_policy: Literal["allow", "strict", "replan_first"] = "allow"
|
|
auto_infer_turn_kind: bool = True
|
|
auto_infer_gate_profile: bool = True
|
|
max_rework_rounds_per_issue: int = 2
|
|
stagnation_threshold: int = 2
|
|
allow_pass_with_constraints: bool = True
|
|
enable_delivery_constraints_propagation: bool = True
|
|
agent_model: str = ""
|
|
agent_confidence_threshold: float = 0.55
|
|
fallback_to_rules_on_parse_error: bool = True
|
|
builtin_blocker_map: dict[str, str] = Field(default_factory=_default_gate_harness_blocker_map)
|
|
turn_kind_overrides: dict[str, str] = Field(default_factory=dict)
|
|
gate_profile_overrides: dict[str, str] = Field(default_factory=dict)
|
|
|
|
|
|
class ParallelPolicyConfig(BaseModel):
|
|
auto_dispatch: bool = True
|
|
review_gate_enabled: bool = True
|
|
max_workers: int = 10
|
|
|
|
|
|
class CoordinationPolicyConfig(BaseModel):
|
|
inference_mode: Literal["llm_primary", "rules_first"] = "llm_primary"
|
|
fallback_mode: Literal["conservative", "balanced"] = "conservative"
|
|
strict_gate_turn_kinds: list[str] = Field(default_factory=lambda: ["verify", "deliver"])
|
|
mixed_gate_turn_kinds: list[str] = Field(default_factory=lambda: ["synthesize", "review", "integration"])
|
|
allow_manager_release_for_mixed_only: bool = True
|
|
allow_custom_signals: bool = True
|
|
|
|
|
|
class HeartbeatConfig(BaseModel):
|
|
enabled: bool = False
|
|
default_interval_sec: int = 300
|
|
max_concurrent_runs: int = 1
|
|
|
|
|
|
class BrowserConfig(BaseModel):
|
|
mode: Literal["embedded", "chrome", "auto"] = "embedded"
|
|
headless: bool = True
|
|
chrome_channel: str = "chrome"
|
|
chrome_executable_path: str = ""
|
|
user_data_dir: str = ""
|
|
args: list[str] = Field(default_factory=list)
|
|
|
|
|
|
class TaskModeConfig(BaseModel):
|
|
max_sub_agents: int = 8
|
|
sub_agent_timeout_sec: int = 86400
|
|
allow_parallel_dispatch: bool = True
|
|
|
|
|
|
ProjectModeConfig = TaskModeConfig
|
|
|
|
|
|
class PromptPrefixStabilityConfig(BaseModel):
|
|
enabled: bool = True
|
|
separate_dynamic_context: bool = True
|
|
emit_cache_fingerprint_events: bool = True
|
|
|
|
|
|
class PromptHarnessConfig(BaseModel):
|
|
enabled: bool = True
|
|
split_static_dynamic: bool = True
|
|
emit_delta_messages: bool = True
|
|
cache_static_prefix_only: bool = True
|
|
artifact_messages_enabled: bool = True
|
|
section_dedup_enabled: bool = True
|
|
reinject_after_compaction: bool = True
|
|
|
|
|
|
class ReactiveCompactionConfig(BaseModel):
|
|
enabled: bool = True
|
|
max_overflow_retries: int = 2
|
|
circuit_breaker_failures: int = 2
|
|
|
|
|
|
class ContextUsageReportingConfig(BaseModel):
|
|
enabled: bool = True
|
|
emit_runtime_events: bool = True
|
|
|
|
|
|
class VerificationPolicyConfig(BaseModel):
|
|
enabled: bool = True
|
|
min_todos_for_verification: int = 3
|
|
require_on_code_edits: bool = True
|
|
require_on_risky_tools: bool = True
|
|
verifier_profile: str = "verify"
|
|
skip_metadata_key: str = "skip_verification"
|
|
|
|
|
|
class BackgroundSessionMemoryConfig(BaseModel):
|
|
enabled: bool = True
|
|
update_interval_messages: int = 4
|
|
max_input_chars: int = 6_000
|
|
|
|
|
|
class PrefetchConfig(BaseModel):
|
|
enabled: bool = True
|
|
session_memory: bool = True
|
|
focused_memory: bool = True
|
|
skills_summary: bool = True
|
|
project_memory_candidates: bool = True
|
|
max_chars: int = 4_000
|
|
|
|
|
|
class ToolAwareMicrocompactConfig(BaseModel):
|
|
enabled: bool = True
|
|
preserve_recent_messages: int = 8
|
|
tool_result_char_budget: int = 4_000
|
|
assistant_char_budget: int = 3_000
|
|
preserve_failure_outputs: bool = True
|
|
|
|
|
|
class TaskLedgerConfig(BaseModel):
|
|
enabled: bool = True
|
|
max_items: int = 24
|
|
persist_to_runtime_session: bool = True
|
|
persist_to_task_metadata: bool = True
|
|
emit_runtime_events: bool = True
|
|
|
|
|
|
class StreamingToolStartConfig(BaseModel):
|
|
enabled: bool = True
|
|
safe_read_only_only: bool = True
|
|
require_allow_prediction: bool = True
|
|
|
|
|
|
class StreamRenderingConfig(BaseModel):
|
|
enabled: bool = True
|
|
enter_depth: int = 8
|
|
enter_age_ms: int = 120
|
|
exit_depth: int = 2
|
|
exit_age_ms: int = 40
|
|
exit_hold_ms: int = 250
|
|
reenter_hold_ms: int = 250
|
|
tick_ms: int = 50
|
|
|
|
|
|
class ContextGuardConfig(BaseModel):
|
|
enabled: bool = True
|
|
# Below this usage ratio the history is never rewritten; at or above it
|
|
# the runtime folds old messages into one LLM summary (durable compaction).
|
|
hard_threshold: float = 0.90
|
|
warn_remaining_pct: int = 15
|
|
tool_output_char_budget: int = 12_000
|
|
shell_stdout_char_budget: int = 12_000
|
|
shell_stderr_char_budget: int = 6_000
|
|
|
|
|
|
class VerificationContractConfig(BaseModel):
|
|
enabled: bool = True
|
|
append_status_to_final: bool = True
|
|
require_explicit_status: bool = True
|
|
|
|
|
|
class WorktreeVenvConfig(BaseModel):
|
|
enabled: bool = False
|
|
provider: Literal["auto", "uv", "venv"] = "auto"
|
|
venv_dir: str = ".opc-venv"
|
|
editable_project: bool = True
|
|
requirements_files: list[str] = Field(default_factory=list)
|
|
auto_detect_requirements: bool = True
|
|
system_site_packages: bool = False
|
|
fail_if_prepare_fails: bool = False
|
|
|
|
|
|
class SandboxPlatformConfig(BaseModel):
|
|
mode: Literal["inherit", "off", "workspace-write", "elevated"] = "inherit"
|
|
wrapper: Literal["auto", "none", "bwrap", "sandbox-exec"] = "auto"
|
|
|
|
|
|
class SandboxExecutionConfig(BaseModel):
|
|
enabled: bool = False
|
|
default_mode: Literal["off", "workspace-write", "elevated"] = "off"
|
|
fail_if_unavailable: bool = False
|
|
allow_direct_fallback: bool = True
|
|
allow_network: bool = True
|
|
windows: SandboxPlatformConfig = Field(
|
|
default_factory=lambda: SandboxPlatformConfig(mode="elevated", wrapper="none")
|
|
)
|
|
linux: SandboxPlatformConfig = Field(
|
|
default_factory=lambda: SandboxPlatformConfig(mode="workspace-write", wrapper="auto")
|
|
)
|
|
macos: SandboxPlatformConfig = Field(
|
|
default_factory=lambda: SandboxPlatformConfig(mode="workspace-write", wrapper="auto")
|
|
)
|
|
|
|
|
|
class ExecutionEnvironmentConfig(BaseModel):
|
|
worktree_venv: WorktreeVenvConfig = Field(default_factory=WorktreeVenvConfig)
|
|
sandbox: SandboxExecutionConfig = Field(default_factory=SandboxExecutionConfig)
|
|
|
|
|
|
class ArtifactCompactionConfig(BaseModel):
|
|
enabled: bool = True
|
|
session_memory_fast_path: bool = True
|
|
reinject_tool_surface_delta: bool = True
|
|
reinject_skills_delta: bool = True
|
|
reinject_active_subagents: bool = True
|
|
reinject_verification_state: bool = True
|
|
reinject_permission_state: bool = True
|
|
prompt_too_long_retry: bool = True
|
|
max_prompt_too_long_retries: int = 3
|
|
artifact_char_budget: int = 12_000
|
|
|
|
|
|
class NativeRuntimeConfig(BaseModel):
|
|
enabled: bool = True
|
|
stream_llm: bool = True
|
|
emit_runtime_events: bool = True
|
|
event_protocol_version: str = "v2"
|
|
enable_tool_hooks: bool = True
|
|
converge_on_parallel_failure: bool = True
|
|
max_parallel_read_tools: int = 6
|
|
tool_result_budget_chars: int = 20_000
|
|
microcompact_chars: int = 8_000
|
|
history_snip_trigger_messages: int = 40
|
|
subagent_max_depth: int = 3
|
|
auto_extract_durable_memory: bool = False
|
|
durable_memory_extract_min_messages: int = 4
|
|
durable_memory_max_input_chars: int = 12_000
|
|
prompt_prefix_stability: PromptPrefixStabilityConfig = Field(default_factory=PromptPrefixStabilityConfig)
|
|
prompt_harness: PromptHarnessConfig = Field(default_factory=PromptHarnessConfig)
|
|
reactive_compaction: ReactiveCompactionConfig = Field(default_factory=ReactiveCompactionConfig)
|
|
context_usage_reporting: ContextUsageReportingConfig = Field(default_factory=ContextUsageReportingConfig)
|
|
verification_policy: VerificationPolicyConfig = Field(default_factory=VerificationPolicyConfig)
|
|
background_session_memory: BackgroundSessionMemoryConfig = Field(default_factory=BackgroundSessionMemoryConfig)
|
|
prefetch: PrefetchConfig = Field(default_factory=PrefetchConfig)
|
|
tool_aware_microcompact: ToolAwareMicrocompactConfig = Field(default_factory=ToolAwareMicrocompactConfig)
|
|
artifact_compaction: ArtifactCompactionConfig = Field(default_factory=ArtifactCompactionConfig)
|
|
task_ledger: TaskLedgerConfig = Field(default_factory=TaskLedgerConfig)
|
|
streaming_tool_start: StreamingToolStartConfig = Field(default_factory=StreamingToolStartConfig)
|
|
stream_rendering: StreamRenderingConfig = Field(default_factory=StreamRenderingConfig)
|
|
context_guard: ContextGuardConfig = Field(default_factory=ContextGuardConfig)
|
|
verification_contract: VerificationContractConfig = Field(default_factory=VerificationContractConfig)
|
|
execution_environment: ExecutionEnvironmentConfig = Field(default_factory=ExecutionEnvironmentConfig)
|
|
|
|
|
|
class NativeSubagentProfileConfig(BaseModel):
|
|
enabled: bool = True
|
|
model: str = ""
|
|
max_iterations: int = 24
|
|
default_isolation: Literal["shared", "worktree"] = "shared"
|
|
background: bool = False
|
|
allowed_tools: list[str] = Field(default_factory=list)
|
|
|
|
|
|
class DenialMemoryConfig(BaseModel):
|
|
enabled: bool = True
|
|
repeat_threshold: int = 2
|
|
|
|
|
|
class GuardianConfig(BaseModel):
|
|
enabled: bool = True
|
|
auto_allow_read_only: bool = True
|
|
cache_upgrade_context: bool = True
|
|
auto_retry_sandbox: bool = True
|
|
max_sandbox_retries: int = 1
|
|
|
|
|
|
class PermissionsV2Config(BaseModel):
|
|
"""Runtime knobs for the unified permission predictor (ApprovalEngine.predict).
|
|
|
|
Shell safe-command policy lives in ``autonomy.safe_command_prefixes`` plus
|
|
the built-in flag-audited classifier (``shell_safety.py``); legacy
|
|
duplicate fields (safe_shell_prefixes, classifier_*, sandbox_policy, ...)
|
|
from the removed runtime-side resolver are ignored on load.
|
|
"""
|
|
|
|
enabled: bool = True
|
|
fail_closed: bool = True
|
|
denial_memory: DenialMemoryConfig = Field(default_factory=DenialMemoryConfig)
|
|
allow_tools: list[str] = Field(default_factory=list)
|
|
deny_tools: list[str] = Field(default_factory=list)
|
|
allowed_paths: list[str] = Field(default_factory=list)
|
|
denied_paths: list[str] = Field(default_factory=list)
|
|
guardian: GuardianConfig = Field(default_factory=GuardianConfig)
|
|
dangerous_shell_patterns: list[str] = Field(default_factory=lambda: [
|
|
r"\brm\s+-rf\b",
|
|
r"\bdrop\s+table\b",
|
|
r"\btruncate\b",
|
|
r"\bterraform\s+destroy\b",
|
|
r"\bgit\s+push\s+--force\b",
|
|
])
|
|
|
|
|
|
def _default_native_subagents() -> dict[str, NativeSubagentProfileConfig]:
|
|
return {
|
|
"general": NativeSubagentProfileConfig(),
|
|
"explore": NativeSubagentProfileConfig(default_isolation="shared"),
|
|
"plan": NativeSubagentProfileConfig(default_isolation="shared"),
|
|
"implement": NativeSubagentProfileConfig(default_isolation="worktree"),
|
|
"verify": NativeSubagentProfileConfig(default_isolation="worktree", background=True),
|
|
}
|
|
|
|
|
|
class RuntimePolicyConfig(BaseModel):
|
|
communication: CommunicationPolicyConfig = Field(default_factory=CommunicationPolicyConfig)
|
|
memory: MemoryPolicyConfig = Field(default_factory=MemoryPolicyConfig)
|
|
handoff: HandoffPolicyConfig = Field(default_factory=HandoffPolicyConfig)
|
|
artifact: ArtifactPolicyConfig = Field(default_factory=ArtifactPolicyConfig)
|
|
review: ReviewPolicyConfig = Field(default_factory=ReviewPolicyConfig)
|
|
gate_harness: GateHarnessPolicyConfig = Field(default_factory=GateHarnessPolicyConfig)
|
|
# Only used within company-mode work-item runtime.
|
|
parallel: ParallelPolicyConfig = Field(default_factory=ParallelPolicyConfig)
|
|
coordination: CoordinationPolicyConfig = Field(default_factory=CoordinationPolicyConfig)
|
|
|
|
|
|
class CoordinatorPolicyConfig(BaseModel):
|
|
synthesis_mode: str = "on_work_item_complete" # "on_work_item_complete" | "on_inbox_threshold" | "periodic"
|
|
inbox_threshold: int = 3
|
|
auto_route: bool = True
|
|
can_spawn_tasks: bool = True
|
|
# Fraction of children that must be DONE before synthesize/deliver can start early
|
|
partial_completion_threshold: float = 0.5
|
|
# Auto-downgrade hard deps to soft after this many seconds of stall
|
|
auto_downgrade_stall_seconds: int = 300
|
|
# Force-advance after this many consecutive no-progress loop iterations
|
|
max_stall_iterations: int = 3
|
|
|
|
|
|
class RoleConfig(BaseModel):
|
|
id: str
|
|
name: str
|
|
responsibility: str
|
|
reports_to: str = "owner"
|
|
icon: str | None = None
|
|
can_spawn: list[str] = Field(default_factory=list)
|
|
tools: list[str] = Field(default_factory=list)
|
|
preferred_external_agent: str | None = None
|
|
prompt_refs: list[str] = Field(default_factory=list)
|
|
skill_refs: list[str] = Field(default_factory=list)
|
|
handoff_template_ref: str | None = None
|
|
memory_policy_ref: str | None = None
|
|
artifact_contract_ref: str | None = None
|
|
runtime_policy: RoleRuntimePolicyConfig = Field(default_factory=RoleRuntimePolicyConfig)
|
|
capabilities: list[str] = Field(default_factory=list)
|
|
role_type: str = "worker" # "worker" | "coordinator" | "reviewer"
|
|
coordinator_policy: CoordinatorPolicyConfig | None = None
|
|
|
|
|
|
class TalentTemplateConfig(BaseModel):
|
|
id: str
|
|
name: str
|
|
description: str = ""
|
|
category: str = ""
|
|
domains: list[str] = Field(default_factory=list)
|
|
tags: list[str] = Field(default_factory=list)
|
|
prompt_ref: str = ""
|
|
preferred_external_agent: str | None = None
|
|
source_repo: str = ""
|
|
source_path: str = ""
|
|
source_revision: str = ""
|
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
class EmployeeConfig(BaseModel):
|
|
employee_id: str
|
|
template_id: str = ""
|
|
name: str
|
|
role_id: str
|
|
description: str = ""
|
|
category: str = ""
|
|
domains: list[str] = Field(default_factory=list)
|
|
tags: list[str] = Field(default_factory=list)
|
|
prompt_refs: list[str] = Field(default_factory=list)
|
|
skill_refs: list[str] = Field(default_factory=list)
|
|
preferred_external_agent: str | None = None
|
|
seniority: str = "junior"
|
|
status: str = "active"
|
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
class EscalationRule(BaseModel):
|
|
condition: str
|
|
action: str
|
|
|
|
|
|
class SeatConfig(BaseModel):
|
|
seat_id: str
|
|
name: str = ""
|
|
role_id: str = ""
|
|
seat_kind: str = "workspace"
|
|
manager_seat_id: str | None = None
|
|
manager_role_id: str | None = None
|
|
shared_executor: bool = False
|
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
class TeamConfig(BaseModel):
|
|
team_id: str
|
|
name: str = ""
|
|
description: str = ""
|
|
seat_ids: list[str] = Field(default_factory=list)
|
|
seats: list[SeatConfig] = Field(default_factory=list)
|
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
class TeamRuntimeConfig(BaseModel):
|
|
default_team_id: str = ""
|
|
shared_role_session_scope: str = "team"
|
|
allow_shared_role_sessions: bool = True
|
|
seat_refresh_interval_seconds: int = 30
|
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
class OrgConfig(BaseModel):
|
|
organization_id: str = DEFAULT_ORGANIZATION_ID
|
|
organization_name: str = "My One-Person Company"
|
|
organization_config_file: str = ""
|
|
company_name: str = "My One-Person Company"
|
|
topology: str = "Corporate Structure"
|
|
default_mode: str = "task" # "task" or "company"
|
|
company_profile: str = "corporate"
|
|
execution_model: str = "actor_runtime"
|
|
final_decider_role_id: str | None = None
|
|
company_profiles: list[str] = Field(default_factory=lambda: ["corporate", "custom"])
|
|
runtime_policies: dict[str, RuntimePolicyConfig] = Field(default_factory=dict)
|
|
roles: list[RoleConfig] = Field(default_factory=list)
|
|
talent_templates: list[TalentTemplateConfig] = Field(default_factory=list)
|
|
employees: list[EmployeeConfig] = Field(default_factory=list)
|
|
teams: list[TeamConfig] = Field(default_factory=list)
|
|
team_runtime: TeamRuntimeConfig = Field(default_factory=TeamRuntimeConfig)
|
|
escalation_rules: list[EscalationRule] = Field(default_factory=list)
|
|
installed_packages: list[Any] = Field(default_factory=list)
|
|
# Fix 5 PR3 feature flag. When ON, runnable work items for a role
|
|
# whose session is already focused on something else are appended to
|
|
# ``role_runtime_session.pending_work_item_ids`` instead of being
|
|
# claimed immediately. Default OFF for a safe rollout — behaviour
|
|
# without the flag matches the pre-PR3 concurrent-claim model.
|
|
role_serial_queue_enabled: bool = True
|
|
|
|
@field_validator("default_mode", mode="before")
|
|
@classmethod
|
|
def _normalize_default_mode(cls, value: Any) -> Any:
|
|
if isinstance(value, str) and value.strip().lower() == "project":
|
|
return "task"
|
|
return value
|
|
|
|
|
|
class MCPServerConfig(BaseModel):
|
|
name: str = ""
|
|
type: str = "local" # "local" or "remote"
|
|
command: list[str] = Field(default_factory=list) # local only
|
|
url: str = "" # remote only
|
|
headers: dict[str, str] = Field(default_factory=dict) # remote only
|
|
enabled: bool = True
|
|
env: dict[str, str] = Field(default_factory=dict)
|
|
tools_filter: list[str] = Field(default_factory=list)
|
|
startup_timeout: float = 30.0
|
|
|
|
|
|
class SystemConfig(BaseModel):
|
|
opc_home: str = ""
|
|
default_channel: str = "cli"
|
|
log_level: str = "INFO"
|
|
max_agent_iterations: int = 50
|
|
context_compression_threshold: float = 0.85
|
|
escalation_timeout_seconds: int = 3600
|
|
auto_approve_below_cost: float = 10.0
|
|
require_confirmation: list[str] = Field(
|
|
default_factory=lambda: ["deploy to production", "send external emails", "modify database schema"]
|
|
)
|
|
mcp_servers: list[MCPServerConfig] = Field(default_factory=list)
|
|
heartbeat: HeartbeatConfig = Field(default_factory=HeartbeatConfig)
|
|
browser: BrowserConfig = Field(default_factory=BrowserConfig)
|
|
native_runtime: NativeRuntimeConfig = Field(default_factory=NativeRuntimeConfig)
|
|
task_mode: TaskModeConfig = Field(
|
|
default_factory=TaskModeConfig,
|
|
validation_alias=AliasChoices("task_mode", "project_mode"),
|
|
serialization_alias="task_mode",
|
|
)
|
|
|
|
|
|
class AutonomyConfig(BaseModel):
|
|
enabled: bool = True
|
|
mode: str = "bounded"
|
|
approval_model: str = ""
|
|
approval_confidence_threshold: float = 0.7
|
|
learned_policy_threshold: float = 0.8
|
|
max_auto_approve_risk: str = "medium"
|
|
allow_native_tool_auto_approval: bool = True
|
|
allow_external_agent_auto_approval: bool = False
|
|
learn_from_feedback: bool = True
|
|
save_external_sessions: bool = True
|
|
tool_first_use_approval: bool = True
|
|
tool_approval_exemptions: list[str] = Field(default_factory=lambda: [
|
|
*COMPANY_APPROVAL_EXEMPT_TOOL_NAMES,
|
|
"request_user_input",
|
|
"todo_read",
|
|
"todo_write",
|
|
])
|
|
command_review_window: int = 20
|
|
sensitive_keywords: list[str] = Field(default_factory=lambda: [
|
|
"token", "password", "secret", "api key", "credential", "private key",
|
|
"payment", "invoice", "wire transfer", "database schema", "drop table",
|
|
"truncate", "delete from", "rm -rf", "terraform destroy", "deploy to production",
|
|
"send email", "external email", "publish", "post to", "webhook",
|
|
])
|
|
safe_command_prefixes: list[str] = Field(default_factory=lambda: [
|
|
"ls", "pwd", "echo", "rg", "find", "git status", "git diff", "python -V",
|
|
"python3 -V", "node -v", "npm -v", "curl", "wget", "yt-dlp", "aria2c", "ffmpeg",
|
|
# Read-only commands agents chain constantly; each segment of a compound
|
|
# command must match one of these for the whole command to stay LOW risk.
|
|
"cd", "cat", "head", "tail", "grep", "wc", "sort", "uniq", "cut", "tr",
|
|
"stat", "file", "which", "date", "du", "df", "tree", "basename", "dirname",
|
|
"realpath", "readlink", "uname", "nproc", "whoami", "hostname", "git log",
|
|
"git show", "git rev-parse",
|
|
])
|
|
permissions_v2: PermissionsV2Config = Field(default_factory=PermissionsV2Config)
|
|
|
|
|
|
class SkillHubConfig(BaseModel):
|
|
enabled: bool = False
|
|
api_base: str = "https://www.skillhub.club/api/v1"
|
|
api_key: str = ""
|
|
api_key_env: str = "SKILLHUB_API_KEY"
|
|
search_limit: int = 5
|
|
method: str = "hybrid"
|
|
cache_remote_skills: bool = True
|
|
promote_after_successes: int = 2
|
|
|
|
|
|
class CapabilityConfig(BaseModel):
|
|
enable_recovery: bool = True
|
|
local_first: bool = True
|
|
attach_remote_skill_summaries: bool = True
|
|
promote_remote_skills: bool = True
|
|
remote_skill_source: str = "skillhub"
|
|
max_remote_skill_results: int = 5
|
|
tool_failure_threshold: int = 2
|
|
skillhub: SkillHubConfig = Field(default_factory=SkillHubConfig)
|
|
|
|
|
|
class BaseChannelConfig(BaseModel):
|
|
enabled: bool = False
|
|
allow_from: list[str] = Field(default_factory=list)
|
|
|
|
|
|
class TelegramChannelConfig(BaseChannelConfig):
|
|
token: str = ""
|
|
proxy: str | None = None
|
|
reply_to_message: bool = False
|
|
|
|
|
|
class WhatsAppChannelConfig(BaseChannelConfig):
|
|
bridge_url: str = "ws://localhost:3001"
|
|
bridge_token: str = ""
|
|
|
|
|
|
class DiscordChannelConfig(BaseChannelConfig):
|
|
token: str = ""
|
|
gateway_url: str = "wss://gateway.discord.gg/?v=10&encoding=json"
|
|
intents: int = 37377
|
|
group_policy: str = "mention"
|
|
|
|
|
|
class FeishuChannelConfig(BaseChannelConfig):
|
|
app_id: str = ""
|
|
app_secret: str = ""
|
|
encrypt_key: str = ""
|
|
verification_token: str = ""
|
|
react_emoji: str = "THUMBSUP"
|
|
|
|
|
|
class MochatMentionConfig(BaseModel):
|
|
require_in_groups: bool = False
|
|
|
|
|
|
class MochatGroupRule(BaseModel):
|
|
require_mention: bool = False
|
|
|
|
|
|
class MochatChannelConfig(BaseChannelConfig):
|
|
base_url: str = "https://mochat.io"
|
|
socket_url: str = ""
|
|
socket_path: str = "/socket.io"
|
|
socket_disable_msgpack: bool = False
|
|
socket_reconnect_delay_ms: int = 1000
|
|
socket_max_reconnect_delay_ms: int = 10000
|
|
socket_connect_timeout_ms: int = 10000
|
|
refresh_interval_ms: int = 30000
|
|
watch_timeout_ms: int = 25000
|
|
watch_limit: int = 100
|
|
retry_delay_ms: int = 500
|
|
max_retry_attempts: int = 0
|
|
claw_token: str = ""
|
|
agent_user_id: str = ""
|
|
sessions: list[str] = Field(default_factory=list)
|
|
panels: list[str] = Field(default_factory=list)
|
|
mention: MochatMentionConfig = Field(default_factory=MochatMentionConfig)
|
|
groups: dict[str, MochatGroupRule] = Field(default_factory=dict)
|
|
reply_delay_mode: str = "non-mention"
|
|
reply_delay_ms: int = 120000
|
|
|
|
|
|
class DingTalkChannelConfig(BaseChannelConfig):
|
|
client_id: str = ""
|
|
client_secret: str = ""
|
|
|
|
|
|
class EmailChannelConfig(BaseChannelConfig):
|
|
consent_granted: bool = False
|
|
imap_host: str = ""
|
|
imap_port: int = 993
|
|
imap_username: str = ""
|
|
imap_password: str = ""
|
|
imap_mailbox: str = "INBOX"
|
|
imap_use_ssl: bool = True
|
|
smtp_host: str = ""
|
|
smtp_port: int = 587
|
|
smtp_username: str = ""
|
|
smtp_password: str = ""
|
|
smtp_use_tls: bool = True
|
|
smtp_use_ssl: bool = False
|
|
from_address: str = ""
|
|
auto_reply_enabled: bool = True
|
|
poll_interval_seconds: int = 30
|
|
mark_seen: bool = True
|
|
max_body_chars: int = 12000
|
|
subject_prefix: str = "Re: "
|
|
|
|
|
|
class SlackDMChannelConfig(BaseModel):
|
|
enabled: bool = True
|
|
policy: str = "open"
|
|
allow_from: list[str] = Field(default_factory=list)
|
|
|
|
|
|
class SlackChannelConfig(BaseChannelConfig):
|
|
mode: str = "socket"
|
|
webhook_path: str = "/slack/events"
|
|
bot_token: str = ""
|
|
app_token: str = ""
|
|
user_token_read_only: bool = True
|
|
reply_in_thread: bool = True
|
|
react_emoji: str = "eyes"
|
|
group_policy: str = "mention"
|
|
group_allow_from: list[str] = Field(default_factory=list)
|
|
dm: SlackDMChannelConfig = Field(default_factory=SlackDMChannelConfig)
|
|
|
|
|
|
class QQChannelConfig(BaseChannelConfig):
|
|
app_id: str = ""
|
|
secret: str = ""
|
|
|
|
|
|
class MatrixChannelConfig(BaseChannelConfig):
|
|
homeserver: str = "https://matrix.org"
|
|
access_token: str = ""
|
|
user_id: str = ""
|
|
device_id: str = ""
|
|
e2ee_enabled: bool = True
|
|
sync_stop_grace_seconds: int = 2
|
|
max_media_bytes: int = 20 * 1024 * 1024
|
|
group_policy: str = "open"
|
|
group_allow_from: list[str] = Field(default_factory=list)
|
|
allow_room_mentions: bool = False
|
|
|
|
|
|
class ChannelsConfig(BaseModel):
|
|
send_progress: bool = True
|
|
send_tool_hints: bool = False
|
|
telegram: TelegramChannelConfig = Field(default_factory=TelegramChannelConfig)
|
|
whatsapp: WhatsAppChannelConfig = Field(default_factory=WhatsAppChannelConfig)
|
|
discord: DiscordChannelConfig = Field(default_factory=DiscordChannelConfig)
|
|
feishu: FeishuChannelConfig = Field(default_factory=FeishuChannelConfig)
|
|
mochat: MochatChannelConfig = Field(default_factory=MochatChannelConfig)
|
|
dingtalk: DingTalkChannelConfig = Field(default_factory=DingTalkChannelConfig)
|
|
email: EmailChannelConfig = Field(default_factory=EmailChannelConfig)
|
|
slack: SlackChannelConfig = Field(default_factory=SlackChannelConfig)
|
|
qq: QQChannelConfig = Field(default_factory=QQChannelConfig)
|
|
matrix: MatrixChannelConfig = Field(default_factory=MatrixChannelConfig)
|
|
|
|
|
|
def _dump_config_item(item: Any) -> Any:
|
|
if hasattr(item, "model_dump"):
|
|
return item.model_dump()
|
|
if isinstance(item, dict):
|
|
return dict(item)
|
|
return item
|
|
|
|
|
|
def _dump_config_list(items: Any) -> list[Any]:
|
|
return [_dump_config_item(item) for item in list(items or [])]
|
|
|
|
|
|
def _runtime_policy_payload_from_org(org: OrgConfig) -> dict[str, Any]:
|
|
return {str(key): _dump_config_item(value) for key, value in dict(org.runtime_policies or {}).items()}
|
|
|
|
|
|
def _materialized_roles_for_org_payload(config: Any, profile: str) -> list[RoleConfig]:
|
|
org = config.org
|
|
if profile == "corporate":
|
|
from opc.layer2_organization.company_runtime_profiles import get_builtin_roles
|
|
|
|
return get_builtin_roles("corporate", configured_roles=list(org.roles or []))
|
|
return list(org.roles or [])
|
|
|
|
|
|
def _infer_final_decider_role_id(roles: list[RoleConfig], profile: str, configured: Any) -> str | None:
|
|
configured_id = str(configured or "").strip()
|
|
if configured_id:
|
|
return configured_id
|
|
role_ids = {str(role.id or "").strip() for role in roles if str(role.id or "").strip()}
|
|
if profile == "corporate" and "ceo" in role_ids:
|
|
return "ceo"
|
|
top_level_role_ids = [
|
|
str(role.id or "").strip()
|
|
for role in roles
|
|
if str(role.id or "").strip()
|
|
and (
|
|
str(role.reports_to or "owner").strip() == "owner"
|
|
or str(role.reports_to or "").strip() not in role_ids
|
|
)
|
|
]
|
|
top_level_role_ids = sorted(dict.fromkeys(top_level_role_ids))
|
|
if len(top_level_role_ids) == 1:
|
|
return top_level_role_ids[0]
|
|
return None
|
|
|
|
|
|
def _effective_runtime_policy_payload(config: Any, profile: str) -> dict[str, Any]:
|
|
try:
|
|
from opc.layer2_organization.org_engine import OrgEngine
|
|
|
|
policy_config = config
|
|
current_profile = str(getattr(config.org, "company_profile", "") or "").strip()
|
|
if current_profile != profile and hasattr(config, "model_copy"):
|
|
policy_config = config.model_copy(deep=True)
|
|
policy_config.org.company_profile = profile
|
|
effective = OrgEngine(policy_config).get_runtime_policy(profile)
|
|
if hasattr(effective, "model_dump"):
|
|
effective = effective.model_dump()
|
|
return dict(effective or {})
|
|
except Exception:
|
|
try:
|
|
from opc.layer2_organization.company_runtime_profiles import get_builtin_runtime_policies
|
|
|
|
policy = get_builtin_runtime_policies().get(profile)
|
|
return policy.model_dump() if policy else {}
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def _runtime_policy_payload_for_org(config: Any, profile: str) -> dict[str, Any]:
|
|
policies = _runtime_policy_payload_from_org(config.org)
|
|
policy_key = "corporate" if profile == "corporate" else "custom" if profile == "custom" else profile
|
|
if policy_key:
|
|
effective = _effective_runtime_policy_payload(config, policy_key)
|
|
if effective:
|
|
policies[policy_key] = effective
|
|
return policies
|
|
|
|
|
|
def _should_keep_org_employee_payload(employee: Any) -> bool:
|
|
if isinstance(employee, EmployeeConfig):
|
|
metadata = dict(employee.metadata or {})
|
|
elif isinstance(employee, dict):
|
|
metadata = dict(employee.get("metadata") or {})
|
|
else:
|
|
return True
|
|
if metadata.get("persist_to_org") or metadata.get("user_saved_default"):
|
|
return True
|
|
if metadata.get("employee_origin") in {"system_default", "recruitment_fallback"}:
|
|
return False
|
|
if metadata.get("auto_created_for_role") and (
|
|
metadata.get("is_default_employee") or metadata.get("is_fallback_employee")
|
|
):
|
|
return False
|
|
return True
|
|
|
|
|
|
def _should_persist_org_employee(employee: EmployeeConfig) -> bool:
|
|
return _should_keep_org_employee_payload(employee)
|
|
|
|
|
|
def _organization_identity_from_org(org: OrgConfig) -> tuple[str, str]:
|
|
display_name = str(org.organization_name or org.company_name or "My One-Person Company").strip()
|
|
profile = str(org.company_profile or "").strip().lower()
|
|
raw_id = str(org.organization_id or "").strip()
|
|
if raw_id and _COMPANY_ORG_ID_RE.match(raw_id):
|
|
org_id = raw_id
|
|
elif profile == "custom":
|
|
org_id = slugify_organization_name(display_name)
|
|
else:
|
|
org_id = DEFAULT_ORGANIZATION_ID
|
|
if profile == "custom" and org_id == DEFAULT_ORGANIZATION_ID and display_name:
|
|
org_id = slugify_organization_name(display_name)
|
|
return validate_organization_id(org_id), display_name
|
|
|
|
|
|
def build_company_org_payload_from_config(
|
|
config: Any,
|
|
*,
|
|
organization_id: str | None = None,
|
|
organization_name: str | None = None,
|
|
force_profile: str | None = None,
|
|
) -> dict[str, Any]:
|
|
org = config.org
|
|
resolved_id, resolved_name = _organization_identity_from_org(org)
|
|
org_id = validate_organization_id(organization_id or resolved_id)
|
|
org_name = str(organization_name or resolved_name or org.company_name or org_id).strip()
|
|
profile = force_profile if force_profile is not None else org.company_profile
|
|
profile = str(profile or "").strip() or ("custom" if org_id != DEFAULT_ORGANIZATION_ID else "corporate")
|
|
roles = _materialized_roles_for_org_payload(config, profile)
|
|
final_decider_role_id = _infer_final_decider_role_id(roles, profile, org.final_decider_role_id)
|
|
return {
|
|
"schema_version": COMPANY_ORG_SCHEMA_VERSION,
|
|
"kind": COMPANY_ORG_KIND,
|
|
"organization_id": org_id,
|
|
"organization_name": org_name,
|
|
"company": {
|
|
"name": org.company_name or org_name,
|
|
"topology": org.topology,
|
|
"company_profile": profile,
|
|
"execution_model": org.execution_model,
|
|
"final_decider_role_id": final_decider_role_id,
|
|
"company_profiles": list(org.company_profiles),
|
|
},
|
|
"roles": [role.model_dump() for role in roles],
|
|
# Employees are stored in .opc/company_state/<org>/employees/*.yaml.
|
|
# Keep org config focused on organization structure; load treats this
|
|
# field as legacy input only.
|
|
"employees": [],
|
|
"escalation_rules": [rule.model_dump() for rule in org.escalation_rules],
|
|
"runtime_policies": _runtime_policy_payload_for_org(config, profile),
|
|
# Talent templates live in .opc/prompts/talent/*.md and builtin presets.
|
|
# Keep org configs focused on organization structure, not talent indexes.
|
|
"talent_templates": [],
|
|
"teams": [team.model_dump() for team in org.teams],
|
|
"team_runtime": org.team_runtime.model_dump(),
|
|
"installed_packages": _dump_config_list(org.installed_packages),
|
|
"role_serial_queue_enabled": bool(org.role_serial_queue_enabled),
|
|
"metadata": {
|
|
"source": "opc_config",
|
|
"organization_config_file": company_org_relative_path(org_id),
|
|
},
|
|
}
|
|
|
|
|
|
def _default_company_org_payload() -> dict[str, Any]:
|
|
cfg = OPCConfig()
|
|
return build_company_org_payload_from_config(
|
|
cfg,
|
|
organization_id=DEFAULT_ORGANIZATION_ID,
|
|
organization_name=cfg.org.company_name,
|
|
force_profile="corporate",
|
|
)
|
|
|
|
|
|
def _validate_company_org_payload(path: Path, data: dict[str, Any]) -> dict[str, Any]:
|
|
schema_version = int(data.get("schema_version", 1) or 1)
|
|
if schema_version > COMPANY_ORG_SCHEMA_VERSION:
|
|
raise ValueError(
|
|
f"{path.name} schema_version {schema_version} is not supported by this version of OpenOPC"
|
|
)
|
|
kind = str(data.get("kind", "") or "").strip()
|
|
if schema_version >= COMPANY_ORG_SCHEMA_VERSION and kind and kind != COMPANY_ORG_KIND:
|
|
raise ValueError(f"Unsupported organization config kind in {path.name}: {kind}")
|
|
data["schema_version"] = schema_version
|
|
return data
|
|
|
|
|
|
def _company_org_payload_to_org_mapping(data: dict[str, Any], *, source_path: Path | None = None) -> dict[str, Any]:
|
|
company = data.get("company") if isinstance(data.get("company"), dict) else {}
|
|
raw_org_id = data.get("organization_id") or DEFAULT_ORGANIZATION_ID
|
|
org_id = validate_organization_id(raw_org_id)
|
|
org_name = str(data.get("organization_name") or company.get("name") or org_id).strip()
|
|
company_name = str(company.get("name") or org_name or "My OPC").strip()
|
|
org: dict[str, Any] = {
|
|
"organization_id": org_id,
|
|
"organization_name": org_name or company_name,
|
|
"organization_config_file": str(source_path or company_org_relative_path(org_id)),
|
|
"company_name": company_name,
|
|
"topology": company.get("topology", ""),
|
|
"company_profile": company.get("company_profile") or ("custom" if org_id != DEFAULT_ORGANIZATION_ID else "corporate"),
|
|
"execution_model": company.get("execution_model") or "actor_runtime",
|
|
"final_decider_role_id": company.get("final_decider_role_id"),
|
|
"company_profiles": company.get("company_profiles") or ["corporate", "custom"],
|
|
}
|
|
for key in _ORG_STRUCTURE_KEYS:
|
|
if key in data:
|
|
value = data.get(key) or []
|
|
if key == "employees" and isinstance(value, list):
|
|
value = [item for item in value if _should_keep_org_employee_payload(item)]
|
|
org[key] = value
|
|
for key in _ORG_RUNTIME_KEYS:
|
|
if key == "role_serial_queue_enabled":
|
|
if key in data:
|
|
org[key] = bool(data.get(key))
|
|
continue
|
|
if key == "talent_templates":
|
|
continue
|
|
if key in data:
|
|
org[key] = data.get(key) or ({} if key in {"runtime_policies", "team_runtime"} else [])
|
|
return org
|
|
|
|
|
|
def _legacy_corporate_config_candidates(config_dir: Path) -> list[Path]:
|
|
candidates: list[Path] = []
|
|
project_root_candidate = _find_project_root() / "config" / "company_corporate_config.yaml"
|
|
local_candidate = Path(config_dir) / "company_corporate_config.yaml"
|
|
candidate_order = [local_candidate]
|
|
try:
|
|
if Path(config_dir).resolve() == (get_opc_home() / "config").resolve():
|
|
candidate_order.insert(0, project_root_candidate)
|
|
except Exception:
|
|
pass
|
|
for path in candidate_order:
|
|
if path not in candidates:
|
|
candidates.append(path)
|
|
return candidates
|
|
|
|
|
|
def _read_legacy_corporate_config(config_dir: Path) -> dict[str, Any]:
|
|
for path in _legacy_corporate_config_candidates(config_dir):
|
|
if not path.exists():
|
|
continue
|
|
data = _read_yaml_file(path)
|
|
schema_ver = int(data.get("schema_version", 1) or 1)
|
|
if schema_ver > 1:
|
|
raise ValueError(
|
|
f"company_corporate_config.yaml schema_version {schema_ver} is not supported by this version of OpenOPC"
|
|
)
|
|
return data
|
|
return {}
|
|
|
|
|
|
def _read_legacy_org_runtime_config(config_dir: Path) -> dict[str, Any]:
|
|
path = Path(config_dir) / "org_config.yaml"
|
|
if not path.exists():
|
|
return {}
|
|
return _read_yaml_file(path)
|
|
|
|
|
|
def _legacy_company_org_payload(config_dir: Path) -> dict[str, Any]:
|
|
corporate_data = _read_legacy_corporate_config(config_dir)
|
|
org_runtime_data = _read_legacy_org_runtime_config(config_dir)
|
|
if not corporate_data and not org_runtime_data:
|
|
return _default_company_org_payload()
|
|
|
|
company = dict(corporate_data.get("company", {}) or org_runtime_data.get("company", {}) or {})
|
|
company_name = str(company.get("name") or "My One-Person Company").strip()
|
|
profile = str(company.get("company_profile") or "").strip() or "corporate"
|
|
org_id = (
|
|
slugify_organization_name(company_name)
|
|
if profile == "custom"
|
|
else DEFAULT_ORGANIZATION_ID
|
|
)
|
|
payload: dict[str, Any] = {
|
|
"schema_version": COMPANY_ORG_SCHEMA_VERSION,
|
|
"kind": COMPANY_ORG_KIND,
|
|
"organization_id": org_id,
|
|
"organization_name": company_name,
|
|
"company": {
|
|
"name": company_name,
|
|
"topology": company.get("topology", ""),
|
|
"company_profile": profile,
|
|
"execution_model": company.get("execution_model") or "actor_runtime",
|
|
"final_decider_role_id": company.get("final_decider_role_id"),
|
|
"company_profiles": company.get("company_profiles") or ["corporate", "custom"],
|
|
},
|
|
"roles": corporate_data.get("roles") or org_runtime_data.get("roles") or [],
|
|
"employees": corporate_data.get("employees") or org_runtime_data.get("employees") or [],
|
|
"escalation_rules": corporate_data.get("escalation_rules") or org_runtime_data.get("escalation_rules") or [],
|
|
"runtime_policies": org_runtime_data.get("runtime_policies") or {},
|
|
"talent_templates": [],
|
|
"teams": org_runtime_data.get("teams") or [],
|
|
"team_runtime": org_runtime_data.get("team_runtime") or {},
|
|
"installed_packages": org_runtime_data.get("installed_packages") or [],
|
|
"role_serial_queue_enabled": bool(org_runtime_data.get("role_serial_queue_enabled", True)),
|
|
"metadata": {"source": "legacy_migration"},
|
|
}
|
|
return payload
|
|
|
|
|
|
def _normalize_corporate_company_org_payload(data: dict[str, Any]) -> dict[str, Any]:
|
|
"""Return a corporate-scoped company org payload.
|
|
|
|
Company mode is the built-in corporate runtime. User-defined orgs are stored
|
|
under ``company_orgs/`` and should not become the process-wide company architecture
|
|
just because an old ``company_index.yaml`` still points at them.
|
|
"""
|
|
payload = dict(data or {})
|
|
company = dict(payload.get("company", {}) or {})
|
|
org_name = str(
|
|
payload.get("organization_name")
|
|
or company.get("name")
|
|
or "My One-Person Company"
|
|
).strip()
|
|
payload["organization_id"] = DEFAULT_ORGANIZATION_ID
|
|
payload["organization_name"] = org_name
|
|
company["name"] = str(company.get("name") or org_name).strip() or org_name
|
|
company["company_profile"] = "corporate"
|
|
company.setdefault("topology", "")
|
|
company.setdefault("execution_model", "actor_runtime")
|
|
company.setdefault("final_decider_role_id", None)
|
|
company.setdefault("company_profiles", ["corporate", "custom"])
|
|
payload["company"] = company
|
|
payload.setdefault("roles", [])
|
|
payload.setdefault("employees", [])
|
|
payload.setdefault("escalation_rules", [])
|
|
payload.setdefault("runtime_policies", {})
|
|
payload["talent_templates"] = []
|
|
payload.setdefault("teams", [])
|
|
payload.setdefault("team_runtime", {})
|
|
payload.setdefault("installed_packages", [])
|
|
payload.setdefault("role_serial_queue_enabled", True)
|
|
payload.setdefault("metadata", {})
|
|
return payload
|
|
|
|
|
|
def _company_payload_profile(data: dict[str, Any]) -> str:
|
|
company = data.get("company") if isinstance(data.get("company"), dict) else {}
|
|
return str(company.get("company_profile") or "").strip().lower()
|
|
|
|
|
|
def _company_payload_with_org_storage_path(data: dict[str, Any], org_id: str) -> dict[str, Any]:
|
|
payload = dict(data or {})
|
|
payload["organization_id"] = validate_organization_id(org_id)
|
|
payload.setdefault("schema_version", COMPANY_ORG_SCHEMA_VERSION)
|
|
payload.setdefault("kind", COMPANY_ORG_KIND)
|
|
payload["metadata"] = {
|
|
**dict(payload.get("metadata", {}) or {}),
|
|
"organization_config_file": company_org_relative_path(org_id),
|
|
}
|
|
return payload
|
|
|
|
|
|
def _company_org_payload_needs_externalization(data: dict[str, Any]) -> bool:
|
|
return bool(data.get("employees") or data.get("talent_templates"))
|
|
|
|
|
|
def _repoint_org_index_if_active_corporate(config_dir: Path, archive_id: str) -> None:
|
|
path = Path(config_dir) / "org_index.yaml"
|
|
if not path.exists():
|
|
return
|
|
try:
|
|
data = _read_yaml_file(path)
|
|
except Exception:
|
|
return
|
|
active_id = str(data.get("active_organization_id") or "").strip()
|
|
if active_id != DEFAULT_ORGANIZATION_ID:
|
|
return
|
|
_write_yaml_preserving_unicode(
|
|
path,
|
|
{
|
|
"schema_version": int(data.get("schema_version", 1) or 1),
|
|
"active_organization_id": archive_id,
|
|
},
|
|
)
|
|
|
|
|
|
def _archive_conflicting_corporate_org_config(config_dir: Path, target_path: Path) -> str | None:
|
|
"""Preserve a custom ``org_corporate`` file before writing corporate config.
|
|
|
|
Older org-mode builds allowed a user-defined custom architecture to use the
|
|
id ``corporate``. With unified ``company_orgs/`` storage, that filename is now
|
|
reserved for the built-in corporate company profile. If we see the old custom
|
|
file, keep it under a fresh id instead of overwriting it.
|
|
"""
|
|
|
|
if not target_path.exists():
|
|
return None
|
|
try:
|
|
existing = _validate_company_org_payload(target_path, _read_yaml_file(target_path))
|
|
except Exception:
|
|
return None
|
|
if _company_payload_profile(existing) != "custom":
|
|
return None
|
|
|
|
company = dict(existing.get("company", {}) or {})
|
|
org_name = str(
|
|
existing.get("organization_name")
|
|
or company.get("name")
|
|
or "Corporate Custom"
|
|
).strip()
|
|
archive_id = allocate_organization_id(
|
|
config_dir,
|
|
org_name,
|
|
preferred_id=f"{DEFAULT_ORGANIZATION_ID}_custom",
|
|
)
|
|
archived = _company_payload_with_org_storage_path(existing, archive_id)
|
|
archived["organization_name"] = org_name
|
|
company["company_profile"] = "custom"
|
|
archived["company"] = company
|
|
archived.setdefault("metadata", {})["source"] = "archived_custom_corporate_conflict"
|
|
write_company_org_payload(config_dir, archive_id, archived)
|
|
_repoint_org_index_if_active_corporate(config_dir, archive_id)
|
|
return archive_id
|
|
|
|
|
|
def _should_persist_company_migration(config_dir: Path) -> bool:
|
|
try:
|
|
return Path(config_dir).resolve() == (get_opc_home() / "config").resolve()
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _migrate_legacy_saved_orgs(config_dir: Path) -> None:
|
|
legacy_dir = _find_project_root() / "config" / "orgs"
|
|
if not legacy_dir.is_dir():
|
|
return
|
|
for legacy_path in sorted(legacy_dir.glob("*.yaml")):
|
|
try:
|
|
data = _read_yaml_file(legacy_path)
|
|
company = dict(data.get("company", {}) or {})
|
|
org_name = str(
|
|
data.get("organization_name")
|
|
or company.get("name")
|
|
or legacy_path.stem
|
|
).strip()
|
|
raw_org_id = str(data.get("organization_id") or "").strip()
|
|
org_id = raw_org_id if _COMPANY_ORG_ID_RE.match(raw_org_id) else slugify_organization_name(legacy_path.stem)
|
|
target_path = company_org_path(config_dir, org_id)
|
|
if target_path.exists():
|
|
continue
|
|
company.setdefault("name", org_name)
|
|
company.setdefault("company_profile", "custom")
|
|
data.update({
|
|
"schema_version": COMPANY_ORG_SCHEMA_VERSION,
|
|
"kind": COMPANY_ORG_KIND,
|
|
"organization_id": org_id,
|
|
"organization_name": org_name,
|
|
"company": company,
|
|
})
|
|
data.setdefault("roles", [])
|
|
data.setdefault("employees", [])
|
|
data.setdefault("escalation_rules", [])
|
|
data.setdefault("runtime_policies", {})
|
|
data["talent_templates"] = []
|
|
data.setdefault("teams", [])
|
|
data.setdefault("team_runtime", {})
|
|
data.setdefault("installed_packages", [])
|
|
data.setdefault("role_serial_queue_enabled", True)
|
|
data.setdefault("metadata", {})["source"] = "legacy_saved_org_migration"
|
|
write_company_org_payload(config_dir, org_id, data)
|
|
except Exception:
|
|
continue
|
|
|
|
|
|
def load_company_org_payload(
|
|
config_dir: Path,
|
|
organization_id: Any = DEFAULT_ORGANIZATION_ID,
|
|
) -> tuple[dict[str, Any], Path]:
|
|
"""Load a specific company-mode organization payload.
|
|
|
|
``OPCConfig.load`` intentionally calls this with ``corporate`` instead of
|
|
consulting ``company_index.yaml``. The active org index is owned by org mode.
|
|
"""
|
|
config_dir = Path(config_dir)
|
|
org_id = validate_organization_id(organization_id or DEFAULT_ORGANIZATION_ID)
|
|
if _should_persist_company_migration(config_dir):
|
|
_migrate_legacy_saved_orgs(config_dir)
|
|
path = company_org_path(config_dir, org_id)
|
|
if path.exists():
|
|
payload = _validate_company_org_payload(path, _read_yaml_file(path))
|
|
if org_id == DEFAULT_ORGANIZATION_ID:
|
|
if _company_payload_profile(payload) == "custom":
|
|
if _should_persist_company_migration(config_dir):
|
|
_archive_conflicting_corporate_org_config(config_dir, path)
|
|
payload = _normalize_corporate_company_org_payload(_legacy_company_org_payload(config_dir))
|
|
write_company_org_payload(config_dir, org_id, _company_payload_with_org_storage_path(payload, org_id))
|
|
return payload, path
|
|
payload = _legacy_company_org_payload(config_dir)
|
|
payload = _normalize_corporate_company_org_payload(payload)
|
|
if _should_persist_company_migration(config_dir) and _company_org_payload_needs_externalization(payload):
|
|
write_company_org_payload(config_dir, org_id, payload)
|
|
payload = _validate_company_org_payload(path, _read_yaml_file(path))
|
|
return payload, path
|
|
if org_id != DEFAULT_ORGANIZATION_ID:
|
|
raise FileNotFoundError(f"Company organization config does not exist: {path}")
|
|
|
|
payload = _normalize_corporate_company_org_payload(_legacy_company_org_payload(config_dir))
|
|
if _should_persist_company_migration(config_dir):
|
|
write_company_org_payload(config_dir, org_id, payload)
|
|
payload = _read_yaml_file(path)
|
|
return _validate_company_org_payload(path, payload), path
|
|
|
|
|
|
def load_active_company_org_payload(config_dir: Path) -> tuple[dict[str, Any], Path]:
|
|
config_dir = Path(config_dir)
|
|
if _should_persist_company_migration(config_dir):
|
|
_migrate_legacy_saved_orgs(config_dir)
|
|
active_id = read_company_index(config_dir)
|
|
if active_id:
|
|
path = company_org_path(config_dir, active_id)
|
|
if not path.exists():
|
|
raise FileNotFoundError(f"Active organization config does not exist: {path}")
|
|
payload = _validate_company_org_payload(path, _read_yaml_file(path))
|
|
if _should_persist_company_migration(config_dir) and _company_org_payload_needs_externalization(payload):
|
|
write_company_org_payload(config_dir, active_id, payload)
|
|
payload = _validate_company_org_payload(path, _read_yaml_file(path))
|
|
return payload, path
|
|
|
|
payload = _legacy_company_org_payload(config_dir)
|
|
org_id = validate_organization_id(payload.get("organization_id") or DEFAULT_ORGANIZATION_ID)
|
|
path = company_org_path(config_dir, org_id)
|
|
if _should_persist_company_migration(config_dir):
|
|
write_company_org_payload(config_dir, org_id, payload)
|
|
payload = _read_yaml_file(path)
|
|
return _validate_company_org_payload(path, payload), path
|
|
|
|
|
|
class OPCConfig(BaseModel):
|
|
system: SystemConfig = Field(default_factory=SystemConfig)
|
|
llm: LLMConfig = Field(default_factory=LLMConfig)
|
|
agents: AgentsConfig = Field(default_factory=AgentsConfig)
|
|
org: OrgConfig = Field(default_factory=OrgConfig)
|
|
channels: ChannelsConfig = Field(default_factory=ChannelsConfig)
|
|
autonomy: AutonomyConfig = Field(default_factory=AutonomyConfig)
|
|
capabilities: CapabilityConfig = Field(default_factory=CapabilityConfig)
|
|
|
|
@classmethod
|
|
def load(cls, config_dir: Path | None = None) -> "OPCConfig":
|
|
if config_dir is None:
|
|
config_dir = get_opc_home() / "config"
|
|
|
|
merged: dict[str, Any] = {}
|
|
for name in ("system_config", "llm_config", "agent_config", "channel_config"):
|
|
path = config_dir / f"{name}.yaml"
|
|
if path.exists():
|
|
with open(path, encoding="utf-8") as f:
|
|
data = yaml.safe_load(f) or {}
|
|
if name == "agent_config":
|
|
data = _migrate_agent_config_approval_modes(path, data)
|
|
data = _migrate_agent_config_external_agent_defaults(path, data)
|
|
merged.update(data)
|
|
|
|
org_payload, org_payload_path = load_company_org_payload(config_dir, DEFAULT_ORGANIZATION_ID)
|
|
|
|
mapping = {}
|
|
if "system" in merged:
|
|
mapping["system"] = merged["system"]
|
|
if "llm" in merged:
|
|
mapping["llm"] = merged["llm"]
|
|
if "external_agents" in merged:
|
|
agents_data = merged["external_agents"]
|
|
mapping["agents"] = {
|
|
"preferred_order": agents_data.get("preferred_order", []),
|
|
"agents": {
|
|
k: v for k, v in agents_data.items() if k != "preferred_order"
|
|
},
|
|
}
|
|
if "native_subagents" in merged:
|
|
agent_mapping = mapping.get("agents", {"preferred_order": [], "agents": {}})
|
|
if isinstance(agent_mapping, dict):
|
|
agent_mapping["native_subagents"] = merged["native_subagents"]
|
|
mapping["agents"] = agent_mapping
|
|
mapping["org"] = _company_org_payload_to_org_mapping(org_payload, source_path=org_payload_path)
|
|
if "channels" in merged:
|
|
mapping["channels"] = merged["channels"]
|
|
if "autonomy" in merged:
|
|
mapping["autonomy"] = merged["autonomy"]
|
|
if "capabilities" in merged:
|
|
mapping["capabilities"] = merged["capabilities"]
|
|
if "mcp_servers" in merged:
|
|
system_data = mapping.get("system", {})
|
|
if isinstance(system_data, dict):
|
|
system_data["mcp_servers"] = merged["mcp_servers"]
|
|
mapping["system"] = system_data
|
|
agent_mapping = mapping.get("agents")
|
|
if isinstance(agent_mapping, dict) and "native_subagents" not in agent_mapping:
|
|
agent_mapping["native_subagents"] = {
|
|
key: value.model_dump()
|
|
for key, value in _default_native_subagents().items()
|
|
}
|
|
mapping["agents"] = agent_mapping
|
|
|
|
config = cls.model_validate(mapping)
|
|
config.org.talent_templates = []
|
|
try:
|
|
from opc.core.employee_registry import load_company_employees
|
|
|
|
organization_id, _ = _organization_identity_from_org(config.org)
|
|
config.org.employees = load_company_employees(
|
|
Path(config_dir).parent,
|
|
organization_id,
|
|
list(config.org.employees),
|
|
)
|
|
except Exception:
|
|
pass
|
|
return config
|
|
|
|
def save(self, config_dir: Path | None = None) -> None:
|
|
if config_dir is None:
|
|
config_dir = get_opc_home() / "config"
|
|
config_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
system_path = config_dir / "system_config.yaml"
|
|
_atomic_write_yaml(system_path, {
|
|
"system": self.system.model_dump(),
|
|
"autonomy": self.autonomy.model_dump(),
|
|
"capabilities": self.capabilities.model_dump(),
|
|
})
|
|
|
|
llm_path = config_dir / "llm_config.yaml"
|
|
_atomic_write_yaml(llm_path, {"llm": self.llm.model_dump()})
|
|
|
|
organization_id, organization_name = _organization_identity_from_org(self.org)
|
|
from opc.core.employee_registry import write_employee_registry
|
|
|
|
self.org.employees, _ = write_employee_registry(
|
|
Path(config_dir).parent,
|
|
organization_id,
|
|
list(self.org.employees),
|
|
)
|
|
profile = str(self.org.company_profile or "").strip().lower()
|
|
if profile == "custom":
|
|
from opc.core.org_config import (
|
|
build_org_config_payload_from_config,
|
|
org_config_path,
|
|
write_org_config_payload,
|
|
write_org_index,
|
|
)
|
|
|
|
payload = build_org_config_payload_from_config(
|
|
self,
|
|
organization_id=organization_id,
|
|
organization_name=organization_name,
|
|
)
|
|
custom_path = org_config_path(config_dir, organization_id)
|
|
if not self.org.roles and custom_path.exists():
|
|
try:
|
|
with open(custom_path, encoding="utf-8") as f:
|
|
existing = yaml.safe_load(f) or {}
|
|
existing_roles = existing.get("roles") or []
|
|
if existing_roles:
|
|
import logging, os as _os
|
|
logging.getLogger(__name__).error(
|
|
"OPCConfig.save(): REFUSED to wipe %d existing roles with "
|
|
"empty list in custom mode. pid=%d, path=%s. "
|
|
"If this save was intentional, use reset_architecture.",
|
|
len(existing_roles), _os.getpid(), custom_path,
|
|
)
|
|
return
|
|
except Exception:
|
|
pass
|
|
write_org_config_payload(config_dir, organization_id, payload)
|
|
write_org_index(config_dir, organization_id)
|
|
else:
|
|
corporate_data = build_company_org_payload_from_config(
|
|
self,
|
|
organization_id=organization_id,
|
|
organization_name=organization_name,
|
|
)
|
|
if organization_id == DEFAULT_ORGANIZATION_ID:
|
|
corporate_path = company_org_path(config_dir, organization_id)
|
|
_archive_conflicting_corporate_org_config(config_dir, corporate_path)
|
|
write_company_org_payload(config_dir, organization_id, corporate_data)
|
|
|
|
agent_path = config_dir / "agent_config.yaml"
|
|
agent_data = {
|
|
"external_agents": {
|
|
"preferred_order": self.agents.preferred_order,
|
|
**{k: v.model_dump() for k, v in self.agents.agents.items()},
|
|
},
|
|
"native_subagents": {
|
|
key: value.model_dump()
|
|
for key, value in (self.agents.native_subagents or _default_native_subagents()).items()
|
|
},
|
|
}
|
|
_atomic_write_yaml(agent_path, agent_data)
|
|
|
|
channel_path = config_dir / "channel_config.yaml"
|
|
_atomic_write_yaml(channel_path, {"channels": self.channels.model_dump()})
|
|
|
|
|
|
AgentsConfig.model_rebuild()
|