Files
OpenOPC/opc/core/config.py
T
2026-07-01 17:56:31 +08:00

1858 lines
70 KiB
Python

"""Configuration management for OPC system."""
from __future__ import annotations
import os
import re
import tempfile
import unicodedata
from pathlib import Path
from typing import Any, Literal
import yaml
from pydantic import AliasChoices, BaseModel, Field, field_validator
from opc.core.company_tools import COMPANY_APPROVAL_EXEMPT_TOOL_NAMES
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
def _find_project_root() -> Path:
"""Walk up from cwd looking for pyproject.toml to locate the project root."""
cwd = Path.cwd()
for parent in [cwd, *cwd.parents]:
if (parent / "pyproject.toml").exists():
return parent
return cwd
def get_opc_home() -> Path:
"""Return the OPC data directory.
Resolution order:
1. $OPC_HOME environment variable (explicit override)
2. {project_root}/.opc (project-local, the default)
"""
env = os.environ.get("OPC_HOME")
if env:
return Path(env)
return _find_project_root() / ".opc"
def get_default_workplace_root() -> Path:
"""Return the default shared workplace root beside the OpenOPC repo."""
project_root = _find_project_root()
return project_root.parent / f"{project_root.name}_workplace"
def get_project_workplace(project_id: str) -> Path:
project = str(project_id or "default").strip() or "default"
return get_default_workplace_root() / project
def get_project_config_dir(project_path: Path | None = None) -> Path:
if project_path:
return project_path / ".opc"
return get_opc_home()
def _atomic_write_text(path: Path, content: str) -> None:
"""Write text via fsync and same-directory atomic replace."""
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
"w",
encoding="utf-8",
dir=path.parent,
prefix=f".{path.name}.",
suffix=".tmp",
delete=False,
) as f:
tmp_path = Path(f.name)
f.write(content)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, path)
except Exception:
if tmp_path is not None:
try:
tmp_path.unlink(missing_ok=True)
except Exception:
pass
raise
def _atomic_write_yaml(path: Path, data: Any) -> None:
_atomic_write_text(path, yaml.dump(data, default_flow_style=False))
# ---------------------------------------------------------------------------
# Company organization config layout
# ---------------------------------------------------------------------------
COMPANY_INDEX_FILENAME = "company_index.yaml"
COMPANY_ORGS_DIRNAME = "company_orgs"
COMPANY_ORG_KIND = "opc_org_architecture"
COMPANY_INDEX_SCHEMA_VERSION = 1
COMPANY_ORG_SCHEMA_VERSION = 2
DEFAULT_ORGANIZATION_ID = "corporate"
_COMPANY_ORG_ID_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
_COMPANY_ORG_FILE_RE = re.compile(r"^(?:company|org)_([a-z0-9_-]{1,64})_config\.yaml$")
_ORG_STRUCTURE_KEYS = ("roles", "employees", "escalation_rules")
_ORG_RUNTIME_KEYS = (
"runtime_policies",
"talent_templates",
"teams",
"team_runtime",
"installed_packages",
"role_serial_queue_enabled",
)
_ORG_BEARING_KEYS = (
"company",
*_ORG_STRUCTURE_KEYS,
*_ORG_RUNTIME_KEYS,
)
def company_index_path(config_dir: Path) -> Path:
return Path(config_dir) / COMPANY_INDEX_FILENAME
def company_orgs_dir(config_dir: Path) -> Path:
return Path(config_dir) / COMPANY_ORGS_DIRNAME
def slugify_organization_name(name: Any, *, fallback: str = "org") -> str:
"""Return a safe file id from a user-facing organization name."""
raw = str(name or "").strip()
normalized = unicodedata.normalize("NFKD", raw)
ascii_text = normalized.encode("ascii", "ignore").decode("ascii")
lowered = ascii_text.lower()
lowered = re.sub(r"\s+", "_", lowered)
lowered = re.sub(r"[^a-z0-9_-]+", "_", lowered)
lowered = re.sub(r"_+", "_", lowered).strip("_-")
if not lowered:
lowered = str(fallback or "org").strip().lower()
lowered = re.sub(r"[^a-z0-9_-]+", "_", lowered)
lowered = re.sub(r"_+", "_", lowered).strip("_-") or "org"
return lowered[:64].strip("_-") or "org"
def validate_organization_id(value: Any) -> str:
org_id = str(value or "").strip()
if not _COMPANY_ORG_ID_RE.match(org_id):
raise ValueError(f"Invalid organization_id: {org_id!r}")
return org_id
def company_org_filename(organization_id: Any) -> str:
return f"org_{validate_organization_id(organization_id)}_config.yaml"
def organization_id_from_company_org_filename(path: Path) -> str | None:
match = _COMPANY_ORG_FILE_RE.match(Path(path).name)
return match.group(1) if match else None
def company_org_path(config_dir: Path, organization_id: Any) -> Path:
return company_orgs_dir(config_dir) / company_org_filename(organization_id)
def company_org_relative_path(organization_id: Any) -> str:
return f"{COMPANY_ORGS_DIRNAME}/{company_org_filename(organization_id)}"
def _read_yaml_file(path: Path) -> dict[str, Any]:
with open(path, encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
if not isinstance(data, dict):
raise ValueError(f"Expected YAML mapping in {path}")
return data
def _write_yaml_preserving_unicode(path: Path, data: Any) -> None:
_atomic_write_text(
path,
yaml.dump(data, default_flow_style=False, sort_keys=False, allow_unicode=True),
)
def read_company_index(config_dir: Path) -> str | None:
path = company_index_path(config_dir)
if not path.exists():
return None
data = _read_yaml_file(path)
schema_version = int(data.get("schema_version", COMPANY_INDEX_SCHEMA_VERSION) or COMPANY_INDEX_SCHEMA_VERSION)
if schema_version > COMPANY_INDEX_SCHEMA_VERSION:
raise ValueError(
f"{COMPANY_INDEX_FILENAME} schema_version {schema_version} is not supported by this version of OpenOPC"
)
active_id = data.get("active_organization_id") or DEFAULT_ORGANIZATION_ID
return validate_organization_id(active_id)
def write_company_index(config_dir: Path, organization_id: Any) -> None:
org_id = validate_organization_id(organization_id)
_write_yaml_preserving_unicode(
company_index_path(config_dir),
{
"schema_version": COMPANY_INDEX_SCHEMA_VERSION,
"active_organization_id": org_id,
},
)
def write_company_org_payload(config_dir: Path, organization_id: Any, payload: dict[str, Any]) -> Path:
org_id = validate_organization_id(organization_id)
path = company_org_path(config_dir, org_id)
payload = dict(payload or {})
raw_employees = list(payload.get("employees", []) or [])
if raw_employees:
from opc.core.employee_registry import load_employee_registry, write_employee_registry
opc_home = Path(config_dir).parent
existing = load_employee_registry(opc_home, org_id)
write_employee_registry(opc_home, org_id, [*existing, *raw_employees])
payload["organization_id"] = org_id
payload.setdefault("schema_version", COMPANY_ORG_SCHEMA_VERSION)
payload.setdefault("kind", COMPANY_ORG_KIND)
payload["employees"] = []
payload["talent_templates"] = []
payload["metadata"] = {
**dict(payload.get("metadata", {}) or {}),
"organization_config_file": company_org_relative_path(org_id),
}
_write_yaml_preserving_unicode(path, payload)
return path
def list_company_org_config_paths(config_dir: Path) -> list[Path]:
orgs_dir = company_orgs_dir(config_dir)
if not orgs_dir.is_dir():
return []
return sorted(path for path in orgs_dir.glob("org_*_config.yaml") if organization_id_from_company_org_filename(path))
def allocate_organization_id(config_dir: Path, organization_name: Any, *, preferred_id: Any = "") -> str:
base = str(preferred_id or "").strip()
if base and _COMPANY_ORG_ID_RE.match(base):
candidate = base
else:
candidate = slugify_organization_name(organization_name)
existing = {
org_id
for path in list_company_org_config_paths(config_dir)
for org_id in [organization_id_from_company_org_filename(path)]
if org_id
}
if candidate not in existing:
return candidate
suffix = 2
while True:
tail = f"_{suffix}"
stem = candidate[: max(1, 64 - len(tail))].rstrip("_-") or "org"
next_id = f"{stem}{tail}"
if next_id not in existing:
return next_id
suffix += 1
# ---------------------------------------------------------------------------
# Config Models
# ---------------------------------------------------------------------------
class LLMConfig(BaseModel):
default_model: str = "anthropic/claude-sonnet-4-20250514"
api_base: str = ""
api_key: str = ""
api_key_env: str = ""
routing: dict[str, str] = Field(default_factory=dict)
fallback: dict[str, Any] = Field(default_factory=dict)
temperature: float = 0.3
max_tokens: int = 8192
# 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. 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/<org>/employees/*.yaml.
# Keep org config focused on organization structure; load treats this
# field as legacy input only.
"employees": [],
"escalation_rules": [rule.model_dump() for rule in org.escalation_rules],
"runtime_policies": _runtime_policy_payload_for_org(config, profile),
# Talent templates live in .opc/prompts/talent/*.md and builtin presets.
# Keep org configs focused on organization structure, not talent indexes.
"talent_templates": [],
"teams": [team.model_dump() for team in org.teams],
"team_runtime": org.team_runtime.model_dump(),
"installed_packages": _dump_config_list(org.installed_packages),
"role_serial_queue_enabled": bool(org.role_serial_queue_enabled),
"metadata": {
"source": "opc_config",
"organization_config_file": company_org_relative_path(org_id),
},
}
def _default_company_org_payload() -> dict[str, Any]:
cfg = OPCConfig()
return build_company_org_payload_from_config(
cfg,
organization_id=DEFAULT_ORGANIZATION_ID,
organization_name=cfg.org.company_name,
force_profile="corporate",
)
def _validate_company_org_payload(path: Path, data: dict[str, Any]) -> dict[str, Any]:
schema_version = int(data.get("schema_version", 1) or 1)
if schema_version > COMPANY_ORG_SCHEMA_VERSION:
raise ValueError(
f"{path.name} schema_version {schema_version} is not supported by this version of OpenOPC"
)
kind = str(data.get("kind", "") or "").strip()
if schema_version >= COMPANY_ORG_SCHEMA_VERSION and kind and kind != COMPANY_ORG_KIND:
raise ValueError(f"Unsupported organization config kind in {path.name}: {kind}")
data["schema_version"] = schema_version
return data
def _company_org_payload_to_org_mapping(data: dict[str, Any], *, source_path: Path | None = None) -> dict[str, Any]:
company = data.get("company") if isinstance(data.get("company"), dict) else {}
raw_org_id = data.get("organization_id") or DEFAULT_ORGANIZATION_ID
org_id = validate_organization_id(raw_org_id)
org_name = str(data.get("organization_name") or company.get("name") or org_id).strip()
company_name = str(company.get("name") or org_name or "My OPC").strip()
org: dict[str, Any] = {
"organization_id": org_id,
"organization_name": org_name or company_name,
"organization_config_file": str(source_path or company_org_relative_path(org_id)),
"company_name": company_name,
"topology": company.get("topology", ""),
"company_profile": company.get("company_profile") or ("custom" if org_id != DEFAULT_ORGANIZATION_ID else "corporate"),
"execution_model": company.get("execution_model") or "actor_runtime",
"final_decider_role_id": company.get("final_decider_role_id"),
"company_profiles": company.get("company_profiles") or ["corporate", "custom"],
}
for key in _ORG_STRUCTURE_KEYS:
if key in data:
value = data.get(key) or []
if key == "employees" and isinstance(value, list):
value = [item for item in value if _should_keep_org_employee_payload(item)]
org[key] = value
for key in _ORG_RUNTIME_KEYS:
if key == "role_serial_queue_enabled":
if key in data:
org[key] = bool(data.get(key))
continue
if key == "talent_templates":
continue
if key in data:
org[key] = data.get(key) or ({} if key in {"runtime_policies", "team_runtime"} else [])
return org
def _legacy_corporate_config_candidates(config_dir: Path) -> list[Path]:
candidates: list[Path] = []
project_root_candidate = _find_project_root() / "config" / "company_corporate_config.yaml"
local_candidate = Path(config_dir) / "company_corporate_config.yaml"
candidate_order = [local_candidate]
try:
if Path(config_dir).resolve() == (get_opc_home() / "config").resolve():
candidate_order.insert(0, project_root_candidate)
except Exception:
pass
for path in candidate_order:
if path not in candidates:
candidates.append(path)
return candidates
def _read_legacy_corporate_config(config_dir: Path) -> dict[str, Any]:
for path in _legacy_corporate_config_candidates(config_dir):
if not path.exists():
continue
data = _read_yaml_file(path)
schema_ver = int(data.get("schema_version", 1) or 1)
if schema_ver > 1:
raise ValueError(
f"company_corporate_config.yaml schema_version {schema_ver} is not supported by this version of OpenOPC"
)
return data
return {}
def _read_legacy_org_runtime_config(config_dir: Path) -> dict[str, Any]:
path = Path(config_dir) / "org_config.yaml"
if not path.exists():
return {}
return _read_yaml_file(path)
def _legacy_company_org_payload(config_dir: Path) -> dict[str, Any]:
corporate_data = _read_legacy_corporate_config(config_dir)
org_runtime_data = _read_legacy_org_runtime_config(config_dir)
if not corporate_data and not org_runtime_data:
return _default_company_org_payload()
company = dict(corporate_data.get("company", {}) or org_runtime_data.get("company", {}) or {})
company_name = str(company.get("name") or "My One-Person Company").strip()
profile = str(company.get("company_profile") or "").strip() or "corporate"
org_id = (
slugify_organization_name(company_name)
if profile == "custom"
else DEFAULT_ORGANIZATION_ID
)
payload: dict[str, Any] = {
"schema_version": COMPANY_ORG_SCHEMA_VERSION,
"kind": COMPANY_ORG_KIND,
"organization_id": org_id,
"organization_name": company_name,
"company": {
"name": company_name,
"topology": company.get("topology", ""),
"company_profile": profile,
"execution_model": company.get("execution_model") or "actor_runtime",
"final_decider_role_id": company.get("final_decider_role_id"),
"company_profiles": company.get("company_profiles") or ["corporate", "custom"],
},
"roles": corporate_data.get("roles") or org_runtime_data.get("roles") or [],
"employees": corporate_data.get("employees") or org_runtime_data.get("employees") or [],
"escalation_rules": corporate_data.get("escalation_rules") or org_runtime_data.get("escalation_rules") or [],
"runtime_policies": org_runtime_data.get("runtime_policies") or {},
"talent_templates": [],
"teams": org_runtime_data.get("teams") or [],
"team_runtime": org_runtime_data.get("team_runtime") or {},
"installed_packages": org_runtime_data.get("installed_packages") or [],
"role_serial_queue_enabled": bool(org_runtime_data.get("role_serial_queue_enabled", True)),
"metadata": {"source": "legacy_migration"},
}
return payload
def _normalize_corporate_company_org_payload(data: dict[str, Any]) -> dict[str, Any]:
"""Return a corporate-scoped company org payload.
Company mode is the built-in corporate runtime. User-defined orgs are stored
under ``company_orgs/`` and should not become the process-wide company architecture
just because an old ``company_index.yaml`` still points at them.
"""
payload = dict(data or {})
company = dict(payload.get("company", {}) or {})
org_name = str(
payload.get("organization_name")
or company.get("name")
or "My One-Person Company"
).strip()
payload["organization_id"] = DEFAULT_ORGANIZATION_ID
payload["organization_name"] = org_name
company["name"] = str(company.get("name") or org_name).strip() or org_name
company["company_profile"] = "corporate"
company.setdefault("topology", "")
company.setdefault("execution_model", "actor_runtime")
company.setdefault("final_decider_role_id", None)
company.setdefault("company_profiles", ["corporate", "custom"])
payload["company"] = company
payload.setdefault("roles", [])
payload.setdefault("employees", [])
payload.setdefault("escalation_rules", [])
payload.setdefault("runtime_policies", {})
payload["talent_templates"] = []
payload.setdefault("teams", [])
payload.setdefault("team_runtime", {})
payload.setdefault("installed_packages", [])
payload.setdefault("role_serial_queue_enabled", True)
payload.setdefault("metadata", {})
return payload
def _company_payload_profile(data: dict[str, Any]) -> str:
company = data.get("company") if isinstance(data.get("company"), dict) else {}
return str(company.get("company_profile") or "").strip().lower()
def _company_payload_with_org_storage_path(data: dict[str, Any], org_id: str) -> dict[str, Any]:
payload = dict(data or {})
payload["organization_id"] = validate_organization_id(org_id)
payload.setdefault("schema_version", COMPANY_ORG_SCHEMA_VERSION)
payload.setdefault("kind", COMPANY_ORG_KIND)
payload["metadata"] = {
**dict(payload.get("metadata", {}) or {}),
"organization_config_file": company_org_relative_path(org_id),
}
return payload
def _company_org_payload_needs_externalization(data: dict[str, Any]) -> bool:
return bool(data.get("employees") or data.get("talent_templates"))
def _repoint_org_index_if_active_corporate(config_dir: Path, archive_id: str) -> None:
path = Path(config_dir) / "org_index.yaml"
if not path.exists():
return
try:
data = _read_yaml_file(path)
except Exception:
return
active_id = str(data.get("active_organization_id") or "").strip()
if active_id != DEFAULT_ORGANIZATION_ID:
return
_write_yaml_preserving_unicode(
path,
{
"schema_version": int(data.get("schema_version", 1) or 1),
"active_organization_id": archive_id,
},
)
def _archive_conflicting_corporate_org_config(config_dir: Path, target_path: Path) -> str | None:
"""Preserve a custom ``org_corporate`` file before writing corporate config.
Older org-mode builds allowed a user-defined custom architecture to use the
id ``corporate``. With unified ``company_orgs/`` storage, that filename is now
reserved for the built-in corporate company profile. If we see the old custom
file, keep it under a fresh id instead of overwriting it.
"""
if not target_path.exists():
return None
try:
existing = _validate_company_org_payload(target_path, _read_yaml_file(target_path))
except Exception:
return None
if _company_payload_profile(existing) != "custom":
return None
company = dict(existing.get("company", {}) or {})
org_name = str(
existing.get("organization_name")
or company.get("name")
or "Corporate Custom"
).strip()
archive_id = allocate_organization_id(
config_dir,
org_name,
preferred_id=f"{DEFAULT_ORGANIZATION_ID}_custom",
)
archived = _company_payload_with_org_storage_path(existing, archive_id)
archived["organization_name"] = org_name
company["company_profile"] = "custom"
archived["company"] = company
archived.setdefault("metadata", {})["source"] = "archived_custom_corporate_conflict"
write_company_org_payload(config_dir, archive_id, archived)
_repoint_org_index_if_active_corporate(config_dir, archive_id)
return archive_id
def _should_persist_company_migration(config_dir: Path) -> bool:
try:
return Path(config_dir).resolve() == (get_opc_home() / "config").resolve()
except Exception:
return False
def _migrate_legacy_saved_orgs(config_dir: Path) -> None:
legacy_dir = _find_project_root() / "config" / "orgs"
if not legacy_dir.is_dir():
return
for legacy_path in sorted(legacy_dir.glob("*.yaml")):
try:
data = _read_yaml_file(legacy_path)
company = dict(data.get("company", {}) or {})
org_name = str(
data.get("organization_name")
or company.get("name")
or legacy_path.stem
).strip()
raw_org_id = str(data.get("organization_id") or "").strip()
org_id = raw_org_id if _COMPANY_ORG_ID_RE.match(raw_org_id) else slugify_organization_name(legacy_path.stem)
target_path = company_org_path(config_dir, org_id)
if target_path.exists():
continue
company.setdefault("name", org_name)
company.setdefault("company_profile", "custom")
data.update({
"schema_version": COMPANY_ORG_SCHEMA_VERSION,
"kind": COMPANY_ORG_KIND,
"organization_id": org_id,
"organization_name": org_name,
"company": company,
})
data.setdefault("roles", [])
data.setdefault("employees", [])
data.setdefault("escalation_rules", [])
data.setdefault("runtime_policies", {})
data["talent_templates"] = []
data.setdefault("teams", [])
data.setdefault("team_runtime", {})
data.setdefault("installed_packages", [])
data.setdefault("role_serial_queue_enabled", True)
data.setdefault("metadata", {})["source"] = "legacy_saved_org_migration"
write_company_org_payload(config_dir, org_id, data)
except Exception:
continue
def load_company_org_payload(
config_dir: Path,
organization_id: Any = DEFAULT_ORGANIZATION_ID,
) -> tuple[dict[str, Any], Path]:
"""Load a specific company-mode organization payload.
``OPCConfig.load`` intentionally calls this with ``corporate`` instead of
consulting ``company_index.yaml``. The active org index is owned by org mode.
"""
config_dir = Path(config_dir)
org_id = validate_organization_id(organization_id or DEFAULT_ORGANIZATION_ID)
if _should_persist_company_migration(config_dir):
_migrate_legacy_saved_orgs(config_dir)
path = company_org_path(config_dir, org_id)
if path.exists():
payload = _validate_company_org_payload(path, _read_yaml_file(path))
if org_id == DEFAULT_ORGANIZATION_ID:
if _company_payload_profile(payload) == "custom":
if _should_persist_company_migration(config_dir):
_archive_conflicting_corporate_org_config(config_dir, path)
payload = _normalize_corporate_company_org_payload(_legacy_company_org_payload(config_dir))
write_company_org_payload(config_dir, org_id, _company_payload_with_org_storage_path(payload, org_id))
return payload, path
payload = _legacy_company_org_payload(config_dir)
payload = _normalize_corporate_company_org_payload(payload)
if _should_persist_company_migration(config_dir) and _company_org_payload_needs_externalization(payload):
write_company_org_payload(config_dir, org_id, payload)
payload = _validate_company_org_payload(path, _read_yaml_file(path))
return payload, path
if org_id != DEFAULT_ORGANIZATION_ID:
raise FileNotFoundError(f"Company organization config does not exist: {path}")
payload = _normalize_corporate_company_org_payload(_legacy_company_org_payload(config_dir))
if _should_persist_company_migration(config_dir):
write_company_org_payload(config_dir, org_id, payload)
payload = _read_yaml_file(path)
return _validate_company_org_payload(path, payload), path
def load_active_company_org_payload(config_dir: Path) -> tuple[dict[str, Any], Path]:
config_dir = Path(config_dir)
if _should_persist_company_migration(config_dir):
_migrate_legacy_saved_orgs(config_dir)
active_id = read_company_index(config_dir)
if active_id:
path = company_org_path(config_dir, active_id)
if not path.exists():
raise FileNotFoundError(f"Active organization config does not exist: {path}")
payload = _validate_company_org_payload(path, _read_yaml_file(path))
if _should_persist_company_migration(config_dir) and _company_org_payload_needs_externalization(payload):
write_company_org_payload(config_dir, active_id, payload)
payload = _validate_company_org_payload(path, _read_yaml_file(path))
return payload, path
payload = _legacy_company_org_payload(config_dir)
org_id = validate_organization_id(payload.get("organization_id") or DEFAULT_ORGANIZATION_ID)
path = company_org_path(config_dir, org_id)
if _should_persist_company_migration(config_dir):
write_company_org_payload(config_dir, org_id, payload)
payload = _read_yaml_file(path)
return _validate_company_org_payload(path, payload), path
class OPCConfig(BaseModel):
system: SystemConfig = Field(default_factory=SystemConfig)
llm: LLMConfig = Field(default_factory=LLMConfig)
agents: AgentsConfig = Field(default_factory=AgentsConfig)
org: OrgConfig = Field(default_factory=OrgConfig)
channels: ChannelsConfig = Field(default_factory=ChannelsConfig)
autonomy: AutonomyConfig = Field(default_factory=AutonomyConfig)
capabilities: CapabilityConfig = Field(default_factory=CapabilityConfig)
@classmethod
def load(cls, config_dir: Path | None = None) -> "OPCConfig":
if config_dir is None:
config_dir = get_opc_home() / "config"
merged: dict[str, Any] = {}
for name in ("system_config", "llm_config", "agent_config", "channel_config"):
path = config_dir / f"{name}.yaml"
if path.exists():
with open(path, encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
if name == "agent_config":
data = _migrate_agent_config_approval_modes(path, data)
data = _migrate_agent_config_external_agent_defaults(path, data)
merged.update(data)
org_payload, org_payload_path = load_company_org_payload(config_dir, DEFAULT_ORGANIZATION_ID)
mapping = {}
if "system" in merged:
mapping["system"] = merged["system"]
if "llm" in merged:
mapping["llm"] = merged["llm"]
if "external_agents" in merged:
agents_data = merged["external_agents"]
mapping["agents"] = {
"preferred_order": agents_data.get("preferred_order", []),
"agents": {
k: v for k, v in agents_data.items() if k != "preferred_order"
},
}
if "native_subagents" in merged:
agent_mapping = mapping.get("agents", {"preferred_order": [], "agents": {}})
if isinstance(agent_mapping, dict):
agent_mapping["native_subagents"] = merged["native_subagents"]
mapping["agents"] = agent_mapping
mapping["org"] = _company_org_payload_to_org_mapping(org_payload, source_path=org_payload_path)
if "channels" in merged:
mapping["channels"] = merged["channels"]
if "autonomy" in merged:
mapping["autonomy"] = merged["autonomy"]
if "capabilities" in merged:
mapping["capabilities"] = merged["capabilities"]
if "mcp_servers" in merged:
system_data = mapping.get("system", {})
if isinstance(system_data, dict):
system_data["mcp_servers"] = merged["mcp_servers"]
mapping["system"] = system_data
agent_mapping = mapping.get("agents")
if isinstance(agent_mapping, dict) and "native_subagents" not in agent_mapping:
agent_mapping["native_subagents"] = {
key: value.model_dump()
for key, value in _default_native_subagents().items()
}
mapping["agents"] = agent_mapping
config = cls.model_validate(mapping)
config.org.talent_templates = []
try:
from opc.core.employee_registry import load_company_employees
organization_id, _ = _organization_identity_from_org(config.org)
config.org.employees = load_company_employees(
Path(config_dir).parent,
organization_id,
list(config.org.employees),
)
except Exception:
pass
return config
def save(self, config_dir: Path | None = None) -> None:
if config_dir is None:
config_dir = get_opc_home() / "config"
config_dir.mkdir(parents=True, exist_ok=True)
system_path = config_dir / "system_config.yaml"
_atomic_write_yaml(system_path, {
"system": self.system.model_dump(),
"autonomy": self.autonomy.model_dump(),
"capabilities": self.capabilities.model_dump(),
})
llm_path = config_dir / "llm_config.yaml"
_atomic_write_yaml(llm_path, {"llm": self.llm.model_dump()})
organization_id, organization_name = _organization_identity_from_org(self.org)
from opc.core.employee_registry import write_employee_registry
self.org.employees, _ = write_employee_registry(
Path(config_dir).parent,
organization_id,
list(self.org.employees),
)
profile = str(self.org.company_profile or "").strip().lower()
if profile == "custom":
from opc.core.org_config import (
build_org_config_payload_from_config,
org_config_path,
write_org_config_payload,
write_org_index,
)
payload = build_org_config_payload_from_config(
self,
organization_id=organization_id,
organization_name=organization_name,
)
custom_path = org_config_path(config_dir, organization_id)
if not self.org.roles and custom_path.exists():
try:
with open(custom_path, encoding="utf-8") as f:
existing = yaml.safe_load(f) or {}
existing_roles = existing.get("roles") or []
if existing_roles:
import logging, os as _os
logging.getLogger(__name__).error(
"OPCConfig.save(): REFUSED to wipe %d existing roles with "
"empty list in custom mode. pid=%d, path=%s. "
"If this save was intentional, use reset_architecture.",
len(existing_roles), _os.getpid(), custom_path,
)
return
except Exception:
pass
write_org_config_payload(config_dir, organization_id, payload)
write_org_index(config_dir, organization_id)
else:
corporate_data = build_company_org_payload_from_config(
self,
organization_id=organization_id,
organization_name=organization_name,
)
if organization_id == DEFAULT_ORGANIZATION_ID:
corporate_path = company_org_path(config_dir, organization_id)
_archive_conflicting_corporate_org_config(config_dir, corporate_path)
write_company_org_payload(config_dir, organization_id, corporate_data)
agent_path = config_dir / "agent_config.yaml"
agent_data = {
"external_agents": {
"preferred_order": self.agents.preferred_order,
**{k: v.model_dump() for k, v in self.agents.agents.items()},
},
"native_subagents": {
key: value.model_dump()
for key, value in (self.agents.native_subagents or _default_native_subagents()).items()
},
}
_atomic_write_yaml(agent_path, agent_data)
channel_path = config_dir / "channel_config.yaml"
_atomic_write_yaml(channel_path, {"channels": self.channels.model_dump()})
AgentsConfig.model_rebuild()