OUTPUT #691
PARÇA 2 / 5
TOPLAM: 167636 karakter | 2901 satır
BU PARÇA: 40000 karakter
 "models.github.ai"))
613-
614-    def _is_copilot_provider(self) -> bool:
615-        """True when the active provider is GitHub Copilot under any alias (``copilot`` / ``github-copilot`` /
616-        ``github``) or by base URL; a bare equality check would silently skip credential recovery."""
617-        return (self.provider or "").strip().lower() in {"copilot", "github-copilot", "github"} or self._is_copilot_url()
618-
--
623-                and "/backend-api/codex" in (getattr(self, "_base_url_lower", "") or ""))
624-
625-    _anthropic_prompt_cache_policy = _forward("agent.agent_runtime_helpers", "anthropic_prompt_cache_policy")
626-    _direct_native_anthropic_tool_cache_capability = _forward("agent.agent_runtime_helpers", "_direct_native_anthropic_tool_cache_capability")
627-
628-    @staticmethod
629:    def _model_requires_responses_api(model: str) -> bool:
630-        """True for GPT-5.x, which OpenAI and OpenRouter reject on /v1/chat/completions
631:        (``unsupported_api_for_model``)."""
632:        return model.lower().rsplit("/", 1)[-1].startswith("gpt-5")  # strip vendor prefix ("openai/gpt-5.4")
633-
634-    @staticmethod
635:    def _provider_model_requires_responses_api(model: str, *, provider: Optional[str] = None) -> bool:
636:        """Return True when this provider/model pair should use Responses API."""
637-        normalized_provider = (provider or "").strip().lower()
638-        # Nous serves GPT-5.x via chat completions (its /v1/responses returns 404); generic custom endpoints
639-        # may relay GPT-5 without full Responses semantics — only direct OpenAI/xAI URLs auto-upgrade.
640-        if normalized_provider in ("nous", "custom"):
641-            return False
642-        if normalized_provider == "copilot":
643-            try:
644:                from hermes_cli.models import _should_use_copilot_responses_api
645:                return _should_use_copilot_responses_api(model)
646-            except Exception:
647-                pass  # fall back to the generic GPT-5 rule
648:        return AIAgent._model_requires_responses_api(model)
649-
650-    def _max_tokens_param(self, value: int) -> dict:
651-        """``max_completion_tokens`` for newer OpenAI families (and Azure / Copilot serving them), else
652:        ``max_tokens``. URL-first, then model-name fallback for third-party endpoints fronting those models."""
653-        if (self._is_direct_openai_url() or self._is_azure_openai_url() or self._is_github_copilot_url()
654:                or model_forces_max_completion_tokens(self.model)):
655-            return {"max_completion_tokens": value}
656-        return {"max_tokens": value}
657-
658-    @staticmethod
659-    def _requested_output_cap_from_api_kwargs(api_kwargs: Any) -> Optional[int]:
660-        """Extract the outgoing response token cap from a prepared request."""
--
667-                continue
668-            if value > 0:
669-                return value
670-        return None
671-
672-    def _has_content_after_think_block(self, content: str) -> bool:
673:        """True when text remains after stripping reasoning blocks (reasoning-only output is retried)."""
674-        return bool(content) and bool(self._strip_think_blocks(content).strip())
675-
676-    _strip_think_blocks = _forward("agent.agent_runtime_helpers", "strip_think_blocks")
677-
678-    @staticmethod
679-    def _has_natural_response_ending(content: str) -> bool:
680:        """Heuristic: does visible assistant text look intentionally finished?"""
681-        stripped = (content or "").rstrip()
682-        if not stripped:
683-            return False
684-        last = stripped[-1]
685-        # Closing punctuation/brackets, a fenced-code close, or an emoji (Misc Symbols, Dingbats, Emoticons, ...).
686-        return stripped.endswith("```") or last in '.!?:)"\']}。!?:)】」』》^' or ord(last) >= 0x1F300
687-
688-    def _is_ollama_glm_backend(self) -> bool:
689:        """Ollama-hosted GLM models misreport finish_reason='stop'. Matches only explicit Ollama signatures
690-        (port 11434, "ollama" in URL, provider ollama), never arbitrary local proxies; excludes Ollama Cloud
691-        (``ollama.com`` / ``:cloud``), which reports faithfully — rewriting it would manufacture truncations.
692-
693-        Crucially it does NOT match arbitrary local/private endpoints (LiteLLM/sglang/vLLM/LM Studio
694:        proxies, Tailscale boxes), which report finish_reason correctly and were the source of #13971's
695-        false-positive truncation continuations.
696-        Two signatures identify it: the ``ollama.com`` host (provider ``ollama-cloud``) and the ``:cloud``
697:        model suffix (cloud generation proxied through a local 11434 endpoint, #98406). Applying the
698:        stop→length rewrite to them manufactures false truncations and causes the continuation nudge to
699:        consume the model's output budget on the next retry, making further false-positives more likely.
700-        """
701:        model_lower = (self.model or "").lower()
702-        provider_lower = (self.provider or "").lower()
703:        if "glm" not in model_lower and provider_lower != "zai":
704-            return False
705-        base = self._base_url_lower
706:        # Ollama Cloud (hosted service or :cloud proxy) forwards finish_reason faithfully — do not rewrite.
707:        if "ollama.com" in base or ":cloud" in model_lower:
708-            return False
709-        if "ollama" in base or ":11434" in base:
710-            return True
711-        return provider_lower == "ollama"
712-
713:    def _should_treat_stop_as_truncated(self, finish_reason: str, assistant_message, messages: Optional[list] = None) -> bool:
714:        """Detect conservative stop->length misreports for Ollama-hosted GLM models."""
715:        if finish_reason != "stop" or self.api_mode != "chat_completions" or not self._is_ollama_glm_backend():
716-            return False
717-        if not any(isinstance(msg, dict) and msg.get("role") == "tool" for msg in (messages or [])):
718-            return False
719-        if assistant_message is None or getattr(assistant_message, "tool_calls", None):
720-            return False
721-        content = getattr(assistant_message, "content", None)
--
724-        visible_text = self._strip_think_blocks(content).strip()
725-        if len(visible_text) < 20 or not re.search(r"\s", visible_text):
726-            return False
727-        return not self._has_natural_response_ending(visible_text)
728-
729-    _looks_like_codex_intermediate_ack = _forward("agent.agent_runtime_helpers", "looks_like_codex_intermediate_ack")
730:    _extract_reasoning = _forward("agent.agent_runtime_helpers", "extract_reasoning")
731-    _cleanup_task_resources = _forward("agent.chat_completion_helpers", "cleanup_task_resources")
732-
733:    # Background memory/skill review — prompts live in agent.background_review.
734:    from agent.background_review import _MEMORY_REVIEW_PROMPT, _SKILL_REVIEW_PROMPT, _COMBINED_REVIEW_PROMPT
735:    _summarize_background_review_actions = _forward_static("agent.background_review", "summarize_background_review_actions")
736-
737:    def _spawn_background_review(self, messages_snapshot: List[Dict], review_memory: bool = False,
738-                                 review_skills: bool = False, focus: Optional[str] = None, explicit: bool = False) -> None:
739-        """Post-turn review entry point: decide WHEN, then spawn.
740-
741-        A review whose runtime is the MANAGED LOCAL llama-server is queued for machine idle (``defer: auto``)
742-        instead of hitting the user's GPU mid-session; everything else spawns immediately. ``explicit``
743:        (/refine) is never deferred but does not touch the ``focus``-keyed delegate/enabled gates.
744-        """
745-        # Gates run at enqueue/spawn time; the idle dispatcher re-checks `enabled` at dispatch time.
746:        if focus is None and getattr(self, "_delegate_depth", 0) > 0:
747-            return
748-        task_cfg = None
749-        if focus is None:
750:            from agent.background_review import load_background_review_settings
751:            enabled, task_cfg = load_background_review_settings()
752-            if not enabled:
753-                return
754-
755-        # Structural clone at the single chokepoint: the fork sanitizes in place, and a shallow copy would
756-        # alias the live history's nested tool_calls/content.
757-        # Structural clone at the single chokepoint every review path (automatic, /refine, idle-queue
758-        # deferral) goes through. See #100795.
759:        from agent.turn_finalizer import _clone_background_review_messages
760:        kwargs = dict(messages_snapshot=_clone_background_review_messages(messages_snapshot),
761-                      review_memory=review_memory, review_skills=review_skills, focus=focus, task_cfg=task_cfg)
762-        if focus is None and not explicit and _review_should_defer(self, task_cfg):
763-            from agent.review_idle_queue import QUEUE
764-            QUEUE.enqueue(self, _review_queue_key(self), kwargs)
765-            return
766:        self._spawn_background_review_now(**kwargs)
767-
768:    def _spawn_background_review_now(self, messages_snapshot: List[Dict], review_memory: bool = False,
769-                                     review_skills: bool = False, focus: Optional[str] = None,
770-                                     task_cfg: Optional[Dict[str, Any]] = None, _requeue_attempts: int = 0) -> None:
771:        """Spawn the background memory/skill review thread.
772-
773-        ``threading.Thread`` is constructed here so tests patching ``run_agent.threading.Thread`` keep working.
774-        ``focus`` is /refine steering text; ``task_cfg`` is the pre-loaded config block (None on direct calls).
775-        A deferred review preempted by a live turn is requeued (bounded) rather than lost.
776-        """
777:        from agent.background_review import (
778:            finish_background_review_run, prepare_background_review_run, spawn_background_review_thread,
779-        )
780:        from tools.thread_context import propagate_context_to_thread
781-
782:        review_run = prepare_background_review_run(self)
783-        if review_run is None:
784-            return
785-        try:
786:            target, _prompt = spawn_background_review_thread(
787-                self, messages_snapshot, review_memory=review_memory, review_skills=review_skills,
788-                focus=focus, task_cfg=task_cfg, review_run=review_run,
789-            )
790-
791-            def _target_with_requeue() -> None:
792-                target()
793-                self._maybe_requeue_preempted_review(review_run, dict(
794-                    messages_snapshot=messages_snapshot, review_memory=review_memory, review_skills=review_skills,
795-                    focus=focus, task_cfg=task_cfg, _requeue_attempts=_requeue_attempts + 1))
796-
797-            # Carry the active profile into the review thread so MEMORY.md / skill review writes land in the
798-            # right profile.
799:            threading.Thread(target=propagate_context_to_thread(_target_with_requeue), daemon=True, name="bg-review").start()
800-        except Exception:
801:            finish_background_review_run(self, review_run)
802-            raise
803-
804-    _REVIEW_REQUEUE_MAX_ATTEMPTS = 3
805-
806-    def _maybe_requeue_preempted_review(self, review_run, kwargs) -> None:
807-        """Requeue a deferred-mode review that a live turn cancelled.
808-
809:        Only for automatic reviews on the managed local runtime; bounded attempts stop a busy box cycling
810-        forever.
811-        """
812-        try:
813-            # Not cancelled == ran to completion (or was never admitted).
814-            if not review_run.cancel_requested.is_set() or kwargs.get("focus") is not None:
815-                return
816-            if kwargs.get("_requeue_attempts", 0) > self._REVIEW_REQUEUE_MAX_ATTEMPTS:
817:                logger.info("Preempted background review dropped after %d requeues", self._REVIEW_REQUEUE_MAX_ATTEMPTS)
818-                return
819-            if not _review_should_defer(self, kwargs.get("task_cfg")):
820-                return
821-            from agent.review_idle_queue import QUEUE
822-            # kwargs carries the incremented _requeue_attempts through the queue so the cap survives.
823-            QUEUE.enqueue(self, _review_queue_key(self), dict(kwargs))
824-        except Exception:  # noqa: BLE001 — requeue is best-effort
825-            logger.debug("Preempted-review requeue failed", exc_info=True)
826-
827:    _build_memory_write_metadata = _forward("agent.background_review", "build_memory_write_metadata")
828-    _apply_pending_steer_to_tool_results = _forward("agent.agent_runtime_helpers", "apply_pending_steer_to_tool_results")
829-
830-    def get_activity_summary(self) -> dict:
831:        """Diagnostic snapshot: ``last_activity_*`` plus the short aliases gateway and delegate readers use."""
832-        from agent.session_activity import build_activity_snapshot
833-
834-        provenance = getattr(self, "_last_activity_provenance", None)
835-        return build_activity_snapshot(
836-            last_activity_at=getattr(self, "_last_activity_ts", None),
837-            last_activity_description=getattr(self, "_last_activity_desc", None) or "",
838-            last_activity_provenance=provenance if provenance is not None else ActivityProvenance.UNKNOWN,
839-            extra={
840-                "current_tool": self._current_tool, "api_call_count": self._api_call_count,
841:                "max_iterations": self.max_iterations, "budget_used": self.iteration_budget.used,
842-                "budget_max": self.iteration_budget.max_total,
843-            },
844-        )
845-
846-    def shutdown_memory_provider(self, messages: list = None) -> None:
847:        """Shut down the memory provider and context engine at session end (idempotent: gateway cleanup and
848-        ``close()`` may both call it)."""
849-        if getattr(self, "_memory_provider_shutdown", False):
850-            return
851-        self._memory_provider_shutdown = True
852-        if self._memory_manager:
853-            try:
854-                self._memory_manager.on_session_end(messages or [])
855-            except Exception as e:
856-                logger.warning("Memory provider on_session_end failed during shutdown: %s", e, exc_info=True)
857-            _quietly(lambda: self._memory_manager.shutdown_all())
858:        _notify_context_engine_session_end(self, messages)
859-
860-    def commit_memory_session(self, messages: list = None) -> None:
861:        """Flush end-of-session extraction on session_id rotation (/new, compression) without tearing providers
862-        down."""
863-        if self._memory_manager:
864-            _quietly(lambda: self._memory_manager.on_session_end(messages or []))
865:        _notify_context_engine_session_end(self, messages)
866-
867-    def _sync_external_memory_for_turn(self, *, original_user_message: Any, final_response: Any, interrupted: bool,
868-                                       messages: list | None = None) -> None:
869:        """Mirror a completed turn into external memory providers (``sync_all`` + ``queue_prefetch_all``).
870-
871-        Uses ``original_user_message`` (``user_message`` may carry injected skill content). Interrupted turns
872-        are skipped: partial output is not durable truth. Best-effort — an offline backend never blocks.
873-
874-        A partial assistant output, an aborted tool chain, or a mid-stream reset is not durable
875-        conversational truth — mirroring it into an external memory backend pollutes future recall with
876:        state the user never saw completed. The prefetch is gated on the same flag: the user's next message
877:        is almost certainly a retry of the same intent, and a prefetch keyed on the interrupted turn would
878:        fire against stale context. See #15218.
879-        """
880-        if interrupted or not (self._memory_manager and final_response and original_user_message):
881-            return
882-        # Flatten multimodal parts to text (newline-joined for memory).
883-        user_text = _summarize_user_message_for_log(original_user_message, sep="\n")
884-        response_text = _summarize_user_message_for_log(final_response, sep="\n")
885-        if not (user_text and response_text):
886-            return
887-        try:
888-            sync_kwargs = {"session_id": self.session_id or "", **({"messages": messages} if messages is not None else {})}
889-            self._memory_manager.sync_all(user_text, response_text, **sync_kwargs)
890:            # Sibling of the build_turn_context() prefetch gate: don't key recall on zero-signal prompts.
891-            if not is_trivial_prompt(user_text):
892-                self._memory_manager.queue_prefetch_all(user_text, session_id=self.session_id or "")
893-        except Exception:
894-            pass
895-
896-    def release_clients(self) -> None:
--
914-        self._close_active_children(soft=False)
915-        _quietly(self._drop_shared_client, lambda c: self._close_openai_client(c, reason="agent_close", shared=True))
916-        self._close_request_clients("agent_close")
917-        _quietly(self._close_codex_session)
918-        # Free conversation history proactively: callers may still hold the closed agent. The DB-flush
919-        # settled-prefix snapshot and the streamed-text accumulator are shadow copies of the same transcript;
920:        # on a closed delegate child they were the only remaining owners, pinning its history in the parent heap.
921-        self._session_messages = []
922-        self._db_flush_scan_prefix = None
923-        self._streamed_assistant_text_parts = []
924-        _quietly(self._trim_process_memory)
925-        _quietly(self._finalize_owned_session_row)
926-
--
972-    def _trim_process_memory() -> None:
973-        """Return freed heap pages to the OS on glibc; safe no-op elsewhere."""
974-        from hermes_cli.mem_trim import trim_memory
975-        trim_memory(force=True, reason="agent close")
976-
977-    def _finalize_owned_session_row(self) -> None:
978:        """End the session row unless ownership was handed forward (compression helpers, review forks sharing
979-        the parent's id; end_session() is first-reason-wins), then release the SQLite handle ONLY when this
980-        agent owns it — a dedicated handle left open pins its fds and token-writer thread for the process
981-        lifetime. The owner flag is cleared first so close() stays idempotent."""
982-        session_db = getattr(self, "_session_db", None)
983-        session_id = getattr(self, "session_id", None)
984-        if getattr(self, "_end_session_on_close", True) and session_db and session_id:
--
993-    def _hydrate_todo_store(self, history: List[Dict[str, Any]]) -> None:
994-        """Replay the most recent todo tool response (the gateway builds a fresh AIAgent per message). Only
995-        results paired with an earlier assistant ``todo`` call count — a forged bare ``role: tool`` message
996-        must not seed the store (GHSA-5g4g-6jrg-mw3g)."""
997-        found = self._latest_todo_response(history)
998-        if found is not None:
999:            last_todo_response, last_todo_revision = found
1000:            # Restore only when history carries a newer revision than the store holds; empty lists are an
1001-            # authoritative clear.
1002-            try:
1003:                history_revision = max(0, int(last_todo_revision or 0))
1004-            except (TypeError, ValueError):
1005:                history_revision = 1
1006:            if history_revision > int(self._todo_store.snapshot().get("revision", 0) or 0):
1007:                self._todo_store.restore(last_todo_response, revision=history_revision)
1008-                if not self.quiet_mode:
1009-                    self._vprint(f"{self.log_prefix}📋 Restored {len(last_todo_response)} todo item(s) from history")
1010-        _set_interrupt(False)
1011-
1012-    def _latest_todo_response(self, history: List[Dict[str, Any]]) -> Optional[tuple]:
1013:        """Walk history backwards for the newest paired, size-bounded todo result → ``(todos, revision)``."""
1014-        from tools.todo_tool import MAX_TODO_RESULT_CHARS
1015-
1016-        for idx in range(len(history) - 1, -1, -1):
1017-            msg = history[idx]
1018-            content = msg.get("content", "")
1019-            if msg.get("role") != "tool" or not isinstance(content, str) or not self._tool_response_matches_todo_call(history, idx):
--
1026-                continue
1027-            try:
1028-                data = json.loads(content)
1029-            except (json.JSONDecodeError, TypeError):
1030-                continue
1031-            if "todos" in data and isinstance(data["todos"], list):
1032:                return data["todos"], data.get("revision", 1)
1033-        return None
1034-
1035-    @classmethod
1036-    def _tool_response_matches_todo_call(cls, history: List[Dict[str, Any]], tool_index: int) -> bool:
1037-        """True when the nearest prior assistant message issued a ``todo`` call with this ``tool_call_id``; a
1038-        ``user``/``system`` boundary or missing id means unpaired → must not hydrate."""
--
1075-        return getattr(getattr(tc, "function", None), "name", "") or ""
1076-
1077-    _VALID_API_ROLES = frozenset({"system", "user", "assistant", "tool", "function", "developer"})
1078-    _sanitize_api_messages = _forward_static("agent.agent_runtime_helpers", "sanitize_api_messages")
1079-
1080-    @staticmethod
1081:    def _is_thinking_only_assistant(msg: Dict[str, Any], *, drop_codex_reasoning_items: bool = True) -> bool:
1082:        """True if ``msg`` is an assistant turn whose only payload is reasoning (no text, no tool_calls).
1083-
1084:        Providers converting reasoning to thinking blocks reject it (400 "final block cannot be thinking"), so
1085:        the turn is dropped from the API copy; the transcript keeps the reasoning block.
1086-        """
1087-        if not isinstance(msg, dict) or msg.get("role") != "assistant" or msg.get("tool_calls"):
1088-            return False
1089-        # Prefill stubs are thinking-only by construction; checked before content inspection since
1090-        # repair_empty_non_final_messages may have healed content.
1091-        if msg.get("_thinking_prefill"):
1092-            return True
1093-        if AIAgent._content_has_real_payload(msg.get("content")):
1094-            return False
1095-        # A native compaction checkpoint makes a carrier never thinking-only, regardless of api_mode or
1096:        # reasoning field. Checked above every reasoning branch so no carrier shape is dropped.
1097-        # The checkpoint is the server-side stand-in for already-pruned history and exists in exactly one
1098:        # place; the codex_responses adapter also surfaces commentary text via msg["reasoning"], so the
1099-        # string branch below would otherwise drop a carrier before the sidecar is ever inspected. See
1100-        # #82108.
1101-        from agent.native_compaction import has_compaction_checkpoint
1102-
1103:        if has_compaction_checkpoint(msg.get("codex_reasoning_items")):
1104-            return False
1105:        reasoning = msg.get("reasoning_content") or msg.get("reasoning")
1106:        rd = msg.get("reasoning_details")
1107:        if (isinstance(reasoning, str) and reasoning.strip()) or (isinstance(rd, list) and rd):
1108-            return True
1109:        # Codex Responses keeps encrypted reasoning under a separate key; only real items count as
1110-        # thinking-only, empty/junk lists fall through to generic empty-turn handling.
1111:        codex_items = msg.get("codex_reasoning_items")
1112:        if drop_codex_reasoning_items and isinstance(codex_items, list):
1113:            return any(isinstance(item, dict) and item.get("type") == "reasoning" for item in codex_items)
1114-        return False
1115-
1116-    @staticmethod
1117-    def _content_has_real_payload(content: Any) -> bool:
1118-        """True when assistant ``content`` carries anything beyond (redacted) thinking blocks / whitespace."""
1119-        if isinstance(content, str):
--
1134-            return False
1135-        return content is not None and content != ""
1136-
1137-    _drop_thinking_only_and_merge_users = _forward_static("agent.agent_runtime_helpers", "drop_thinking_only_and_merge_users")
1138-
1139-    @staticmethod
1140:    def _cap_delegate_task_calls(tool_calls: list) -> list:
1141:        """Cap delegate_task calls in one turn at max_concurrent_children (non-delegate calls all kept);
1142-        returns the original list when nothing was truncated."""
1143:        from tools.delegate_tool import _get_max_concurrent_children
1144-        max_children = _get_max_concurrent_children()
1145:        delegate_count = sum(1 for tc in tool_calls if tc.function.name == "delegate_task")
1146:        if delegate_count <= max_children:
1147-            return tool_calls
1148:        kept_delegates, truncated = 0, []
1149-        for tc in tool_calls:
1150:            if tc.function.name == "delegate_task":
1151:                if kept_delegates >= max_children:
1152-                    continue
1153:                kept_delegates += 1
1154-            truncated.append(tc)
1155:        logger.warning("Truncated %d excess delegate_task call(s) to enforce "
1156:                       "max_concurrent_children=%d limit", delegate_count - max_children, max_children)
1157-        return truncated
1158-
1159-    @staticmethod
1160-    def _deduplicate_tool_calls(tool_calls: list) -> list:
1161-        """Drop duplicate (tool_name, arguments) pairs in one turn (first wins). Valid JSON arguments are
1162-        canonicalized so key order/whitespace can't evade dedup; returns the original list when nothing was removed."""
--
1187-    _deterministic_call_id = staticmethod(_codex_deterministic_call_id)
1188-    _split_responses_tool_id = staticmethod(_codex_split_responses_tool_id)
1189-    _derive_responses_function_call_id = staticmethod(_codex_derive_responses_function_call_id)
1190-
1191-    _interruptible_api_call = _forward("agent.chat_completion_helpers", "interruptible_api_call")
1192-    _interruptible_streaming_api_call = _forward("agent.chat_completion_helpers", "interruptible_streaming_api_call")
1193:    _try_activate_fallback = _forward("agent.chat_completion_helpers", "try_activate_fallback")
1194-
1195:    def _has_pending_fallback(self) -> bool:
1196:        """Whether a fallback provider remains (mirrors ``try_activate_fallback``'s guard) — gates the
1197:        "trying fallback..." status so we never announce one that won't be attempted.
1198-
1199-        See #17446.
1200-        """
1201:        return getattr(self, "_fallback_index", 0) < len(getattr(self, "_fallback_chain", None) or [])
1202-
1203-    _restore_primary_runtime = _forward("agent.agent_runtime_helpers", "restore_primary_runtime")
1204-    _try_recover_primary_transport = _forward("agent.agent_runtime_helpers", "try_recover_primary_transport")
1205-    _build_api_kwargs = _forward("agent.chat_completion_helpers", "build_api_kwargs")
1206-
1207-    def _set_tool_guardrail_halt(self, decision: ToolGuardrailDecision) -> None:
1208:        """Record the first guardrail decision that should stop this turn."""
1209-        # web_search per-turn cap blocks further searches, but must not end an
1210-        # autonomous production turn; the agent should change strategy instead.
1211-        if decision.code == "loop_web_search_cap":
1212-            return
1213-        if decision.should_halt and self._tool_guardrail_halt_decision is None:
1214-            self._tool_guardrail_halt_decision = decision
1215-
1216-    def _toolguard_controlled_halt_response(self, decision: ToolGuardrailDecision) -> str:
1217-        return (
1218:            f"I stopped retrying {decision.tool_name or 'a tool'} because it hit the tool-call guardrail "
1219-            f"({decision.code}) after {decision.count} repeated non-progressing "
1220-            "attempts. The last tool result explains the blocker; the next step is "
1221-            "to change strategy instead of repeating the same call."
1222-        )
1223-
1224-    def _append_guardrail_observation(self, tool_name: str, function_args: dict, function_result: str, *,
--
1234-                    tool_call_id=tool_call_id, failed=failed,
1235-                )
1236-                stall_notice, result_stub = observation.notice, observation.stub
1237-            except Exception as exc:
1238-                logger.debug("stall-guard identical-call observation failed: %s", exc)
1239-        # Result-reference stubbing: a 2nd+ identical call with a byte-identical FRESH result enters
1240:        # context as a short stub. Not a cache — the tool ran; only plain-string results are stubbed.
1241-        if result_stub and isinstance(function_result, str):
1242-            function_result = result_stub
1243-        if decision.action in {"warn", "halt"}:
1244-            function_result = append_toolguard_guidance(function_result, decision)
1245-        if decision.should_halt:
1246-            self._set_tool_guardrail_halt(decision)
1247-        else:
1248:            # observe_call may have raised the identical-call streak halt (hard_stop_enabled, tool-agnostic).
1249-            streak_halt = self._tool_guardrails.halt_decision
1250-            if streak_halt is not None and streak_halt.code == "identical_call_streak_halt":
1251-                function_result = append_toolguard_guidance(function_result, streak_halt)
1252-                self._set_tool_guardrail_halt(streak_halt)
1253-        if stall_notice:
1254-            function_result = (function_result or "") + "\n\n" + stall_notice
--
1284-                return run(*args)
1285-            from agent.tool_executor import execute_tool_calls_segmented
1286-            return execute_tool_calls_segmented(self, *args, segments=segments)
1287-        finally:
1288-            self._executing_tools = False
1289-
1290:    def _dispatch_delegate_task(self, function_args: dict) -> str:
1291:        """Single call site for delegate_task dispatch; new DELEGATE_TASK_SCHEMA fields are added only here."""
1292:        from tools.delegate_tool import _strip_model_hidden_task_fields, delegate_task as _delegate_task
1293:        # Top-level MODEL delegations always run in the background (handle returned, results re-enter as
1294-        # messages). An ORCHESTRATOR SUBAGENT (depth > 0) stays synchronous — it needs results in-turn and
1295:        # owns no gateway session. The schema-level `background` param is intentionally ignored.
1296-        goal = function_args.get("goal")
1297-        is_creative_director = isinstance(goal, str) and goal.lstrip().startswith("HERMES_CREATIVE_DIRECTOR")
1298-
1299-        if is_creative_director:
1300-            director_charter = """
1301-HERMES_CREATIVE_DIRECTOR — AUTHORITATIVE CHARTER
--
1303-ROLE
1304-You are the Creative Director only. The main Sol agent is the executor.
1305-
1306-IMMUTABLE TECHNICAL BASELINE
1307-- Main executor remains gpt-5.6-sol.
1308-- Current Golden TTS remains Google tr-TR-Chirp3-HD-Algieba at rate 0.90.
1309:- Do not propose or alter TTS/voice/rate, model/provider, pipeline/helper, QA architecture, code, config, services or technical orchestration.
1310:- Use no production tools. No web, terminal, browser, TTS or Vision calls.
1311-
1312-KNOWN FAILURE LESSONS
1313-Past Hermes outputs repeatedly suffered from:
1314-- repeated or same-family footage
1315-- long generic infographic/card sections
1316-- cross-video AI template smell
--
1351-The executor must preserve each shot's narrative intent even if the exact asset changes.
1352-"""
1353-
1354-            original_goal = goal
1355-            goal = director_charter.strip() + "\n\nORIGINAL PRODUCTION BRIEF FROM SOL:\n" + original_goal
1356-
1357:        return _delegate_task(
1358:            goal=goal, context=function_args.get("context"),
1359:            tasks=_strip_model_hidden_task_fields(function_args.get("tasks")),
1360:            max_iterations=function_args.get("max_iterations"), role=function_args.get("role"),
1361:            background=False if is_creative_director else not (getattr(self, "_delegate_depth", 0) > 0),
1362-            action=function_args.get("action"),
1363-            subagent_id=function_args.get("subagent_id"), message=function_args.get("message"), parent_agent=self,
1364-        )
1365-
1366-    _invoke_tool = _forward("agent.agent_runtime_helpers", "invoke_tool")
1367-
--
1378-            else:
1379-                out_lines.extend(textwrap.wrap(raw_line, width=wrap_width, break_long_words=True, break_on_hyphens=False) or [raw_line])
1380-        return f"{indent}{label}" + ("\n" + indent).join(out_lines)
1381-
1382-    _execute_tool_calls_concurrent = _forward("agent.tool_executor", "execute_tool_calls_concurrent")
1383-    _execute_tool_calls_sequential = _forward("agent.tool_executor", "execute_tool_calls_sequential")
1384:    _handle_max_iterations = _forward("agent.chat_completion_helpers", "handle_max_iterations")
1385-
1386-    def _conversation_root_id(self) -> Optional[str]:
1387-        """Session-lineage ROOT id for Portal usage attribution, so one conversation keeps a single
1388:        ``conversation=`` tag across compression rotation; subagents resolve via ``_parent_session_id``."""
1389-        sid = getattr(self, "session_id", None)
1390-        if not sid:
1391-            return None
1392-        # Subagents may not have a DB row yet on their first turn; walking from the parent id still lands
1393-        # on the right root.
1394-        start = getattr(self, "_parent_session_id", None) or sid
--
1399-            return db.get_conversation_root(start) or start
1400-        except Exception:
1401-            logger.debug("Conversation root lineage walk failed", exc_info=True)
1402-            return start
1403-
1404-
1405:_BASIC_TOOLSETS = {"web", "terminal", "vision", "creative", "reasoning"}
1406:_COMPOSITE_TOOLSETS = {"research", "development", "analysis", "content_creation", "full_stack"}
1407-_LIST_TOOLS_USAGE = """
1408-💡 Usage Examples:
1409:  # Use predefined toolsets
1410:  python run_agent.py --enabled_toolsets=research --query='search for Python news'
1411:  python run_agent.py --enabled_toolsets=development --query='debug this code'
1412:  python run_agent.py --enabled_toolsets=safe --query='analyze without terminal'
1413-
1414:  # Combine multiple toolsets
1415:  python run_agent.py --enabled_toolsets=web,vision --query='analyze website'
1416-
1417:  # Disable toolsets
1418:  python run_agent.py --disabled_toolsets=terminal --query='no command execution'
1419-
1420-  # Run with trajectory saving enabled
1421-  python run_agent.py --save_trajectories --query='your question here'"""
1422-
1423-
1424-def _print_tool_listing() -> None:
1425:    """``--list_tools``: print toolsets (basic / composite / scenario / legacy), every tool, and usage examples."""
1426:    from model_tools import get_all_tool_names, get_available_toolsets
1427:    from toolsets import get_all_toolsets, get_toolset_info
1428-
1429:    print("📋 Available Tools & Toolsets:")
1430-    print("-" * 50)
1431:    print("\n🎯 Predefined Toolsets (New System):")
1432-    print("-" * 40)
1433:    basic_toolsets, composite_toolsets, scenario_toolsets = [], [], []
1434:    for name in get_all_toolsets():
1435:        info = get_toolset_info(name)
1436-        if info:
1437:            bucket = basic_toolsets if name in _BASIC_TOOLSETS else composite_toolsets if name in _COMPOSITE_TOOLSETS else scenario_toolsets
1438-            bucket.append((name, info))
1439:    print("\n📌 Basic Toolsets:")
1440:    for name, info in basic_toolsets:
1441-        print(f"  • {name:15} - {info['description']}")
1442-        print(f"    Tools: {', '.join(info['resolved_tools']) if info['resolved_tools'] else 'none'}")
1443:    print("\n📂 Composite Toolsets (built from other toolsets):")
1444:    for name, info in composite_toolsets:
1445-        print(f"  • {name:15} - {info['description']}")
1446-        print(f"    Includes: {', '.join(info['includes']) if info['includes'] else 'none'}")
1447-        print(f"    Total tools: {info['tool_count']}")
1448:    print("\n🎭 Scenario-Specific Toolsets:")
1449:    for name, info in scenario_toolsets:
1450-        print(f"  • {name:20} - {info['description']}")
1451-        print(f"    Total tools: {info['tool_count']}")
1452:    print("\n📦 Legacy Toolsets (for backward compatibility):")
1453:    for name, info in get_available_toolsets().items():
1454-        print(f"  {'✅' if info['available'] else '❌'} {name}: {info['description']}")
1455-        if not info["available"]:
1456-            print(f"    Requirements: {', '.join(info['requirements'])}")
1457-    all_tools = get_all_tool_names()
1458-    print(f"\n🔧 Individual Tools ({len(all_tools)} available):")
1459-    for tool_name in sorted(all_tools):
1460:        print(f"  📌 {tool_name} (from {get_toolset_for_tool(tool_name)})")
1461-    print(_LIST_TOOLS_USAGE)
1462-
1463-
1464:def _parse_toolset_arg(raw: Optional[str], label: str) -> Optional[List[str]]:
1465:    """Comma-separated toolset CLI arg → list (echoed), or None when absent."""
1466-    if not raw:
1467-        return None
1468-    names = [t.strip() for t in raw.split(",")]
1469-    print(f"{label}: {names}")
1470-    return names
1471-
1472-
1473:def _save_sample_trajectory(agent: "AIAgent", result: dict, user_query: str, model: str) -> None:
1474-    """``--save_sample``: write one trajectory (same format as batch_runner) to a UUID-named JSON file."""
1475-    sample_filename = f"sample_{str(uuid.uuid4())[:8]}.json"
1476-    entry = {
1477:        "conversations": agent._convert_to_trajectory_format(result['messages'], user_query, result['completed']),
1478:        "timestamp": datetime.now().isoformat(), "model": model, "completed": result['completed'], "query": user_query,
1479-    }
1480-    try:
1481-        with open(sample_filename, "w", encoding="utf-8") as f:
1482-            f.write(json.dumps(entry, ensure_ascii=False, indent=2))
1483-        print(f"\n💾 Sample trajectory saved to: {sample_filename}")
1484-    except Exception as e:
1485-        print(f"\n⚠️ Failed to save sample: {e}")
1486-
1487-
1488-def main(
1489:    query: str = None, model: str = "", api_key: str = None, base_url: str = "", max_turns: int = 10,
1490:    enabled_toolsets: str = None, disabled_toolsets: str = None, list_tools: bool = False,
1491-    save_trajectories: bool = False, save_sample: bool = False, verbose: bool = False, log_prefix_chars: int = 20,
1492-):
1493-    """
1494-    Main function for running the agent directly.
1495-
1496-    Args:
1497-        query (str): Natural language query for the agent. Defaults to Python 3.13 example.
1498:        model (str): Model name to