# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Agent tools, sub-agent capabilities, and explorer settings configuration.""" import os from pathlib import Path from typing import Any, Literal from pydantic import BaseModel, Field, model_validator from artemis.config.constants import ( AGENT_CONFIG_FILENAME, DEFAULT_EXPLORER_VERSION, ExplorerVersion, ) from artemis.config.paths import ROOT_DIR, get_config_path from artemis.config.settings import settings from artemis.config.core.interpolator import interpolate_config_value from artemis.utils.file import load_jsonc from artemis.utils.logger import get_logger logger = get_logger(__name__) class VideoAnalyzerConfig(BaseModel): """Configuration specific to VideoAnalyzer and screen recording.""" enabled: bool | None = Field( default_factory=lambda: settings.ARTEMIS_VIDEO_ENABLED, description=( "Whether continuous screen video recording and video analyzer tools " "are enabled. If None, auto-detected based on scrcpy & ffmpeg availability." ), ) enable_ledger: bool = Field( default=True, description="Whether to enable video action ledger state tracking.", ) chunk_size_seconds: float = Field( default_factory=lambda: settings.ARTEMIS_VIDEO_CHUNK_SECONDS, ge=5.0, le=300.0, description="Maximum duration of an initial video-analysis chunk.", ) min_chunk_seconds: float = Field( default=4.0, ge=1.0, le=30.0, description="Smallest chunk produced by failure-driven bisection.", ) max_split_depth: int = Field( default=4, ge=0, le=8, description="Maximum recursive chunk bisection depth after failures.", ) circuit_breaker_threshold: int = Field( default=3, ge=1, le=20, description="Consecutive transient model failures before opening the circuit.", ) circuit_breaker_cooldown_seconds: float = Field( default=60.0, ge=1.0, le=900.0, description="How long an unhealthy video model remains bypassed.", ) action_window_seconds: float = Field( default=2.0, ge=0.0, le=10.0, description="Dense-perception window on each side of a recorded mobile action.", ) dense_action_fps: float = Field( default=4.0, ge=1.0, le=15.0, description="Frame sampling rate around recorded mobile actions.", ) max_dense_action_frames: int = Field( default=24, ge=0, le=120, description="Maximum additional action-proximal keyframes per chunk.", ) max_retries: int = Field( default_factory=lambda: settings.ARTEMIS_VIDEO_MAX_RETRIES, ge=0, le=5, description=( "Retries against the video model before switching to the " "configured universal fallback. A value of 1 means two total attempts." ), ) model_call_timeout_seconds: float = Field( default_factory=lambda: settings.ARTEMIS_VIDEO_TIMEOUT_SECONDS, ge=15.0, le=600.0, description="Hard timeout for one Responses video-model response.", ) model_config = {"extra": "allow"} class PlannerValidationConfig(BaseModel): """Configuration specific to Planner validation of milestone changes.""" enabled: bool = Field( default=True, description=( "Whether every top-level milestone text change is reviewed by the" " lightweight async planner validator. The review is advisory: a" " flagged change is never rolled back, the Operator only receives" " the concern and its reason as feedback." ), ) model_config = {"extra": "allow"} class CommitteeConfig(BaseModel): """Configuration specific to Multi-Agent Committee / Council.""" enabled: bool = Field( default=False, description="Whether to equip Operator with the ask_committee tool.", ) debate_rounds: int = Field( default=2, ge=1, le=5, description="Number of debate rounds among council members (1-5).", ) model_config = {"extra": "allow"} class CheckerConfig(BaseModel): """Configuration for plan-driven checkpoint verification and the final review.""" enabled: bool = Field( default=True, description=( "Master switch (compat alias): False disables BOTH midway" " checkpoints and the final check." ), ) midway_checks: bool = Field( default=False, description=( "Whether plan-declared midway checkpoints run (requires enabled=True)." " Off in the factory layering: final check on, midway checks off." ), ) final_check: bool = Field( default=True, description="Whether the exit final review runs (requires enabled=True).", ) max_iterations: int = Field( default=20, ge=1, le=50, description="Maximum verification iterations for Checker LLM reasoning and inspection.", ) final_check_max_attempts: int = Field( default=3, ge=1, le=10, description="Maximum final-review attempts before ending with a blocked outcome.", ) checkpoint_max_repairs: int = Field( default=2, ge=0, le=10, description="Per-checkpoint repair quota for failed verify criteria.", ) max_concurrent_checkpoints: int = Field( default=3, ge=1, le=10, description="Maximum concurrently running checkpoint attempts.", ) checkpoint_timeout: float = Field( default_factory=lambda: settings.ARTEMIS_CHECKPOINT_TIMEOUT_SECONDS, gt=0, description="Per-attempt timeout (seconds) for one checkpoint check.", ) settlement_timeout: float = Field( default_factory=lambda: settings.ARTEMIS_SETTLEMENT_TIMEOUT_SECONDS, gt=0, description="Aggregate timeout (seconds) for the exit settlement barrier.", ) assert_failure_policy: Literal["continue", "halt"] = Field( default="continue", description="Whether an assert failure lets execution continue or halts the run.", ) device_probes: bool = Field( default=True, description="Whether the Checker's enumerated read-only device probes are registered.", ) model_config = {"extra": "allow"} #: Coarse verification presets exposed to the CLI (``--verification-level``) #: and the admin console (``verification_level`` on ``/api/run``). Each preset #: is a partial :class:`CheckerConfig` override; unspecified fields keep the #: values from ``artemis.jsonc``. ``final`` mirrors the factory layering. VerificationLevel = Literal["off", "final", "checkpoints", "strict"] DEFAULT_VERIFICATION_LEVEL: VerificationLevel = "final" VERIFICATION_LEVEL_PRESETS: dict[str, dict[str, Any]] = { # No Checker at all: the Operator self-reports completion, nothing audits it. "off": {"enabled": False}, # Factory layering: one exit audit against the user's goal, no midway checkpoints. "final": {"enabled": True, "midway_checks": False, "final_check": True}, # Every plan-declared checkpoint runs at its subgoal's completion, plus the exit audit. "checkpoints": {"enabled": True, "midway_checks": True, "final_check": True}, # Checkpoints + exit audit with a larger repair budget; a failed assert halts the run. "strict": { "enabled": True, "midway_checks": True, "final_check": True, "assert_failure_policy": "halt", "checkpoint_max_repairs": 4, "final_check_max_attempts": 5, "max_iterations": 30, }, } def checker_overrides_for_level(level: str | None) -> dict[str, Any]: """Return the :class:`CheckerConfig` field overrides for a verification level. Args: level: One of ``off``, ``final``, ``checkpoints`` or ``strict`` (case-insensitive, surrounding whitespace ignored). Raises: ValueError: when ``level`` is not a known preset. """ key = str(level or "").strip().lower() preset = VERIFICATION_LEVEL_PRESETS.get(key) if preset is None: known = ", ".join(VERIFICATION_LEVEL_PRESETS) raise ValueError(f"Unknown verification level {level!r}; expected one of: {known}") return dict(preset) def verification_level_for_checker(checker: "CheckerConfig") -> VerificationLevel: """Classify an effective :class:`CheckerConfig` back onto the coarse ladder. Used by launcher UIs to show where the configured defaults sit; the inverse of :func:`checker_overrides_for_level` for the fields the presets control. """ if not checker.enabled or (not checker.midway_checks and not checker.final_check): return "off" if checker.midway_checks: return "strict" if checker.assert_failure_policy == "halt" else "checkpoints" return "final" def run_tuning_for_profile( profile: str | None, *, checker: "CheckerConfig", explorer: "ExplorerConfig", explorer_versions: dict[str, str] | None = None, ) -> dict[str, str] | None: """Return Pro verification and explorer settings for session metadata. Flash has no per-run tuning and returns None. """ key = str(profile or "").strip().lower() if key != "pro": return None return { "verification_level": verification_level_for_checker(checker), "explorer_mode": explorer.resolve(profile="pro", per_agent_overrides=explorer_versions), } class OutputterConfig(BaseModel): """Configuration specific to Outputter post-execution report synthesis agent.""" enabled: bool = Field( default=True, description="Whether Outputter agent is mounted for post-execution report/structured output synthesis.", ) force_synthesis: bool = Field( default=False, description="Whether to force Outputter synthesis even when no structured schema is explicitly requested.", ) model_config = {"extra": "allow"} class ExplorerConfig(BaseModel): """Configuration specific to UI Explorer visual sub-agent (Flash / Pro / Ultra).""" default_version: ExplorerVersion = Field( default=DEFAULT_EXPLORER_VERSION, description="Default Explorer version mode ('flash', 'pro', or 'ultra').", ) flash_mode: ExplorerVersion = Field( default="flash", description="Explorer version mode when main system runs under Flash profile (FlashRunner).", ) pro_mode: ExplorerVersion = Field( default="flash", description="Explorer version mode when main system runs under Pro profile (Graph / Operator / Validator).", ) caching: bool | None = Field( default=None, description=( "Legacy compatibility field; Responses prompt caching is managed by the endpoint." ), ) model_config = {"extra": "allow"} def resolve( self, explicit_version: str | None = None, agent_name: str | None = None, profile: str | None = None, per_agent_overrides: dict[str, str] | None = None, ) -> ExplorerVersion: """Resolves active Explorer version with clean precedence rules.""" if explicit_version: v = str(explicit_version).strip().lower() if v in ("flash", "pro", "ultra"): return v # type: ignore env_v = os.getenv("ARTEMIS_EXPLORER_VERSION", "").strip().lower() if env_v in ("flash", "pro", "ultra"): return env_v # type: ignore if per_agent_overrides and agent_name and agent_name in per_agent_overrides: v = str(per_agent_overrides[agent_name]).strip().lower() if v in ("flash", "pro", "ultra"): return v # type: ignore if profile in ("flash", "flash_runner") or agent_name in ("flash", "flash_runner"): return self.flash_mode if profile in ("pro", "operator", "validator") or agent_name in ( "pro", "operator", "validator", ): return self.pro_mode return self.default_version class StepSummarizerConfig(BaseModel): """Configuration specific to Flash asynchronous step visual summarization.""" enabled: bool = Field( default=True, description="Whether to asynchronously summarize historical steps to replace pruned images.", ) model: str = Field( default_factory=lambda: settings.OPENAI_MODEL, description="Lightweight model used for background step state summarization.", ) prune_history_xml: bool = Field( default=True, description="Whether to prune outdated heavy UI XML trees from historical turns.", ) retry_limit: int = Field( default=3, ge=0, description=( "Maximum retries per step summary after the first attempt; on" " exhaustion the step is marked summary_status=failed instead of" " retrying forever." ), ) model_config = {"extra": "allow"} class MemoryRuntimeConfig(BaseModel): """Scheduling options for the shared step-memory runtime (agent.memory.runtime).""" max_concurrency: int = Field( default=2, ge=1, le=16, description=( "Maximum background summary attempts running concurrently." " Default 2 per the 2026-09-01 on-device baseline" " (history-baseline-2026-09-01.md §6): the serial queue at 1 pushed" " summary-ready P90 past the scrub-edge grace window; lens calls" " are independent lightweight-model requests, so 2 is low-risk." ), ) retry_limit: int = Field( default=3, ge=0, description=( "Maximum retries per summary job after the first attempt; on" " exhaustion the job enters an explicit failed state." ), ) flush_timeout_s: float = Field( default=30.0, gt=0, description="Upper bound in seconds for draining in-flight summary jobs at shutdown.", ) model_config = {"extra": "allow"} class MemoryTranscriptConfig(BaseModel): """Scrub-edge options for the message transcript (agent.memory.transcript).""" enabled: bool = Field( default=True, description=( "Whether the Pro operator builds its prompt from the session" " transcript ledger (S/F/A regions + per-turn tail) instead of the" " legacy per-turn 2-message rebuild. On by default since M5" " (2026-09-01, per the on-device A/B in" " history-baseline-2026-09-01.md); this is also the rollback" " switch — setting it to false restores the legacy path" " byte-for-byte, and L2/L3 chunk compression rides the same flag." ), ) image_scrub_depth: int = Field( default=3, ge=1, description=( "Depth K of the screenshot scrub edge from the start gate up: the" " K-th most recent screenshot (the live one counts as depth 1) is" " replaced by its visual summary." ), ) image_scrub_depth_relaxed: int = Field( default=6, ge=1, description=( "Screenshot scrub depth while the operator's measured context is" " below budget*start_ratio (nothing is being replaced yet, so" " more raw screenshots stay in view). Must be >= image_scrub_depth;" " set equal to it to disable the occupancy-driven depth. A change" " of depth never rewrites an already-scrubbed message." ), ) pending_grace_steps: int = Field( default=3, ge=0, description=( "Extra image-depths a step may retain its screenshot past the" " scrub edge while its summary is still pending; afterwards the" " image is replaced by a placeholder referencing the DataEngine step." ), ) xml_scrub_depth: int | None = Field( default=1, ge=1, description=( "Text edge of the scrub: once a message is deeper than this many" " image-bearing observations (1 = as soon as a newer observation" " exists) its UI element list, plan recitation and per-turn" " ephemeral blocks are removed. Kept shallow on purpose: only the" " live observation carries an indexed element list, so a stale" " [n] index can never be picked up as a target. Screenshots follow" " image_scrub_depth / image_scrub_depth_relaxed." ), ) context_budget_tokens: int = Field( default=80000, ge=1024, description=( "Measured-token budget for the transcript context (provisional" " default pending the on-device baseline; §3.4). The soft/hard" " ratios below are applied against this budget." ), ) start_ratio: float = Field( default=0.35, ge=0.0, lt=1.0, description=( "Start gate: below budget*start_ratio of the operator's measured" " prompt size, closed segments only prepare their capsule in the" " background — the original turns stay in the transcript. Ready" " chunks replace their turns once the measured context reaches" " this ratio (the hard threshold overrides it). 0 restores" " swap-as-soon-as-ready." ), ) soft_ratio: float = Field( default=0.7, gt=0.0, lt=1.0, description=( "Soft threshold: when the operator's last measured prompt size" " reaches budget*soft_ratio, the oldest open segment is" " chunk-compressed (L2)." ), ) hard_ratio: float = Field( default=0.9, gt=0.0, le=1.0, description=( "Hard threshold: when the operator's last measured prompt size" " reaches budget*hard_ratio, ready chunks swap as L2 blocks first;" " only if that would not bring the estimated context back under the" " line does everything closed force-swap (pending chunks included)" " and the whole frozen region fold into a recall-only era — L3: the" " period paragraph with the per-step index. The fold is permanent;" " later L2 chunks are appended after it." ), ) min_active_steps: int = Field( default=5, ge=1, description=( "Sliding-window floor: no compression trigger may shrink the" " active region below this many most-recent raw turns." ), ) similarity_hint: bool = Field( default=True, description=( "Whether the operator prompt injects a local historical-state hint" " when the current screen's perceptual hash closely matches a much" " older step's post-action screen (no model call, no historical" " image). Silent when the match is within the recent 3 steps —" " that regime belongs to the pixel-level same-screen note." ), ) similarity_max_distance: int = Field( default=5, ge=0, le=64, description=( "Maximum Hamming distance between 64-bit dHash values for the" " historical-state hint to fire. Calibrated 2026-09-01 from 460" " dHash-stamped on-device steps (16 sessions): same-screen" " re-captures cluster at distance <=4, genuinely different screens" " mass at >=7 (valley at 5-6); cross-app pairs (guaranteed" " different screens) score <=5 in only 0.14% of cases vs 0.24% at" " the previous provisional 8." ), ) model_config = {"extra": "allow"} @model_validator(mode="after") def _ratios_are_ordered(self) -> "MemoryTranscriptConfig": """The three thresholds form a ladder against the same budget.""" if not (0.0 <= self.start_ratio <= self.soft_ratio <= self.hard_ratio <= 1.0): raise ValueError( "memory.transcript ratios must satisfy" " 0 <= start_ratio <= soft_ratio <= hard_ratio <= 1" f" (got start_ratio={self.start_ratio}, soft_ratio={self.soft_ratio}," f" hard_ratio={self.hard_ratio})." ) if self.image_scrub_depth_relaxed < self.image_scrub_depth: raise ValueError( "memory.transcript.image_scrub_depth_relaxed must be >= image_scrub_depth" f" (got {self.image_scrub_depth_relaxed} < {self.image_scrub_depth})." ) return self class MemoryRecallConfig(BaseModel): """search_history tool options (agent.memory.recall).""" enabled: bool = Field( default=True, description=( "Whether the operator (Pro and Flash) gets the search_history tool" " for deterministic lookups into cold (compressed/evicted) history." " replay_steps and get_step_screenshot are not gated by this flag." ), ) max_results: int = Field( default=5, ge=1, le=20, description="Upper bound on results per search_history call.", ) max_text_tokens: int = Field( default=2000, ge=128, description=( "Estimated-token cap (char/4) on one search_history response;" " excerpts are truncated to fit." ), ) screen_scan_steps: int = Field( default=150, ge=0, description=( "How many most-recent steps have their screenshot OCR/UI-tree text" " scanned by search_history (per-image JSON loads are the expensive" " part of the sweep). Narrow with step_range to reach older steps." ), ) model_config = {"extra": "allow"} class MemoryReplayConfig(BaseModel): """replay_steps tool options (agent.memory.replay).""" max_steps: int = Field( default=5, ge=1, le=50, description="Maximum steps replayed per replay_steps call.", ) max_tokens: int = Field( default=12000, ge=128, description=( "Estimated-token budget (char/4) of one replay_steps response." " When exceeded, whole trailing steps are dropped; individual" " steps are kept intact." ), ) model_config = {"extra": "allow"} class MemoryChunkingConfig(BaseModel): """Segment (L2) chunk compression options (agent.memory.chunking).""" max_steps: int = Field( default=12, ge=1, description=( "Size-threshold trigger: an open segment of this many steps is" " chunk-compressed even without a milestone switch. Also the" " maximum steps per chunk when a longer segment is split." ), ) target_source_tokens: int = Field( default=2000, ge=128, description=( "Size-threshold trigger: estimated source tokens (transcript" " characters through the session-calibrated ratio, 4 chars/token" " by default) over the open segment's eligible portion that force" " a chunk compression." ), ) min_steps: int = Field( default=3, ge=1, description=( "Minimum turns in a chunk closed by the size or pressure trigger:" " below this the eligible turns keep accumulating instead of" " becoming one-step chunks (each chunk costs a capsule call)." " Milestone closes are exempt (a complete segment is a unit) and" " the hard threshold waives it (emergency)." ), ) model: str = Field( default_factory=lambda: settings.OPENAI_MODEL, description="Model used for the chunk-level StepCapsuleLens (bands ①+②).", ) max_chunks: int = Field( default=8, ge=1, description=( "Maximum full three-band chunk blocks kept in the frozen region;" " older chunks merge into eras (headers set-merged, ledgers kept)." ), ) max_eras: int | None = Field( default=None, ge=1, description=( "Maximum eras kept with their per-segment ledgers; older eras" " collapse their ledgers to search_history marker lines. None" " (default) follows max_chunks, preserving the pre-M5 equal-value" " behavior." ), ) @model_validator(mode="after") def _min_steps_within_cap(self) -> "MemoryChunkingConfig": if self.min_steps > self.max_steps: raise ValueError( "memory.chunking.min_steps must not exceed max_steps" f" (got min_steps={self.min_steps}, max_steps={self.max_steps})." ) return self model_config = {"extra": "allow"} class MemoryConfig(BaseModel): """Unified step-memory configuration block (agent.memory).""" runtime: MemoryRuntimeConfig = Field( default_factory=MemoryRuntimeConfig, description="Shared background summarization runtime scheduling options.", ) transcript: MemoryTranscriptConfig = Field( default_factory=MemoryTranscriptConfig, description="Transcript scrub-edge options.", ) chunking: MemoryChunkingConfig = Field( default_factory=MemoryChunkingConfig, description="Segment chunk (L2) compression options.", ) recall: MemoryRecallConfig = Field( default_factory=MemoryRecallConfig, description="search_history tool options.", ) replay: MemoryReplayConfig = Field( default_factory=MemoryReplayConfig, description="replay_steps tool options.", ) policies: dict[str, dict[str, Any]] = Field( default_factory=dict, description=( "Per-agent overrides of the compiled-history ContextPolicy table" " (artemis.memory.context_policy); keys are agent names, values" " are policy field overrides (e.g. {'planner': {'last_n_detailed': 3}})." ), ) model_config = {"extra": "allow"} class FlashProfileConfig(BaseModel): """Configuration options specific to the ⚡ Flash execution profile (FlashRunner/ReactiveRunner).""" max_turns: int = Field( default=0, ge=0, description=( "Maximum reactive turns for Flash execution; 0 (the default) means" " unlimited. Context growth is bounded by the transcript ledger's" " scrub edge and chunk compression, not by a turn cap, so a long" " task simply runs until it reports its status." ), ) explorer_mode: ExplorerVersion = Field( default="flash", description="Perception mode for Flash runner ('flash' for 1-shot detection).", ) step_summarizer: StepSummarizerConfig = Field( default_factory=StepSummarizerConfig, description="Asynchronous visual context compressor settings.", ) model_config = {"extra": "allow"} class ProExplorerConfig(BaseModel): """Explorer perception settings under Pro profile.""" mode: ExplorerVersion = Field( default="flash", description="Explorer perception mode under Pro profile ('flash', 'pro', or 'ultra').", ) caching: bool = Field( default=True, description="Whether to enable context caching for multi-turn Pro/Ultra exploration.", ) model_config = {"extra": "allow"} class ExecutionConfig(BaseModel): """Validator execution tiers for the Pro profile.""" max_burst_actions: int = Field( default=4, ge=2, le=10, description=( "Maximum turn-ending actions the Operator may chain into one fast-action" " burst (executed back to back without the safety net). A longer turn is" " rejected before execution and fed back to the Operator." ), ) plan_ledger_gate: bool = Field( default=True, description=( "Require active plan sub-goals and periodically prompt the Operator to" " update stale plan progress." ), ) plan_ledger_stale_turns: int = Field( default=4, ge=0, description=( "Action turns allowed without a task_plan change before the ledger gate" " prompts for an update. Set to 0 to disable staleness checks." ), ) class ProProfileConfig(BaseModel): """Configuration options specific to the 🚀 Pro execution profile (LangGraph / Multi-Agent Closed-Loop).""" execution: ExecutionConfig = Field( default_factory=ExecutionConfig, description="Validator execution tiers (vetted single action vs fast-action burst).", ) explorer: ProExplorerConfig = Field( default_factory=ProExplorerConfig, description="Explorer perception settings for Pro profile.", ) planner_validation: PlannerValidationConfig = Field( default_factory=PlannerValidationConfig, description="Planner milestone consistency validation settings.", ) committee: CommitteeConfig = Field( default_factory=CommitteeConfig, description="Multi-Agent Committee council debate settings.", ) checker: CheckerConfig = Field( default_factory=CheckerConfig, description="Checker checkpoint verification and exit final review settings.", ) video_analyzer: VideoAnalyzerConfig = Field( default_factory=VideoAnalyzerConfig, description="Screen video action ledger tracking settings.", ) model_config = {"extra": "allow"} class AgentGlobalConfig(BaseModel): """Global configuration parsed from artemis.jsonc / agent_config.json.""" # ⚡ Profile-categorized configurations flash: FlashProfileConfig = Field( default_factory=FlashProfileConfig, description="Options for ⚡ Flash reactive runner execution.", ) pro: ProProfileConfig = Field( default_factory=ProProfileConfig, description="Options for 🚀 Pro multi-agent graph execution.", ) # 🌐 Core Component Configurations explorer: ExplorerConfig = Field( default_factory=ExplorerConfig, description="UI Explorer sub-agent runtime options.", ) explorer_versions: dict[str, str] = Field( default_factory=dict, description=( "Advanced per-agent override of the Explorer tier (e.g." ' {"validator": "ultra"}). Empty by default so that' " ``explorer.flash_mode`` / ``explorer.pro_mode`` decide." ), ) planner_validation: PlannerValidationConfig = Field( default_factory=PlannerValidationConfig, description="Planner milestone validation runtime options.", ) committee: CommitteeConfig = Field( default_factory=CommitteeConfig, description="Committee council runtime options.", ) checker: CheckerConfig = Field( default_factory=CheckerConfig, description="Checker checkpoint verification and exit final review runtime options.", ) outputter: OutputterConfig = Field( default_factory=OutputterConfig, description="Outputter synthesis agent runtime options.", ) denylisted_tools: dict[str, list[str]] = Field( default_factory=dict, description="List of denylisted tools per agent.", ) video_analyzer: VideoAnalyzerConfig = Field( default_factory=VideoAnalyzerConfig, description="Video analyzer runtime options.", ) memory: MemoryConfig = Field( default_factory=MemoryConfig, description="Unified step-memory runtime and transcript scrub options.", ) model_config = {"extra": "allow"} @model_validator(mode="before") @classmethod def synchronize_profiles_and_components(cls, data: Any) -> Any: """Bidirectionally synchronize between categorized 'flash'/'pro' blocks and component fields.""" if not isinstance(data, dict): return data # 1. Sync from pro block to root components if provided if "pro" in data and isinstance(data["pro"], dict): pro_data = data["pro"] if "checker" in pro_data and "checker" not in data: data["checker"] = pro_data["checker"] if "committee" in pro_data and "committee" not in data: data["committee"] = pro_data["committee"] if "planner_validation" in pro_data and "planner_validation" not in data: data["planner_validation"] = pro_data["planner_validation"] if "video_analyzer" in pro_data and "video_analyzer" not in data: data["video_analyzer"] = pro_data["video_analyzer"] if "explorer" in pro_data and isinstance(pro_data["explorer"], dict): exp_pro = pro_data["explorer"] if "explorer" not in data: data["explorer"] = {} if isinstance(data["explorer"], dict): if "mode" in exp_pro and "pro_mode" not in data["explorer"]: data["explorer"]["pro_mode"] = exp_pro["mode"] if "caching" in exp_pro and "caching" not in data["explorer"]: data["explorer"]["caching"] = exp_pro["caching"] # 2. Sync from flash block to explorer.flash_mode if "flash" in data and isinstance(data["flash"], dict): flash_data = data["flash"] if "explorer_mode" in flash_data: if "explorer" not in data: data["explorer"] = {} if isinstance(data["explorer"], dict) and "flash_mode" not in data["explorer"]: data["explorer"]["flash_mode"] = flash_data["explorer_mode"] # 3. Sync from root components to pro/flash blocks if pro/flash block is omitted if "checker" in data and ("pro" not in data or "checker" not in data.get("pro", {})): data.setdefault("pro", {})["checker"] = data["checker"] if "committee" in data and ("pro" not in data or "committee" not in data.get("pro", {})): data.setdefault("pro", {})["committee"] = data["committee"] if "planner_validation" in data and ( "pro" not in data or "planner_validation" not in data.get("pro", {}) ): data.setdefault("pro", {})["planner_validation"] = data["planner_validation"] if "video_analyzer" in data and ( "pro" not in data or "video_analyzer" not in data.get("pro", {}) ): data.setdefault("pro", {})["video_analyzer"] = data["video_analyzer"] # 3b. Legacy alias: an explicit flash.step_summarizer.retry_limit keeps # working by seeding memory.runtime.retry_limit when the new key is # absent (an explicit new key wins over the legacy one). flash_block = data.get("flash") flash_ss = flash_block.get("step_summarizer") if isinstance(flash_block, dict) else None if isinstance(flash_ss, dict) and "retry_limit" in flash_ss: mem_block = data.setdefault("memory", {}) if isinstance(mem_block, dict): runtime_block = mem_block.setdefault("runtime", {}) if isinstance(runtime_block, dict): runtime_block.setdefault("retry_limit", flash_ss["retry_limit"]) # 4. Environment variable overrides (highest precedence for runtime switches) def _parse_bool(var_name: str) -> bool | None: v = os.getenv(var_name) if v is None: return None v_clean = v.strip().lower() if v_clean in ("1", "true", "yes", "on"): return True if v_clean in ("0", "false", "no", "off"): return False return None chk_env = _parse_bool("ARTEMIS_CHECKER_ENABLED") if chk_env is not None: data.setdefault("checker", {})["enabled"] = chk_env data.setdefault("pro", {}).setdefault("checker", {})["enabled"] = chk_env comm_env = _parse_bool("ARTEMIS_COMMITTEE_ENABLED") if comm_env is not None: data.setdefault("committee", {})["enabled"] = comm_env data.setdefault("pro", {}).setdefault("committee", {})["enabled"] = comm_env pv_env = _parse_bool("ARTEMIS_PLANNER_VALIDATION_ENABLED") if pv_env is not None: data.setdefault("planner_validation", {})["enabled"] = pv_env data.setdefault("pro", {}).setdefault("planner_validation", {})["enabled"] = pv_env out_env = _parse_bool("ARTEMIS_OUTPUTTER_ENABLED") if out_env is not None: data.setdefault("outputter", {})["enabled"] = out_env vid_rec_env = _parse_bool("ARTEMIS_VIDEO_RECORDING_ENABLED") or _parse_bool( "ARTEMIS_WITH_VIDEO_RECORDING_TOOLS" ) if vid_rec_env is not None: data.setdefault("video_analyzer", {})["enabled"] = vid_rec_env data.setdefault("pro", {}).setdefault("video_analyzer", {})["enabled"] = vid_rec_env vid_env = _parse_bool("ARTEMIS_VIDEO_LEDGER_ENABLED") if vid_env is not None: data.setdefault("video_analyzer", {})["enable_ledger"] = vid_env data.setdefault("pro", {}).setdefault("video_analyzer", {})["enable_ledger"] = vid_env return data def get_explorer_version( self, explicit_version: str | None = None, agent_name: str | None = "operator", profile: str | None = None, ) -> ExplorerVersion: """Resolve Explorer version from this configuration.""" return self.explorer.resolve( explicit_version=explicit_version, agent_name=agent_name, profile=profile, per_agent_overrides=self.explorer_versions, ) def resolve_explorer_version( ctx: Any | None = None, explicit_version: str | None = None, agent_or_profile_name: str | None = "operator", ) -> ExplorerVersion: """Resolves the active Explorer tier for a calling agent or profile. Precedence: explicit argument, ``ARTEMIS_EXPLORER_VERSION`` environment override, then the user's agent configuration (per-agent override, profile mode, default version) found on ``ctx.agent_config`` or, failing that, on ``ctx.execution_setup``. The tier is a user setting: calling agents never pass it themselves. """ for source in ( getattr(ctx, "agent_config", None), getattr(ctx, "execution_setup", None), ): explorer_cfg = getattr(source, "explorer", None) if explorer_cfg is not None and hasattr(explorer_cfg, "resolve"): return explorer_cfg.resolve( explicit_version=explicit_version, agent_name=agent_or_profile_name, per_agent_overrides=getattr(source, "explorer_versions", None), ) return ExplorerConfig().resolve( explicit_version=explicit_version, agent_name=agent_or_profile_name ) def load_agent_config( config_path: Path | None = None, ) -> AgentGlobalConfig: """Load and validate AgentGlobalConfig from artemis.jsonc or agent_config.json. Args: config_path: Optional explicit file path to configuration file. Returns: Validated AgentGlobalConfig instance. """ resolved_path = config_path if not resolved_path: for candidate in ("artemis.jsonc", "artemis.json", AGENT_CONFIG_FILENAME): try: resolved_path = get_config_path(candidate) break except FileNotFoundError: continue if not resolved_path: resolved_path = get_config_path(AGENT_CONFIG_FILENAME, ROOT_DIR / AGENT_CONFIG_FILENAME) try: with open(resolved_path, encoding="utf-8") as f: data = interpolate_config_value(load_jsonc(f)) if isinstance(data, dict) and "agent" in data: return AgentGlobalConfig.model_validate(data["agent"]) return AgentGlobalConfig.model_validate(data) except Exception as e: logger.error(f"Failed to load agent config from '{resolved_path}': {e}") raise ValueError(f"Failed to parse '{resolved_path}': {e}") from e