{"repo": "automatosai/automatos-ai", "files": {"orchestrator/modules/agents/factory/agent_factory.py": {"de23266d55047969e96888289cc506462b1e3c49": {"t": 1785674655.2426536, "n": 80167, "h": "ba59aa5d9eb58a09", "c": "\"\"\"\nAgent Factory — Clean Rewrite\n==============================\n\nPure execution layer for agents. Users define their own agent types.\nThe orchestrator handles all prompt engineering using Context Engineering.\nMultiple agents of different types can run simultaneously.\n\nTool Source: ONE path — get_tools_for_agent() from tool_router.py.\nNo hardcoded tool schemas, no legacy JSON action format, no mid-execution discovery.\n\"\"\"\n\nimport asyncio\nimport json\nimport logging\nimport time\nfrom dataclasses import dataclass, field\nfrom datetime import datetime\nfrom enum import Enum\nfrom typing import Any, Dict, List, Optional, Tuple, Union\n\nfrom sqlalchemy.orm import Session\n\nfrom config import config\nfrom core.llm import LLMConfig, LLMManager, LLMProvider, LLMResponse, create_llm_manager\nfrom core.llm.defaults import DEFAULT_MAX_OUTPUT_TOKENS\nfrom core.models import Agent, Base, PriorityLevel, Skill\nfrom core.models.composio_cache import AgentAppAssignment, ComposioAppCache\n\n\nlogger = logging.getLogger(__name__)\n\n\n# ---------------------------------------------------------------------------\n# Lazy imports (avoid circular deps)\n# ---------------------------------------------------------------------------\n\ndef get_monitoring_service():\n from core.services.monitoring_service import get_monitoring_service as _get_monitor\n return _get_monitor()\n\n\ndef get_unified_tool_executor(db_session: Session, workspace_dir: str = \"/tmp/automatos_workspace\"):\n from modules.tools import UnifiedToolExecutor\n return UnifiedToolExecutor(db_session, workspace_dir=workspace_dir)\n\n\n# ---------------------------------------------------------------------------\n# Enums & Dataclasses (KEPT — battle-tested)\n# ---------------------------------------------------------------------------\n\nclass AgentLifecycle(Enum):\n INITIALIZING = \"initializing\"\n ACTIVE = \"active\"\n BUSY = \"busy\"\n LEARNING = \"learning\"\n HIBERNATING = \"hibernating\"\n RETIRED = \"retired\"\n\n\n@dataclass\nclass ModelConfiguration:\n \"\"\"Complete model configuration for an agent (PRD-15).\"\"\"\n provider: str\n model_id: str\n temperature: float = 0.7\n max_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS\n top_p: float = 1.0\n frequency_penalty: float = 0.0\n presence_penalty: float = 0.0\n fallback_model_id: Optional[str] = None\n\n def to_dict(self) -> Dict[str, Any]:\n return {\n \"provider\": self.provider,\n \"model_id\": self.model_id,\n \"temperature\": self.temperature,\n \"max_tokens\": self.max_tokens,\n \"top_p\": self.top_p,\n \"frequency_penalty\": self.frequency_penalty,\n \"presence_penalty\": self.presence_penalty,\n \"fallback_model_id\": self.fallback_model_id,\n }\n\n @staticmethod\n def from_dict(data: Dict[str, Any]) -> \"ModelConfiguration\":\n from core.llm.defaults import DEFAULT_LLM_PROVIDER, DEFAULT_LLM_MODEL\n return ModelConfiguration(\n provider=data.get(\"provider\") or DEFAULT_LLM_PROVIDER,\n model_id=data.get(\"model_id\", DEFAULT_LLM_MODEL),\n temperature=data.get(\"temperature\", 0.7),\n max_tokens=data.get(\"max_tokens\", DEFAULT_MAX_OUTPUT_TOKENS),\n top_p=data.get(\"top_p\", 1.0),\n frequency_penalty=data.get(\"frequency_penalty\", 0.0),\n presence_penalty=data.get(\"presence_penalty\", 0.0),\n fallback_model_id=data.get(\"fallback_model_id\"),\n )\n\n @staticmethod\n def get_default() -> \"ModelConfiguration\":\n from core.llm.defaults import DEFAULT_LLM_PROVIDER, DEFAULT_LLM_MODEL\n return ModelConfiguration(provider=DEFAULT_LLM_PROVIDER, model_id=DEFAULT_LLM_MODEL)\n\n\n@dataclass\nclass AgentMetadata:\n \"\"\"User-defined agent metadata — completely flexible.\"\"\"\n name: str\n agent_type: str\n description: Optional[str] = None\n persona: Optional[str] = None\n skills: List[str] = field(default_factory=list)\n model_config: Optional[ModelConfiguration] = None\n # Deprecated — keep for backward compat\n preferred_model: Optional[str] = None\n temperature: Optional[float] = None\n max_tokens: Optional[int] = None\n context_window: Optional[int] = None\n custom_metadata: Dict[str, Any] = field(default_factory=dict)\n\n def get_model_config(self) -> ModelConfiguration:\n if self.model_config:\n return self.model_config\n if self.preferred_model:\n from core.llm.defaults import DEFAULT_LLM_PROVIDER\n provider = DEFAULT_LLM_PROVIDER\n if \"claude\" in self.preferred_model.lower():\n provider = \"anthropic\"\n elif \"llama\" in self.preferred_model.lower() or \"mistral\" in self.preferred_model.lower():\n provider = \"huggingface\"\n return ModelConfiguration(\n provider=provider,\n model_id=self.preferred_model,\n temperature=self.temperature or 0.7,\n max_tokens=self.max_tokens or DEFAULT_MAX_OUTPUT_TOKENS,\n )\n return ModelConfiguration.get_default()\n\n def get_llm_config(self) -> Dict[str, Any]:\n mc = self.get_model_config()\n return {\n \"provider\": mc.provider,\n \"model\": mc.model_id,\n \"temperature\": mc.temperature,\n \"max_tokens\": mc.max_tokens,\n \"context_window\": self.context_window or 8192,\n }\n\n\n@dataclass\nclass ResolvedKey:\n \"\"\"Result of API key resolution with source metadata.\"\"\"\n api_key: str\n source: str # \"byok\", \"platform\", \"env\"\n is_byok: bool\n provider: str = \"\"\n\n\n@dataclass\nclass AgentRuntime:\n \"\"\"Runtime representation of an agent.\"\"\"\n agent_id: int\n metadata: AgentMetadata\n llm_manager: LLMManager\n lifecycle_state: AgentLifecycle\n created_at: datetime\n execution_count: int = 0\n total_tokens_used: int = 0\n last_execution: Optional[datetime] = None\n performance_metrics: Dict[str, Any] = field(default_factory=dict)\n memory: List[Dict[str, Any]] = field(default_factory=list)\n tools: List[Dict[str, Any]] = field(default_factory=list) # Composio app assignments\n tool_executor: Any = None\n is_byok: bool = False\n resolved_provider: str = \"\"\n workspace_id: Optional[Any] = None\n\n def update_metrics(self, execution_time: float, tokens_used: int, success: bool):\n self.execution_count += 1\n self.total_tokens_used += tokens_used\n self.last_execution = datetime.now()\n if \"avg_execution_time\" not in self.performance_metrics:\n self.performance_metrics[\"avg_execution_time\"] = execution_time\n else:\n avg = self.performance_metrics[\"avg_execution_time\"]\n self.performance_metrics[\"avg_execution_time\"] = (\n (avg * (self.execution_count - 1) + execution_time) / self.execution_count\n )\n if \"success_count\" not in self.performance_metrics:\n self.performance_metrics[\"success_count\"] = 0\n if success:\n self.performance_metrics[\"success_count\"] += 1\n self.performance_metrics[\"success_rate\"] = (\n self.performance_metrics[\"success_count\"] / self.execution_count\n )\n\n\n# ---------------------------------------------------------------------------\n# AgentFactory\n# ---------------------------------------------------------------------------\n\nclass AgentFactory:\n \"\"\"\n Creates and manages user-defined agents.\n Pure execution layer — the orchestrator handles all prompt engineering.\n \"\"\"\n\n def __init__(self, db_session: Session = None):\n if db_session:\n self.db_session = db_session\n else:\n from core.database.database import SessionLocal\n self.db_session = SessionLocal()\n self.active_agents: Dict[int, AgentRuntime] = {}\n self.logger = logging.getLogger(__name__)\n\n # ==================================================================\n # LLM Config Resolution\n # ==================================================================\n\n def _get_default_llm_config_from_settings(self) -> Dict[str, Any]:\n \"\"\"Get default LLM config — used when agent has no model_config.\n\n The single source of truth is Settings > Orchestrator (Auto agent row).\n This fallback is only hit for non-Auto agents without their own config.\n We use safe defaults (OpenRouter/Gemini Flash) instead of reading from\n system_settings which may have stale cached values.\n \"\"\"\n try:\n from core.llm.manager import get_system_setting\n\n provider = get_system_setting(\"orchestrator_llm\", \"provider\")\n if not provider:\n provider = get_system_setting(\"orchestrator_llm\", \"llm_provider\")\n model = get_system_setting(\"orchestrator_llm\", \"model\")\n if not model:\n model = get_system_setting(\"orchestrator_llm\", \"llm_model\")\n from core.llm.defaults import DEFAULT_LLM_PROVIDER, DEFAULT_LLM_MODEL\n if not provider:\n provider = DEFAULT_LLM_PROVIDER\n if not model:\n model = DEFAULT_LLM_MODEL\n if not provider or not model:\n self.logger.warning(\"LLM provider/model not in system settings, using defaults\")\n return {\n \"provider\": DEFAULT_LLM_PROVIDER,\n \"model\": DEFAULT_LLM_MODEL,\n \"temperature\": 0.7,\n \"max_tokens\": DEFAULT_MAX_OUTPUT_TOKENS,\n \"context_window\": 8192,\n }\n\n # Lookup context window from LLM models registry\n context_window = 8192\n max_tokens = DEFAULT_MAX_OUTPUT_TOKENS\n try:\n from core.models import LLMModel\n llm_model = self.db_session.query(LLMModel).filter_by(model_id=model).first()\n if llm_model:\n context_window = llm_model.context_window\n max_tokens = llm_model.max_output_tokens\n except Exception as e:\n self.logger.warning(f\"Could not get context window from registry: {e}\")\n\n self.logger.info(f\"Using model from settings: {model} (context: {context_window})\")\n return {\n \"provider\": provider,\n \"model\": model,\n \"temperature\": 0.7,\n \"max_tokens\": max_tokens,\n \"context_window\": context_window,\n }\n except Exception as e:\n from core.llm.defaults import DEFAULT_LLM_PROVIDER, DEFAULT_LLM_MODEL\n self.logger.warning(f\"Could not get LLM config from settings: {e}, using defaults\")\n return {\n \"provider\": DEFAULT_LLM_PROVIDER,\n \"model\": DEFAULT_LLM_MODEL,\n \"temperature\": 0.7,\n \"max_tokens\": DEFAULT_MAX_OUTPUT_TOKENS,\n \"context_window\": 8192,\n }\n\n def _get_system_llm_config_from_settings(self) -> Dict[str, Any]:\n \"\"\"Get System LLM config — the cheap/fast tier for Light power mode.\"\"\"\n try:\n from core.llm.manager import get_system_setting\n from core.llm.defaults import DEFAULT_LLM_PROVIDER, DEFAULT_LLM_MODEL\n\n provider = get_system_setting(\"system_llm\", \"provider\") or get_system_setting(\"system_llm\", \"llm_provider\")\n model = get_system_setting(\"system_llm\", \"model\") or get_system_setting(\"system_llm\", \"llm_model\")\n\n if not provider:\n provider = DEFAULT_LLM_PROVIDER\n if not model:\n model = DEFAULT_LLM_MODEL\n\n context_window = 8192\n max_tokens = DEFAULT_MAX_OUTPUT_TOKENS\n try:\n from core.models import LLMModel\n llm_model = self.db_session.query(LLMModel).filter_by(model_id=model).first()\n if llm_model:\n context_window = llm_model.context_window\n max_tokens = llm_model.max_output_tokens\n except Exception as e:\n self.logger.warning(f\"Could not get context window for system_llm: {e}\")\n\n self.logger.info(f\"System LLM from settings: {model} (context: {context_window})\")\n return {\n \"provider\": provider,\n \"model\": model,\n \"temperature\": 0.7,\n \"max_tokens\": max_tokens,\n \"context_window\": context_window,\n }\n except Exception as e:\n from core.llm.defaults import DEFAULT_LLM_PROVIDER, DEFAULT_LLM_MODEL\n self.logger.warning(f\"Could not get system_llm config: {e}, using defaults\")\n return {\n \"provider\": DEFAULT_LLM_PROVIDER,\n \"model\": DEFAULT_LLM_MODEL,\n \"temperature\": 0.7,\n \"max_tokens\": DEFAULT_MAX_OUTPUT_TOKENS,\n \"context_window\": 8192,\n }\n\n def _model_max_output_tokens(self, model_id: Optional[str]) -> int:\n \"\"\"The selected model's own output ceiling from the LLM registry, or the\n canonical default if the model isn't found. Lets an agent that hasn't set\n an explicit Max Output Tokens default to what its model actually supports\n — never a hardcoded literal.\"\"\"\n if model_id and self.db_session is not None:\n try:\n from core.models import LLMModel\n m = self.db_session.query(LLMModel).filter_by(model_id=model_id).first()\n if m and m.max_output_tokens:\n return m.max_output_tokens\n except Exception as e:\n self.logger.warning(f\"max_output_tokens lookup failed for {model_id}: {e}\")\n return DEFAULT_MAX_OUTPUT_TOKENS\n\n def _resolve_provider_for_model(self, provider_str: str, model_id: str) -> tuple[str, str]:\n \"\"\"Auto-detect and correct provider-model mismatches.\n\n Returns (provider, model_id) — both may be rewritten.\n \"\"\"\n DIRECT_PROVIDERS = {\n \"openai\", \"anthropic\", \"google\", \"grok\", \"azure\", \"azure_openai\",\n \"aws_bedrock\", \"bedrock\", \"huggingface\", \"openrouter\",\n }\n # Known OpenRouter vendor prefixes for bare model-id recovery\n VENDOR_PREFIX_RULES = (\n (\"llama\", \"meta-llama\"),\n (\"qwen\", \"qwen\"),\n (\"deepseek\", \"deepseek-ai\"),\n (\"mistral\", \"mistralai\"),\n (\"mixtral\", \"mistralai\"),\n (\"gemma\", \"google\"),\n (\"phi\", \"microsoft\"),\n (\"yi-\", \"01-ai\"),\n )\n model_lower = model_id.lower()\n\n # Unknown/deprecated provider (e.g. legacy 'aiml') → route through OpenRouter\n if provider_str not in DIRECT_PROVIDERS:\n # Already slash-format: pass straight to OpenRouter\n if \"/\" in model_id:\n self.logger.info(\n f\"Provider '{provider_str}' not recognized, routing '{model_id}' through OpenRouter\"\n )\n return \"openrouter\", model_id\n # Bare id → infer vendor prefix so OpenRouter can resolve it\n for needle, vendor in VENDOR_PREFIX_RULES:\n if model_lower.startswith(needle):\n rewritten = f\"{vendor}/{model_id}\"\n self.logger.warning(\n f\"Provider '{provider_str}' unknown and model '{model_id}' has no slash; \"\n f\"rewriting to '{rewritten}' and routing through OpenRouter\"\n )\n return \"openrouter\", rewritten\n # Last resort: assume OpenRouter can handle it\n self.logger.warning(\n f\"Provider '{provider_str}' unknown; routing bare model '{model_id}' through OpenRouter\"\n )\n return \"openrouter\", model_id\n\n # Slash-format model IDs are OpenRouter marketplace models\n if \"/\" in model_id and provider_str != \"openrouter\":\n prefix = model_id.split(\"/\")[0].lower()\n if prefix == provider_str.lower() or prefix not in DIRECT_PROVIDERS:\n self.logger.info(\n f\"Slash-format model '{model_id}' with provider='{provider_str}' \"\n f\"detected as OpenRouter marketplace model. Routing through OpenRouter.\"\n )\n return \"openrouter\", model_id\n\n # Fix provider-model mismatches\n inferred = None\n if model_lower.startswith(\"gemini\"):\n inferred = \"google\"\n elif model_lower.startswith(\"claude\"):\n inferred = \"anthropic\"\n elif model_lower.startswith((\"gpt-\", \"o1\", \"o3\", \"o4\")):\n inferred = \"openai\"\n elif model_lower.startswith(\"grok\"):\n inferred = \"grok\"\n\n if inferred and inferred != provider_str and provider_str in DIRECT_PROVIDERS:\n self.logger.warning(\n f\"Provider-model mismatch: provider='{provider_str}' but model='{model_id}' \"\n f\"suggests '{inferred}'. Auto-correcting.\"\n )\n return inferred, model_id\n\n return provider_str, model_id\n\n async def _resolve_api_key(self, provider_name: str, agent_name: str = \"\", workspace_id=None) -> Optional[ResolvedKey]:\n \"\"\"\n Resolve API key: BYOK → credential store → env vars.\n \"\"\"\n from core.credentials.resolver import get_credential_resolver\n\n resolver = get_credential_resolver()\n\n # 1. Check BYOK\n if workspace_id:\n try:\n from core.models.workspaces import Workspace\n from core.models.core import UserApiKey\n from core.credentials.encryption import get_encryption_service\n\n workspace = self.db_session.query(Workspace).get(workspace_id)\n byok_overrides = (workspace.settings or {}).get(\"byok_overrides\", {}) if workspace else {}\n\n if byok_overrides.get(provider_name, False):\n byok_key = (\n self.db_session.query(UserApiKey)\n .filter(\n UserApiKey.workspace_id == workspace_id,\n UserApiKey.provider == provider_name,\n UserApiKey.is_active == True,\n )\n .order_by(UserApiKey.last_used_at.desc().nullslast())\n .first()\n )\n if byok_key:\n encryption = get_encryption_service()\n decrypted = encryption.decrypt(byok_key.encrypted_key)\n self.logger.info(f\"Resolved BYOK API key for '{provider_name}' workspace={workspace_id}\")\n return ResolvedKey(api_key=decrypted, source=\"byok\", is_byok=True, provider=provider_name)\n else:\n self.logger.info(f\"BYOK enabled but no active key for '{provider_name}', falling through\")\n except Exception as e:\n self.logger.error(f\"BYOK key lookup failed for {provider_name}: {e}\")\n\n # 1.5 Operator workspace key (PLATFORM_KEY_WORKSPACE_ID) — the pilot\n # \"platform key\" lane, read live from user_api_keys instead of a\n # duplicated credential-store copy that can drift (2026-07-30).\n from core.llm.workspace_keys import get_platform_workspace_key\n ws_key = get_platform_workspace_key(provider_name)\n if ws_key:\n self.logger.info(\n f\"Resolved platform key from operator workspace store for '{provider_name}' ({agent_name})\"\n )\n return ResolvedKey(api_key=ws_key, source=\"platform_workspace\", is_byok=False, provider=provider_name)\n\n # 2. Credential store (platform keys)\n cred_names = [\n f\"development_{provider_name}_api\",\n f\"development_{provider_name}\",\n f\"{provider_name}_api\",\n provider_name,\n ]\n for cred_name in cred_names:\n try:\n key = resolver.get_credential_field(cred_name, \"api_key\")\n if not key:\n key = resolver.get_credential_field(cred_name, \"api_token\")\n if key:\n self.logger.info(f\"Resolved platform API key from credential '{cred_name}' for {agent_name}\")\n return ResolvedKey(api_key=key, source=\"platform\", is_byok=False, provider=provider_name)\n except Exception:\n continue\n\n # 3. Config env vars\n from config import config as _cfg\n config_map = {\n \"openai\": _cfg.OPENAI_API_KEY,\n \"anthropic\": _cfg.ANTHROPIC_API_KEY,\n \"google\": _cfg.GOOGLE_API_KEY,\n \"openrouter\": _cfg.OPENROUTER_API_KEY,\n \"grok\": _cfg.XAI_API_KEY,\n \"azure\": _cfg.AZURE_OPENAI_API_KEY,\n \"azure_openai\": _cfg.AZURE_OPENAI_API_KEY,\n \"aws_bedrock\": _cfg.AWS_ACCESS_KEY_ID,\n \"bedrock\": _cfg.AWS_ACCESS_KEY_ID,\n }\n key = config_map.get(provider_name)\n if key:\n self.logger.info(f\"Using config API key for {provider_name} for {agent_name}\")\n return ResolvedKey(api_key=key, source=\"env\", is_byok=False, provider=provider_name)\n\n return None\n\n async def _create_llm_manager(self, model_config: ModelConfiguration, agent_name: str = \"\", workspace_id=None) -> Tuple[LLMManager, ResolvedKey]:\n \"\"\"Create LLM manager with API key resolution (PRD-15, PRD-54).\"\"\"\n from core.llm import LLMConfig, LLMProvider as LLMProviderEnum\n\n provider_map = {\n \"openai\": LLMProviderEnum.OPENAI,\n \"anthropic\": LLMProviderEnum.ANTHROPIC,\n \"google\": LLMProviderEnum.GOOGLE,\n \"openrouter\": LLMProviderEnum.OPENROUTER,\n \"grok\": LLMProviderEnum.GROK,\n \"huggingface\": LLMProviderEnum.HUGGINGFACE,\n \"azure\": LLMProviderEnum.AZURE,\n \"azure_openai\": LLMProviderEnum.AZURE,\n \"aws_bedrock\": LLMProviderEnum.AWS_BEDROCK,\n \"bedrock\": LLMProviderEnum.AWS_BEDROCK,\n }\n\n effective_provider, effective_model_id = self._resolve_provider_for_model(\n model_config.provider, model_config.model_id\n )\n if effective_provider not in provider_map:\n raise ValueError(f\"Unsupported provider: {effective_provider}\")\n\n provider = provider_map[effective_provider]\n resolved = await self._resolve_api_key(effective_provider, agent_name, workspace_id=workspace_id)\n if not resolved:\n raise ValueError(\n f\"No API key available for {effective_provider}. \"\n f\"Add one in Settings → API Keys, or set platform fallback \"\n f\"({effective_provider.upper()}_API_KEY env var on the API service).\"\n )\n\n # PRD-222 US-005 — trial routing gate at the LLM key-resolution choke point.\n # BYOK provably bypasses it (resolved.is_byok short-circuits). For a non-BYOK\n # call on a trial workspace: block an exhausted trial with the typed error,\n # else route on the platform key and PIN the model to the trial allowlist.\n # No-op for non-trial workspaces and system calls (workspace_id None).\n trial_routed = False\n if workspace_id and not resolved.is_byok:\n from services.trial_ledger import (\n resolve_trial_routing, TrialExhaustedError,\n ACTION_BLOCKED, ACTION_PLATFORM_TRIAL,\n )\n from core.models.workspaces import Workspace\n\n _ws = self.db_session.query(Workspace).get(workspace_id)\n routing = resolve_trial_routing(_ws, effective_model_id, is_byok=False)\n if routing.action == ACTION_BLOCKED:\n self.logger.info(f\"[Trial] Blocking exhausted trial workspace {workspace_id}\")\n raise TrialExhaustedError()\n if routing.action == ACTION_PLATFORM_TRIAL:\n trial_routed = True\n if routing.model and routing.model != effective_model_id:\n self.logger.info(\n f\"[Trial] Pinning off-allowlist model {effective_model_id} -> \"\n f\"{routing.model} for workspace {workspace_id}\"\n )\n effective_provider, effective_model_id = self._resolve_provider_for_model(\n model_config.provider, routing.model\n )\n if effective_provider not in provider_map:\n raise ValueError(f\"Unsupported trial provider: {effective_provider}\")\n provider = provider_map[effective_provider]\n resolved = await self._resolve_api_key(\n effective_provider, agent_name, workspace_id=workspace_id\n )\n if not resolved:\n raise ValueError(\n f\"No platform key available for trial model {effective_model_id}.\"\n )\n\n llm_config = LLMConfig(\n provider=provider,\n model=effective_model_id,\n temperature=model_config.temperature,\n max_tokens=model_config.max_tokens,\n api_key=resolved.api_key,\n top_p=getattr(model_config, 'top_p', None),\n frequency_penalty=getattr(model_config, 'frequency_penalty', None),\n presence_penalty=getattr(model_config, 'presence_penalty', None),\n stop=getattr(model_config, 'stop', None),\n timeout=getattr(model_config, 'timeout', None),\n )\n\n # Bedrock uses IAM auth\n if effective_provider in (\"aws_bedrock\", \"bedrock\"):\n llm_config.secret_key = config.AWS_SECRET_ACCESS_KEY\n llm_config.base_url = config.AWS_REGION or \"us-east-1\"\n\n self.logger.info(\n f\"Creating LLM manager for {agent_name or 'agent'}: \"\n f\"provider={effective_provider}, model={model_config.model_id}, source={resolved.source}\"\n )\n\n manager = LLMManager(\n config=llm_config,\n workspace_id=workspace_id,\n agent_id=None,\n is_byok=resolved.is_byok,\n trial=trial_routed,\n )\n return manager, resolved\n\n async def _verify_llm_connection(self, llm_manager: LLMManager) -> Dict[str, Any]:\n \"\"\"Verify LLM connection with minimal test.\"\"\"\n try:\n start_time = time.time()\n messages = [{\"role\": \"user\", \"content\": \"Respond with 'OK' to confirm connection.\"}]\n response = await llm_manager.generate_response(messages)\n response_time = time.time() - start_time\n if response and response.content:\n return {\n \"success\": True,\n \"response_time\": response_time,\n \"tokens_used\": response.usage.get(\"total_tokens\", 0) if response.usage else 0,\n }\n return {\"success\": False, \"error\": \"No response from LLM\"}\n except Exception as e:\n return {\"success\": False, \"error\": str(e)}\n\n # ==================================================================\n # Agent Lifecycle\n # ==================================================================\n\n async def create_agent(\n self,\n metadata: Union[AgentMetadata, Dict[str, Any]],\n auto_verify: bool = True,\n ) -> AgentRuntime:\n \"\"\"Create an agent from user-defined metadata.\"\"\"\n start_time = time.time()\n\n if isinstance(metadata, dict):\n model_config = None\n if \"model_config\" in metadata:\n model_config = ModelConfiguration.from_dict(metadata[\"model_config\"])\n metadata = AgentMetadata(\n name=metadata.get(\"name\", \"Unnamed Agent\"),\n agent_type=metadata.get(\"type\", \"generic\"),\n description=metadata.get(\"description\"),\n skills=metadata.get(\"skills\", []),\n model_config=model_config,\n preferred_model=metadata.get(\"preferred_model\"),\n temperature=metadata.get(\"temperature\"),\n max_tokens=metadata.get(\"max_tokens\"),\n context_window=metadata.get(\"context_window\"),\n custom_metadata=metadata.get(\"metadata\", {}),\n )\n\n model_config = metadata.get_model_config()\n\n db_agent = Agent(\n name=metadata.name,\n description=metadata.description or f\"User-defined {metadata.agent_type} agent\",\n agent_type=metadata.agent_type,\n status=AgentLifecycle.INITIALIZING.value,\n configuration={\n \"skills\": metadata.skills,\n \"llm_config\": metadata.get_llm_config(),\n \"custom_metadata\": metadata.custom_metadata,\n },\n model_config=model_config.to_dict(),\n priority_level=PriorityLevel.MEDIUM.value,\n max_concurrent_tasks=5,\n auto_start=False,\n created_by=\"agent_factory\",\n )\n\n self.db_session.add(db_agent)\n self.db_session.commit()\n\n try:\n llm_manager, resolved = await self._create_llm_manager(model_config, db_agent.name, workspace_id=db_agent.workspace_id)\n\n if auto_verify:\n verification_result = await self._verify_llm_connection(llm_manager)\n if not verification_result[\"success\"] and model_config.fallback_model_id:\n self.logger.warning(\n f\"Primary model '{model_config.model_id}' failed, trying fallback '{model_config.fallback_model_id}'\"\n )\n fallback_config = ModelConfiguration(\n provider=model_config.provider,\n model_id=model_config.fallback_model_id,\n temperature=model_config.temperature,\n max_tokens=model_config.max_tokens,\n )\n llm_manager, resolved = await self._create_llm_manager(fallback_config, db_agent.name, workspace_id=db_agent.workspace_id)\n verification_result = await self._verify_llm_connection(llm_manager)\n if verification_result[\"success\"]:\n db_agent.model_config = fallback_config.to_dict()\n self.db_session.commit()\n\n if not verification_result[\"success\"]:\n self.db_session.delete(db_agent)\n self.db_session.commit()\n raise Exception(f\"LLM verification failed: {verification_result['error']}\")\n\n agent_tools = await self._load_agent_tools(db_agent.id)\n\n agent_runtime = AgentRuntime(\n agent_id=db_agent.id,\n metadata=metadata,\n llm_manager=llm_manager,\n lifecycle_state=AgentLifecycle.ACTIVE,\n created_at=datetime.now(),\n tools=agent_tools,\n is_byok=resolved.is_byok,\n resolved_provider=resolved.provider,\n workspace_id=db_agent.workspace_id,\n )\n\n db_agent.status = AgentLifecycle.ACTIVE.value\n self.db_session.commit()\n self.active_agents[db_agent.id] = agent_runtime\n\n self.logger.info(\n f\"Agent '{metadata.name}' (type: {metadata.agent_type}) created in {time.time() - start_time:.2f}s\"\n )\n return agent_runtime\n\n except Exception as e:\n self.logger.error(f\"Failed to create agent: {e}\")\n if db_agent.id:\n self.db_session.delete(db_agent)\n self.db_session.commit()\n raise\n\n async def activate_agent(\n self,\n agent_id: int,\n workspace_dir: str = \"/tmp/automatos_workspace\",\n use_orchestrator_llm: bool = False,\n force_llm_tier: Optional[str] = None,\n ) -> Optional[AgentRuntime]:\n \"\"\"Load an agent from database and activate it in runtime.\n\n PRD-137 Fix #2: parameter renamed from ``use_system_llm`` to\n ``use_orchestrator_llm`` to match what the code actually does.\n\n ``force_llm_tier``: when set to ``\"orchestrator_llm\"`` or\n ``\"system_llm\"``, overrides the agent's own model with the\n corresponding tier from system_settings. Used by mission\n power modes (Light → system_llm, Max → orchestrator_llm).\n \"\"\"\n try:\n if agent_id in self.active_agents:\n self.logger.info(f\"Agent {agent_id} already active in runtime\")\n return self.active_agents[agent_id]\n\n from modules.agents.queries import get_agent_with_context\n\n db_agent = get_agent_with_context(self.db_session, agent_id)\n if not db_agent:\n self.logger.error(f\"Agent {agent_id} not found in database\")\n return None\n\n # Resolve LLM config: agent's own model_config → orchestrator-tier defaults\n agent_model_config = db_agent.model_config or {}\n agent_config = db_agent.configuration or {}\n agent_llm_config = agent_config.get(\"llm_config\") or {}\n\n agent_has_model = agent_model_config.get(\"model_id\") and agent_model_config.get(\"provider\")\n\n if force_llm_tier == \"system_llm\":\n tier_config = self._get_system_llm_config_from_settings()\n llm_config_dict = {\n \"provider\": tier_config.get(\"provider\"),\n \"model\": tier_config.get(\"model\"),\n \"temperature\": agent_llm_config.get(\"temperature\", tier_config.get(\"temperature\", 0.7)),\n \"max_tokens\": tier_config.get(\"max_tokens\", DEFAULT_MAX_OUTPUT_TOKENS),\n }\n self.logger.info(f\"Agent {agent_id} using LLM: {llm_config_dict.get('provider')}/{llm_config_dict.get('model')} (force_llm_tier=system_llm)\")\n elif force_llm_tier == \"orchestrator_llm\" or use_orchestrator_llm:\n reason = f\"force_llm_tier={force_llm_tier}\" if force_llm_tier else \"use_orchestrator_llm=True\"\n orchestrator_llm_config = self._get_default_llm_config_from_settings()\n llm_config_dict = {\n \"provider\": orchestrator_llm_config.get(\"provider\"),\n \"model\": orchestrator_llm_config.get(\"model\"),\n \"temperature\": agent_llm_config.get(\"temperature\", orchestrator_llm_config.get(\"temperature\", 0.7)),\n \"max_tokens\": agent_llm_config.get(\"max_tokens\", orchestrator_llm_config.get(\"max_tokens\", DEFAULT_MAX_OUTPUT_TOKENS)),\n }\n self.logger.info(f\"Agent {agent_id} using LLM: {llm_config_dict.get('provider')}/{llm_config_dict.get('model')} ({reason})\")\n elif agent_has_model:\n llm_config_dict = {\n \"provider\": agent_model_config[\"provider\"],\n \"model\": agent_model_config[\"model_id\"],\n \"temperature\": agent_model_config.get(\"temperature\", 0.7),\n \"max_tokens\": agent_model_config.get(\"max_tokens\") or self._model_max_output_tokens(agent_model_config.get(\"model_id\")),\n }\n self.logger.info(f\"Agent {agent_id} using LLM: {llm_config_dict['provider']}/{llm_config_dict['model']} (agent model_config)\")\n else:\n orchestrator_llm_config = self._get_default_llm_config_from_settings()\n llm_config_dict = {\n \"provider\": orchestrator_llm_config.get(\"provider\"),\n \"model\": orchestrator_llm_config.get(\"model\"),\n \"temperature\": agent_llm_config.get(\"temperature\", orchestrator_llm_config.get(\"temperature\", 0.7)),\n \"max_tokens\": agent_llm_config.get(\"max_tokens\", orchestrator_llm_config.get(\"max_tokens\", DEFAULT_MAX_OUTPUT_TOKENS)),\n }\n self.logger.info(f\"Agent {agent_id} using LLM: {llm_config_dict.get('provider')}/{llm_config_dict.get('model')} (no agent model_config)\")\n\n # PRD-223 W0: the orchestrator seat is policy-gated. This is the\n # last-mile check — every writer path (settings route, per-agent\n # route, chat tool, seeds) converges here, so a quarantined model\n # cannot reach Auto's chair no matter how it was written. On a\n # block, degrade to a trusted brain (orchestrator default, then\n # platform default) — never a dead chat.\n is_orchestrator_seat = (\n use_orchestrator_llm\n or force_llm_tier == \"orchestrator_llm\"\n or (\n getattr(db_agent, \"name\", \"\") == \"Auto\"\n and bool(getattr(db_agent, \"is_system_agent\", False))\n )\n )\n if is_orchestrator_seat:\n from core.llm.model_policy import check_model_for_agent, check_orchestrator_model\n\n allowed, reason = check_model_for_agent(\n self.db_session,\n getattr(db_agent, \"workspace_id\", None),\n llm_config_dict.get(\"model\"),\n orchestrator_seat=True,\n )\n if not allowed:\n blocked_model = llm_config_dict.get(\"model\")\n fallback = self._get_default_llm_config_from_settings()\n fb_ok, _ = check_orchestrator_model(fallback.get(\"model\"))\n if not fb_ok:\n from core.llm.defaults import DEFAULT_LLM_PROVIDER, DEFAULT_LLM_MODEL\n fallback = {\"provider\": DEFAULT_LLM_PROVIDER, \"model\": DEFAULT_LLM_MODEL}\n llm_config_dict[\"provider\"] = fallback.get(\"provider\")\n llm_config_dict[\"model\"] = fallback.get(\"model\")\n self.logger.critical(\n f\"[model-policy] Agent {agent_id} orchestrator-seat model \"\n f\"'{blocked_model}' BLOCKED ({reason}) — substituting \"\n f\"'{llm_config_dict['provider']}/{llm_config_dict['model']}' (PRD-223)\"\n )\n\n from core.llm.defaults import DEFAULT_LLM_PROVIDER, DEFAULT_LLM_MODEL\n provider_str = llm_config_dict.get(\"provider\") or DEFAULT_LLM_PROVIDER\n model_id_str = llm_config_dict.get(\"model\", DEFAULT_LLM_MODEL)\n provider_str, model_id_str = self._resolve_provider_for_model(provider_str, model_id_str)\n\n try:\n provider = LLMProvider(provider_str)\n except ValueError:\n # Safety net: any provider that survived the resolver without being\n # normalised (e.g. stale enum value) is forced through OpenRouter.\n self.logger.warning(\n f\"Provider '{provider_str}' is not a valid LLMProvider enum; forcing OpenRouter\"\n )\n provider_str = \"openrouter\"\n if \"/\" not in model_id_str:\n original = (llm_config_dict.get(\"provider\") or \"\").lower()\n if original and original != provider_str:\n model_id_str = f\"{original}/{model_id_str}\"\n provider = LLMProvider(provider_str)\n resolved = await self._resolve_api_key(provider_str, db_agent.name, workspace_id=db_agent.workspace_id)\n\n # Fallback: if direct provider has no credential, try OpenRouter\n if (not resolved or not resolved.api_key) and provider_str != \"openrouter\":\n self.logger.warning(\n f\"No credential for provider '{provider_str}' — falling back to OpenRouter for '{model_id_str}'\"\n )\n provider_str = \"openrouter\"\n if \"/\" not in model_id_str:\n original_provider = llm_config_dict.get(\"provider\", \"\").lower()\n model_id_str = f\"{original_provider}/{model_id_str}\"\n provider = LLMProvider(provider_str)\n resolved = await self._resolve_api_key(provider_str, db_agent.name, workspace_id=db_agent.workspace_id)\n\n llm_config = LLMConfig(\n provider=provider,\n model=model_id_str,\n temperature=llm_config_dict.get(\"temperature\", 0.7),\n max_tokens=llm_config_dict.get(\"max_tokens\", DEFAULT_MAX_OUTPUT_TOKENS),\n api_key=resolved.api_key if resolved else None,\n top_p=llm_config_dict.get(\"top_p\"),\n frequency_penalty=llm_config_dict.get(\"frequency_penalty\"),\n presence_penalty=llm_config_dict.get(\"presence_penalty\"),\n stop=llm_config_dict.get(\"stop\"),\n timeout=llm_config_dict.get(\"timeout\"),\n )\n llm_manager = LLMManager(\n config=llm_config,\n workspace_id=db_agent.workspace_id,\n agent_id=agent_id,\n is_byok=resolved.is_byok if resolved else False,\n )\n\n persona_text = \"\"\n if getattr(db_agent, \"use_custom_persona\", False) and getattr(db_agent, \"custom_persona_prompt\", None):\n persona_text = db_agent.custom_persona_prompt\n elif getattr(db_agent, \"persona\", None) and getattr(db_agent.persona, \"system_prompt\", None):\n persona_text = db_agent.persona.system_prompt\n\n metadata = AgentMetadata(\n name=db_agent.name,\n agent_type=db_agent.agent_type,\n description=db_agent.description,\n persona=persona_text or None,\n skills=agent_config.get(\"skills\", []),\n custom_metadata=agent_config.get(\"custom_metadata\", {}),\n )\n\n agent_tools = await self._load_agent_tools(agent_id)\n\n agent_runtime = AgentRuntime(\n agent_id=agent_id,\n metadata=metadata,\n llm_manager=llm_manager,\n lifecycle_state=AgentLifecycle.ACTIVE,\n created_at=datetime.now(),\n tools=agent_tools,\n tool_executor=get_unified_tool_executor(self.db_session, workspace_dir or \"/tmp/automatos_workspace\"),\n is_byok=resolved.is_byok if resolved else False,\n resolved_provider=resolved.provider if resolved else provider_str,\n workspace_id=db_agent.workspace_id,\n )\n\n self.active_agents[agent_id] = agent_runtime\n db_agent.status = AgentLifecycle.ACTIVE.value\n self.db_session.commit()\n\n self.logger.info(f\"Activated agent {agent_id} ({db_agent.name}) with {llm_config_dict.get('model')}\")\n return agent_runtime\n\n except Exception as e:\n self.logger.error(f\"Failed to activate agent {agent_id}: {e}\")\n return None\n\n # ==================================================================\n # execute_with_prompt — CLEAN REWRITE\n # ==================================================================\n # ONE tool source: get_tools_for_agent() from tool_router.py\n # Tool loop with max_tool_iterations (default 10)\n # No hardcoded schemas, no legacy JSON actions, no mid-execution discovery\n\n async def execute_with_prompt(\n self,\n agent: Union[int, AgentRuntime],\n prompt: str,\n system_prompt: Optional[str] = None,\n context: Optional[Dict[str, Any]] = None,\n use_memory: bool = True,\n max_retries: int = 2,\n max_tool_iterations: int = 10,\n composio_action_names: Optional[set] = None,\n context_mode: Optional[str] = None, # ContextMode enum value — overrides default TASK_EXECUTION\n attachment_ids: Optional[List[str]] = None, # PRD-127: ephemeral attachments\n # Legacy params — accepted but ignored (callers may still pass them)\n enable_actions: bool = True,\n action_executor: Optional[Any] = None,\n required_tools: Optional[List[str]] = None,\n workspace_dir: Optional[str] = None,\n ) -> Dict[str, Any]:\n \"\"\"\n Execute a task with orchestrator-provided prompt.\n\n Tools are determined by agent assignments via get_tools_for_agent() —\n the single source of truth. No required_tools parameter needed.\n \"\"\"\n start_time = time.time()\n\n # --- Resolve agent runtime ---\n if isinstance(agent, int):\n agent_runtime = self.active_agents.get(agent)\n if not agent_runtime:\n self.logger.info(f\"Agent {agent} not in runtime, activating...\")\n agent_runtime = await self.activate_agent(agent, workspace_dir=workspace_dir or \"/tmp/automatos_workspace\")\n if not agent_runtime:\n return {\"status\": \"error\", \"error\": f\"Agent {agent} could not be activated\"}\n else:\n agent_runtime = agent\n\n agent_name = agent_runtime.metadata.name\n agent_id = agent_runtime.agent_id\n\n self.logger.info(\"=\" * 80)\n self.logger.info(f\"EXECUTING AGENT: {agent_name} (ID: {agent_id}, Type: {agent_runtime.metadata.agent_type})\")\n self.logger.info(\"=\" * 80)\n\n agent_runtime.lifecycle_state = AgentLifecycle.BUSY\n\n try:\n # --- Build messages ---\n messages = []\n\n # System prompt: explicit OR ContextService\n context_result = None # Populated when ContextService builds the prompt\n\n if system_prompt:\n messages.append({\"role\": \"system\", \"content\": system_prompt})\n else:\n # --- ContextService path (the single prompt builder) ---\n from modules.agents.queries import get_agent_with_context\n from modules.context import ContextService, ContextMode\n\n mode = ContextMode(context_mode) if context_mode else ContextMode.TASK_EXECUTION\n db_agent = get_agent_with_context(self.db_session, agent_runtime.agent_id)\n if db_agent:\n # Note: attachment_ids intentionally NOT passed here. AgentFactory\n # builds its own messages list and only consumes context_result's\n # system_prompt/tools, so any attachment parts ContextService injects\n # into context_result.messages would be discarded. Resolution happens\n # below after the user prompt is appended — single chokepoint for\n # both system_prompt-bypass and ContextService paths.\n context_result = await ContextService(self.db_session).build_context(\n mode=mode,\n agent=db_agent,\n workspace_id=agent_runtime.workspace_id,\n task_description=prompt,\n # Narrow the dispatcher enum to task-relevant actions —\n # without a query this lane shipped all 137 every run.\n query=prompt,\n )\n # PRD-201 S4: carry the assembler's cache-stable prefix on the\n # system message so the Anthropic client can place its\n # cache_control breakpoint there. Non-Anthropic providers\n # ignore the extra key.\n messages.append({\n \"role\": \"system\",\n \"content\": context_result.system_prompt,\n \"cache_prefix\": context_result.cacheable_prefix,\n })\n else:\n # Last resort — should not happen\n messages.append({\"role\": \"system\", \"content\": f\"You are agent {agent_runtime.agent_id}.\"})\n\n # Short-term memory\n if use_memory and agent_runtime.memory:\n for mem in agent_runtime.memory[-3:]:\n if \"user_prompt\" in mem:\n messages.append({\"role\": \"user\", \"content\": mem[\"user_prompt\"]})\n if \"assistant_response\" in mem:\n messages.append({\"role\": \"assistant\", \"content\": mem[\"assistant_response\"]})\n\n # Preserve original prompt for Composio hint generation\n original_user_prompt = prompt\n\n # --- Build tools ---\n if context_result is not None:\n # ContextService already loaded tools via ToolsSection\n tool_schemas = list(context_result.tools)\n else:\n # Explicit system_prompt path: load tools directly\n from modules.tools.tool_router import get_tools_for_agent_async\n\n # PRD-138 US-009: pass the user prompt so the dispatcher\n # enum narrows in lockstep with the prompt summary. Falls\n # back to the full enum if SEMANTIC_TOOL_ROUTING is off,\n # the prompt is empty, or ranking fails. Awaited on this\n # loop — never bridged through a helper thread.\n tool_schemas = await get_tools_for_agent_async(\n agent_id=agent_runtime.agent_id,\n db_session=self.db_session,\n workspace_id=agent_runtime.workspace_id,\n query=prompt,\n )\n\n # Composio hint injection (enriches composio_execute with action enum + hints)\n workspace_id = agent_runtime.workspace_id\n composio_apps = [t for t in (agent_runtime.tools or []) if t.get(\"provider\") == \"Composio\"]\n if composio_apps:\n if composio_action_names:\n # Recipe path: pre-resolved action names\n self._inject_composio_recipe_hints(\n tool_schemas, messages, composio_apps, composio_action_names,\n )\n else:\n # Default path: hint service\n self._inject_composio_hints(\n tool_schemas, messages, agent_runtime, original_user_prompt, workspace_id,\n )\n\n all_tool_names = [t[\"function\"][\"name\"] for t in tool_schemas]\n self.logger.info(f\"Providing {len(tool_schemas)} tools to agent: {all_tool_names}\")\n\n # Add user prompt\n messages.append({\"role\": \"user\", \"content\": prompt})\n\n # PRD-127: Single chokepoint for attachment resolution in non-chat LLM calls.\n # Works for both branches above (system_prompt bypass + ContextService path)\n # because it runs on the final messages list. Any entry point that forwards\n # attachment_ids through to execute_with_prompt gets multimodal support for\n # free — no per-handler wiring, no duplicated vision checks.\n if attachment_ids:\n try:\n from uuid import UUID as _UUID\n from modules.attachments.resolver import (\n AttachmentResolver,\n VisionNotSupportedError,\n inject_parts_into_last_user_message,\n )\n _llm_cfg = getattr(agent_runtime.llm_manager, \"config\", None)\n _model_id = getattr(_llm_cfg, \"model\", None) if _llm_cfg else None\n _resolver = AttachmentResolver(db_session=self.db_session)\n _parts, _att_failures = await _resolver.resolve(\n attachment_ids=[_UUID(a) for a in attachment_ids],\n workspace_id=_UUID(str(agent_runtime.workspace_id)),\n model_id=_model_id or \"\",\n )\n if _parts:\n messages = inject_parts_into_last_user_message(messages, _parts)\n self.logger.info(\n f\"[PRD-127] AgentFactory resolved {len(_parts)} attachment parts \"\n f\"from {len(attachment_ids)} ids for agent {agent_id}\"\n )\n if _att_failures:\n # PRD-223 S0.3: marker part already rides in _parts.\n self.logger.warning(\n f\"[PRD-223] AgentFactory: {len(_att_failures)} attachment(s) \"\n f\"unavailable for agent {agent_id}\"\n )\n except VisionNotSupportedError as _vne:\n self.logger.warning(\n f\"[PRD-127] AgentFactory vision not supported for agent {agent_id}: {_vne}\"\n )\n except Exception as _att_err:\n self.logger.error(\n f\"[PRD-127] AgentFactory attachment resolution failed: {_att_err}\",\n exc_info=True,\n )\n\n # --- Execute with retries ---\n last_error = None\n messages_snapshot = list(messages) # snapshot before retry loop\n\n # PRD-201 S5: generalise ContextGuard beyond chat — missions and\n # heartbeats now get the same model-aware compaction the chat path\n # already had, run once before the tool loop. Guarded: a compaction\n # fault must never fail the run.\n try:\n from core.context_guard import ContextGuard as _ContextGuard\n _guard_model = getattr(\n getattr(agent_runtime.llm_manager, \"config\", None), \"model\", None\n ) or \"\"\n _compacted, _was_compacted, _guarded_tools = await _ContextGuard().check_and_compact(\n messages=messages_snapshot,\n model_name=_guard_model,\n llm_manager=agent_runtime.llm_manager,\n workspace_id=str(agent_runtime.workspace_id),\n agent_id=agent_runtime.agent_id,\n db_session=self.db_session,\n tools=tool_schemas,\n )\n if _was_compacted:\n messages_snapshot = _compacted\n self.logger.info(\n \"[PRD-201 S5] ContextGuard compacted headless context for agent %s\",\n agent_id,\n )\n if _guarded_tools is None and tool_schemas:\n tool_schemas = []\n self.logger.warning(\n \"[PRD-201 S5] ContextGuard dropped tools (over budget) for agent %s\",\n agent_id,\n )\n except Exception as _cg_err:\n self.logger.debug(\"[PRD-201 S5] ContextGuard skipped: %s\", _cg_err)\n\n from core.llm.request_scope import headless_run\n\n for attempt in range(max(1, max_retries)):\n try:\n messages = list(messages_snapshot) # reset each attempt\n # PRD-201 S5: mark the Anthropic call as a headless run so the\n # client seam emits context-editing + the memory tool.\n with headless_run():\n response = await agent_runtime.llm_manager.generate_response(messages, tools=tool_schemas)\n execution_time = time.time() - start_time\n\n # --- Converged tool loop (PRD-142 W3-S4 / G6): same executor as chat ---\n from modules.tools.execution.tool_loop import ToolLoopExecutor\n\n async def _agent_llm_cb(msgs, tls):\n # PRD-201 S5: keep the headless scope across the tool loop's\n # re-invocations so every iteration emits context-editing.\n with headless_run():\n return await agent_runtime.llm_manager.generate_response(msgs, tools=tls)\n\n # PRD-178 S1 (F020): thread the calling task's field context\n # so PlatformActionExecutor binds field tools to THIS run's\n # field_id — never a `.first()` guess over concurrent missions.\n _field_caller_context = (\n {\"field_context\": context}\n if context and context.get(\"field_id\") else None\n )\n # PRD-193 S1/S4 (P2-12): a confirmation ask fired on a\n # board-task run must link its grant back to the task\n # (details.board_task_id), so the grant API's existing\n # board re-queue resumes the run into the now-active\n # grant. api/board_tasks passes source/task_id in context.\n if (\n context\n and context.get(\"source\") == \"board_task\"\n and context.get(\"task_id\") is not None\n ):\n _field_caller_context = {\n **(_field_caller_context or {}),\n \"board_task_id\": context.get(\"task_id\"),\n }\n\n async def _agent_tool_cb(name, args, call_id, ws_id):\n # PRD-201 S5: the Anthropic memory tool is client-executed —\n # run it against the durable store with the /memories\n # traversal guard, never through the platform tool registry.\n if name == \"memory\":\n from modules.memory.memory_tool import (\n DurableMemoryStoreBackend,\n MemoryToolBackend,\n )\n _mem_result = await MemoryToolBackend(\n DurableMemoryStoreBackend(), workspace_id=ws_id\n ).handle(args or {})\n return {\n \"success\": True,\n \"llm_context\": _mem_result,\n \"raw_result\": _mem_result,\n }\n # SLACK empty-params guard.\n if not args and \"SLACK\" in name:\n return {\n \"success\": False,\n \"llm_context\": json.dumps(\n {\"error\": \"Empty parameters for tool requiring input\"}\n ),\n }\n try:\n # PRD-192 S3: turn-level budget estimate on the\n # agent lane (same shared estimator as chat) so\n # the policy gate prices this call.\n from core.context_guard import estimate_turn_budget\n _turn_budget = estimate_turn_budget(\n agent_runtime.llm_manager, messages\n )\n _cb_caller_context = (\n {**(_field_caller_context or {}), **_turn_budget}\n or None\n )\n raw = await agent_runtime.tool_executor.execute_tool(\n tool_name=name,\n parameters=args,\n agent_id=agent_runtime.agent_id,\n workspace_id=ws_id,\n caller_context=_cb_caller_context,\n )\n return {\n \"success\": True,\n \"llm_context\": json.dumps(raw),\n \"raw_result\": raw,\n }\n except Exception as tool_err:\n self.logger.error(f\" [TRACE] {name} failed: {tool_err}\")\n return {\n \"success\": False,\n \"llm_context\": json.dumps({\"error\": str(tool_err)}),\n }\n\n loop_executor = ToolLoopExecutor(\n llm_callback=_agent_llm_cb,\n tool_callback=_agent_tool_cb,\n max_iterations=max_tool_iterations,\n content_truncate_tokens=0, # agent path historically did not truncate\n )\n loop_result = await loop_executor.run(\n initial_response=response,\n messages=messages,\n tools=tool_schemas,\n workspace_id=workspace_id,\n )\n response = loop_result.response\n tool_iteration = loop_result.iterations\n execution_time = time.time() - start_time\n\n # Recover the last round's tool messages for empty-response synthesis.\n tool_results: List[Dict[str, Any]] = []\n for msg in reversed(messages):\n if msg.get(\"role\") == \"tool\":\n tool_results.append(msg)\n elif tool_results:\n break\n tool_results.reverse()\n\n if loop_result.max_iterations_reached:\n self.logger.warning(f\"Hit max tool iterations ({max_tool_iterations}) for agent {agent_id}\")\n\n # Synthesize empty response from tool results\n if response and (not response.content or not response.content.strip()):\n if tool_results:\n tool_summary = \"\\n\\n\".join([\n f\"**{tr['name']}**: {tr['content'][:500]}\"\n for tr in tool_results\n if tr.get(\"role\") == \"tool\"\n ])\n response.content = f\"Based on the tool results:\\n\\n{tool_summary}\"\n\n # Handle truncation: continue generating if output was cut off\n max_continuations = 2\n continuation = 0\n while (\n response\n and getattr(response, \"finish_reason\", None) == \"length\"\n and response.content\n and continuation < max_continuations\n ):\n continuation += 1\n completion_tokens = (response.usage or {}).get(\"completion_tokens\", 0)\n self.logger.info(\n \"Output truncated for agent %s (continuation %d/%d, %d tokens so far). \"\n \"Requesting continuation...\",\n agent_id, continuation, max_continuations, completion_tokens,\n )\n # Append partial output and ask to continue\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n messages.append({\n \"role\": \"user\",\n \"content\": \"Your response was truncated. Continue exactly where you left off — do not repeat any content.\",\n })\n continuation_response = await agent_runtime.llm_manager.generate_response(messages, tools=tool_schemas)\n if continuation_response and continuation_response.content:\n response.content += continuation_response.content\n if continuation_response.usage:\n prev_usage = response.usage or {}\n response.usage = {\n \"total_tokens\": prev_usage.get(\"total_tokens\", 0) + continuation_response.usage.get(\"total_tokens\", 0),\n \"completion_tokens\": prev_usage.get(\"completion_tokens\", 0) + continuation_response.usage.get(\"completion_tokens\", 0),\n \"prompt_tokens\": continuation_response.usage.get(\"prompt_tokens\", 0),\n }\n response.finish_reason = getattr(continuation_response, \"finish_reason\", None)\n execution_time = time.time() - start_time\n else:\n break\n\n if continuation > 0:\n self.logger.info(\n \"Completed %d continuation(s) for agent %s. Total output: %d chars\",\n continuation, agent_id, len(response.content) if response and response.content else 0,\n )\n\n if response and response.content:\n tokens_used = response.usage.get(\"total_tokens\", 0) if response.usage else 0\n agent_runtime.update_metrics(execution_time, tokens_used, True)\n\n # Store in short-term memory\n agent_runtime.memory.append({\n \"task\": prompt[:200],\n \"response\": response.content[:500],\n \"summary\": f\"Executed: {prompt[:100]}\",\n \"timestamp\": datetime.now().isoformat(),\n \"tokens\": tokens_used,\n \"execution_time\": execution_time,\n })\n if len(agent_runtime.memory) > 20:\n agent_runtime.memory = agent_runtime.memory[-20:]\n\n agent_runtime.lifecycle_state = AgentLifecycle.ACTIVE\n\n # Record monitoring\n monitoring = get_monitoring_service()\n monitoring.record_agent_execution(\n agent_id=agent_runtime.agent_id,\n agent_name=agent_runtime.metadata.name,\n task=prompt[:100],\n execution_time_ms=execution_time * 1000,\n tokens_used=tokens_used,\n success=True,\n )\n\n return {\n \"status\": \"success\",\n \"result\": response.content,\n \"agent\": {\n \"id\": agent_runtime.agent_id,\n \"name\": agent_runtime.metadata.name,\n \"type\": agent_runtime.metadata.agent_type,\n },\n \"execution\": {\n \"time\": execution_time,\n \"tokens_used\": tokens_used,\n \"model\": response.model,\n \"provider\": response.provider,\n \"attempt\": attempt + 1,\n \"tool_iterations\": tool_iteration,\n },\n \"metrics\": {\n \"total_executions\": agent_runtime.execution_count,\n \"success_rate\": agent_runtime.performance_metrics.get(\"success_rate\", 1.0),\n \"avg_execution_time\": agent_runtime.performance_metrics.get(\"avg_execution_time\", execution_time),\n },\n }\n else:\n last_error = \"Empty response from LLM\"\n\n except Exception as e:\n last_error = str(e)\n self.logger.warning(f\"Attempt {attempt + 1} failed: {last_error}\")\n if attempt < max_retries - 1:\n await asyncio.sleep(2 ** attempt)\n\n # All retries failed\n agent_runtime.update_metrics(time.time() - start_time, 0, False)\n agent_runtime.lifecycle_state = AgentLifecycle.ACTIVE\n return {\n \"status\": \"error\",\n \"error\": f\"Task execution failed after {max_retries} attempts: {last_error}\",\n \"agent\": {\n \"id\": agent_runtime.agent_id,\n \"name\": agent_runtime.metadata.name,\n \"type\": agent_runtime.metadata.agent_type,\n },\n }\n\n except Exception as e:\n agent_runtime.lifecycle_state = AgentLifecycle.ACTIVE\n self.logger.error(f\"Task execution error: {e}\")\n return {\"status\": \"error\", \"error\": str(e)}\n\n # ==================================================================\n # Composio Hint Injection\n # ------------------------------------------------------------------\n # These methods are COMPLEMENTARY to ComposioSection (modules/context/\n # sections/composio.py), NOT redundant. ComposioSection renders static\n # app-level descriptions in the system prompt. These methods do dynamic\n # per-request work: _inject_composio_hints() uses ComposioHintService\n # to semantically match user prompts to relevant actions AND constrains\n # the composio_execute tool schema enum. _inject_composio_recipe_hints()\n # does the same for recipe paths with pre-resolved action names.\n # Future: absorb hint logic into a ComposioHintSection or expand\n # ComposioSection with action-level detail. See PRD-81 Task 5.7.\n # ==================================================================\n\n def _inject_composio_recipe_hints(\n self,\n tool_schemas: List[Dict],\n messages: List[Dict],\n composio_apps: List[Dict],\n composio_action_names: set,\n ):\n \"\"\"Inject pre-resolved Composio actions (recipe path).\"\"\"\n sorted_actions = sorted(composio_action_names)\n\n # Find composio_execute schema and constrain its action enum\n for schema in tool_schemas:\n if schema.get(\"function\", {}).get(\"name\") == \"composio_execute\":\n schema[\"function\"][\"parameters\"][\"properties\"][\"action\"] = {\n \"type\": \"string\",\n \"description\": \"Action name — must be one of these actions.\",\n \"enum\": sorted_actions,\n }\n break\n\n app_names = [t.get(\"name\") for t in composio_apps]\n hint_lines = [\n f\"You have Composio apps connected: {', '.join(app_names)}.\",\n f\"Available actions (use exactly these names): {', '.join(sorted_actions)}.\",\n \"Call composio_execute with the action name and required params.\",\n ]\n insert_at = 1 if messages and messages[0].get(\"role\") == \"system\" else 0\n messages.insert(insert_at, {\"role\": \"system\", \"content\": \"\\n\".join(hint_lines)})\n\n self.logger.info(\n f\"Composio (semantic): constrained to {len(sorted_actions)} actions: {sorted_actions}\"\n )\n\n def _inject_composio_hints(\n self,\n tool_schemas: List[Dict],\n messages: List[Dict],\n agent_runtime: AgentRuntime,\n original_user_prompt: str,\n workspace_id: Optional[Any],\n ):\n \"\"\"Inject Composio per-action tools (primary) or hint fallback.\n\n Primary: ComposioToolService returns per-action OpenAI function schemas\n (e.g. TAVILY_SEARCH with typed params). Strips composio_execute and\n replaces with per-action tools.\n\n Fallback: ComposioHintService injects action names as system prompt\n hints and constrains composio_execute's action enum.\n \"\"\"\n try:\n from modules.tools.services.composio_tool_service import ComposioToolService\n\n composio_svc = ComposioToolService(self.db_session)\n composio_result = composio_svc.get_tools_for_step(\n agent_id=agent_runtime.agent_id,\n workspace_id=workspace_id,\n task_prompt=original_user_prompt,\n )\n\n if composio_result and composio_result.tools:\n # Strip composio_execute, add per-action schemas\n tool_schemas[:] = [\n t for t in tool_schemas\n if t.get(\"function\", {}).get(\"name\") != \"composio_execute\"\n ] + composio_result.tools\n\n # Add scope message so the LLM knows which apps are available\n from api.recipe_executor import _composio_scope_message\n insert_at = 1 if messages and messages[0].get(\"role\") == \"system\" else 0\n messages.insert(insert_at, {\n \"role\": \"system\",\n \"content\": _composio_scope_message(composio_result.app_names),\n })\n\n self.logger.info(\n f\"Composio (per-action): strategy={composio_result.strategy} \"\n f\"actions={len(composio_result.action_set)} \"\n f\"search_ms={composio_result.search_ms}\"\n )\n return\n\n except Exception as e:\n self.logger.warning(f\"ComposioToolService failed, falling back to hints: {e}\")\n\n # Fallback: hint-based injection\n try:\n from modules.tools.services.composio_hint_service import ComposioHintService\n\n hint_service = ComposioHintService(self.db_session)\n hint_result = hint_service.build_hints(\n agent_id=agent_runtime.agent_id,\n prompt=original_user_prompt,\n workspace_id=workspace_id,\n )\n\n if hint_result.hint_lines:\n insert_at = 1 if messages and messages[0].get(\"role\") == \"system\" else 0\n messages.insert(insert_at, {\"role\": \"system\", \"content\": \"\\n\".join(hint_result.hint_lines)})\n\n if hint_result.matched_actions:\n for schema in tool_schemas:\n if schema.get(\"function\", {}).get(\"name\") == \"composio_execute\":\n schema[\"function\"][\"parameters\"][\"properties\"][\"action\"] = {\n \"type\": \"string\",\n \"description\": \"Action name — must be one of the candidate actions listed above.\",\n \"enum\": hint_result.matched_actions,\n }\n break\n\n self.logger.info(\n f\"Composio (hints fallback): strategy={hint_result.strategy_used}, \"\n f\"constrained_actions={len(hint_result.matched_actions)}, \"\n f\"apps={hint_result.allowed_apps}\"\n )\n except Exception as e:\n self.logger.warning(f\"Failed to inject Composio hints: {e}\")\n\n # ==================================================================\n # Composio App Loading\n # ==================================================================\n\n async def _load_agent_tools(self, agent_id: int) -> List[Dict[str, Any]]:\n \"\"\"Load Composio apps assigned to an agent from the database.\"\"\"\n try:\n assignments = (\n self.db_session.query(AgentAppAssignment)\n .filter(\n AgentAppAssignment.agent_id == agent_id,\n AgentAppAssignment.is_active.is_(True),\n AgentAppAssignment.app_type == \"EXTERNAL\",\n )\n .all()\n )\n if not assignments:\n return []\n\n app_names = [a.app_name.upper() for a in assignments if a.app_name]\n cache = {\n a.app_name: a\n for a in self.db_session.query(ComposioAppCache).filter(ComposioAppCache.app_name.in_(app_names)).all()\n }\n\n tools: List[Dict[str, Any]] = []\n for assignment in assignments:\n app_name = (assignment.app_name or \"\").upper()\n app = cache.get(app_name)\n if not app_name:\n continue\n tools.append({\n \"name\": app_name,\n \"description\": (app.description if app else \"\") or \"\",\n \"provider\": \"Composio\",\n \"category\": ((app.categories or [None])[0] if app else None),\n \"icon\": app.logo_url if app else None,\n \"assigned_at\": assignment.assigned_at.isoformat() if assignment.assigned_at else None,\n })\n\n self.logger.info(f\"Loaded {len(tools)} Composio app(s) for agent {agent_id}\")\n return tools\n except Exception as e:\n self.logger.warning(f\"Failed to load Composio apps for agent {agent_id}: {e}\")\n return []\n\n # ==================================================================\n # Status & Utility\n # ==================================================================\n\n async def get_agent_status(self, agent_id: int) -> Dict[str, Any]:\n \"\"\"Get detailed status of an agent.\"\"\"\n agent_runtime = self.active_agents.get(agent_id)\n\n if not agent_runtime:\n db_agent = self.db_session.query(Agent).filter_by(id=agent_id).first()\n if db_agent:\n return {\n \"status\": \"inactive\",\n \"agent\": {\n \"id\": db_agent.id,\n \"name\": db_agent.name,\n \"type\": db_agent.agent_type,\n \"database_status\": db_agent.status,\n },\n \"runtime\": None,\n \"message\": \"Agent exists in database but not in runtime.\",\n }\n return {\"status\": \"not_found\", \"error\": f\"Agent {agent_id} does not exist\"}\n\n provider_info = agent_runtime.llm_manager.get_provider_info()\n return {\n \"status\": \"active\",\n \"agent\": {\n \"id\": agent_runtime.agent_id,\n \"name\": agent_runtime.metadata.name,\n \"type\": agent_runtime.metadata.agent_type,\n \"lifecycle_state\": agent_runtime.lifecycle_state.value,\n \"skills\": agent_runtime.metadata.skills,\n },\n \"runtime\": {\n \"created_at\": agent_runtime.created_at.isoformat(),\n \"last_execution\": agent_runtime.last_execution.isoformat() if agent_runtime.last_execution else None,\n \"execution_count\": agent_runtime.execution_count,\n \"total_tokens_used\": agent_runtime.total_tokens_used,\n \"memory_size\": len(agent_runtime.memory),\n },\n \"llm\": provider_info,\n \"metrics\": agent_runtime.performance_metrics,\n }\n\n def get_agent_tool_capability(self, agent_runtime: AgentRuntime, capability: str) -> bool:\n \"\"\"Check if an agent has a specific tool capability.\"\"\"\n for tool in agent_runtime.tools:\n tool_capabilities = tool.get(\"capabilities\", {})\n if isinstance(tool_capabilities, dict):\n if capability in tool_capabilities.get(\"methods\", []):\n return True\n return False\n\n def get_agent_tools_summary(self, agent_runtime: AgentRuntime) -> Dict[str, Any]:\n \"\"\"Get summary of agent's tools for display/logging.\"\"\"\n return {\n \"total_tools\": len(agent_runtime.tools),\n \"tools\": [\n {\"name\": t.get(\"name\"), \"category\": t.get(\"category\"), \"provider\": t.get(\"provider\")}\n for t in agent_runtime.tools\n ],\n \"categories\": list(set(t.get(\"category\") for t in agent_runtime.tools if t.get(\"category\"))),\n }\n\n def cleanup(self):\n \"\"\"Clean up resources.\"\"\"\n if self.db_session:\n self.db_session.close()\n"}}}}