"""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//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()