"""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 soft_threshold: float = 0.60 hard_threshold: float = 0.80 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 ClassifierThresholdsConfig(BaseModel): allow: float = 0.2 ask: float = 0.5 deny: float = 0.8 class DenialMemoryConfig(BaseModel): enabled: bool = True repeat_threshold: int = 2 class SandboxPolicyConfig(BaseModel): treat_network_as_risky: bool = True treat_external_paths_as_high_risk: bool = True explicit_prefix_allowlist: list[str] = Field(default_factory=list) 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): enabled: bool = True fail_closed: bool = True classifier_enabled: bool = True shell_ast_validation: bool = True llm_classifier_model: str = "" classifier_thresholds: ClassifierThresholdsConfig = Field(default_factory=ClassifierThresholdsConfig) denial_memory: DenialMemoryConfig = Field(default_factory=DenialMemoryConfig) sandbox_policy: SandboxPolicyConfig = Field(default_factory=SandboxPolicyConfig) candidate_extractors: list[str] = Field(default_factory=lambda: [ "path", "file_path", "directory", "working_directory", "target_output_dir", "workspace_path", "command", "cmd", "url", ]) default_scope: str = "once" allow_scopes: list[str] = Field(default_factory=lambda: ["once", "session", "project", "global"]) 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) safe_shell_prefixes: list[str] = Field(default_factory=lambda: [ "ls", "pwd", "echo", "rg", "git status", "git diff", "curl", "wget", "yt-dlp", "aria2c", "ffmpeg", "python -V", "python3 -V", "node -v", "npm -v", ]) ask_shell_prefixes: list[str] = Field(default_factory=lambda: [ "git commit", "git push", "npm install", "pip install", "pnpm install", "cargo test", "pytest", ]) 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", ]) 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//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()