|
166 | 166 | if (os.environ.get("MIOS_AGENT_PIPE_BACKEND_LIGHT") or "").strip().lower() |
167 | 167 | in {"1", "true", "yes", "on"} |
168 | 168 | else "http://localhost:8642/v1")).rstrip("/") |
| 169 | +# True when the reasoning backend is the light llama.cpp lane DIRECTLY (the |
| 170 | +# BACKEND_LIGHT "bypass Hermes" deployment), so callers know the primary endpoint |
| 171 | +# is llama.cpp -- which 200-accepts but SILENTLY IGNORES tool_choice='required'. |
| 172 | +# Hermes (:8642) is an OpenAI gateway that DOES honor it, so this stays False then. |
| 173 | +_BACKEND_IS_LIGHT = ( |
| 174 | + (os.environ.get("MIOS_AGENT_PIPE_BACKEND_LIGHT") or "").strip().lower() |
| 175 | + in {"1", "true", "yes", "on"} |
| 176 | + and not (os.environ.get("MIOS_AGENT_PIPE_BACKEND") or "").strip()) |
169 | 177 | BACKEND_MODEL = (os.environ.get("MIOS_AGENT_PIPE_BACKEND_MODEL") |
170 | 178 | or os.environ.get("MIOS_AI_MODEL") # WS-0B: ONE owned key = [ai].model |
171 | 179 | or "hermes-agent") |
@@ -7659,15 +7667,36 @@ def _perm_rank(perm: str) -> int: |
7659 | 7667 | or "interactive").strip().lower() |
7660 | 7668 |
|
7661 | 7669 |
|
7662 | | -def _hitl_block_reason(tool: str) -> "Optional[str]": |
| 7670 | +def _effective_perm(tool: str, args: "Optional[dict]" = None) -> str: |
| 7671 | + """The permission tier that actually governs THIS call. Umbrella verbs that |
| 7672 | + dispatch to a NAMED sub-action with its own permission (os_recipe -> a named |
| 7673 | + [recipes.*]) must be gated by the RECIPE's tier, not the umbrella verb's |
| 7674 | + worst-case 'interactive' -- otherwise HITL block-mode neutralizes even the |
| 7675 | + read-only recipes (service-status / show-network / disk-usage / os-control- |
| 7676 | + health) the agent needs for routine OS introspection. Falls back to the |
| 7677 | + verb's own permission. Degrade-open: any lookup miss -> the verb tier.""" |
| 7678 | + vperm = str((_VERB_CATALOG.get(tool) or {}).get("permission", "read")).lower() |
| 7679 | + try: |
| 7680 | + if tool == "os_recipe" and args: |
| 7681 | + rn = str((args or {}).get("name") or "").strip().replace("_", "-") |
| 7682 | + rc = _RECIPE_CATALOG.get(rn) |
| 7683 | + if rc: |
| 7684 | + return str(rc.get("permission", vperm)).lower() |
| 7685 | + except Exception: # noqa: BLE001 -- degrade-open |
| 7686 | + pass |
| 7687 | + return vperm |
| 7688 | + |
| 7689 | + |
| 7690 | +def _hitl_block_reason(tool: str, args: "Optional[dict]" = None) -> "Optional[str]": |
7663 | 7691 | """#62: in BLOCK mode return a human-readable refusal reason if `tool` is a |
7664 | 7692 | high-risk (tier >= [ai].hitl_threshold) action requiring approval, else None. |
7665 | 7693 | AUDIT mode logs the high-risk action and returns None (proceed). OFF returns |
7666 | | - None immediately. Degrade-open: never raises, never gates on error.""" |
| 7694 | + None immediately. Degrade-open: never raises, never gates on error. For |
| 7695 | + os_recipe the effective tier is the NAMED recipe's, not the umbrella verb's.""" |
7667 | 7696 | if _HITL_MODE not in ("audit", "block"): |
7668 | 7697 | return None |
7669 | 7698 | try: |
7670 | | - vperm = str((_VERB_CATALOG.get(tool) or {}).get("permission", "read")).lower() |
| 7699 | + vperm = _effective_perm(tool, args) |
7671 | 7700 | if _perm_rank(vperm) < _perm_rank(_HITL_THRESHOLD): |
7672 | 7701 | return None # below the gate threshold -> not human-gated |
7673 | 7702 | if _HITL_MODE == "audit": |
@@ -7700,7 +7729,7 @@ async def _hitl_arbiter_verdict(tool: str, args: dict) -> "Optional[str]": |
7700 | 7729 | if not _HITL_ARBITER_URL: |
7701 | 7730 | return None |
7702 | 7731 | try: |
7703 | | - vperm = str((_VERB_CATALOG.get(tool) or {}).get("permission", "read")).lower() |
| 7732 | + vperm = _effective_perm(tool, args) |
7704 | 7733 | if _perm_rank(vperm) < _perm_rank(_HITL_THRESHOLD): |
7705 | 7734 | return None # below the threshold -> arbiter not consulted |
7706 | 7735 | client = await _get_client() |
@@ -9576,7 +9605,12 @@ async def _firecrawl(url: str) -> tuple: |
9576 | 9605 | # backend). A THIRD fetch engine raced beside extract + crawl4ai so the |
9577 | 9606 | # pipeline uses ALL web tools (operator) -- richest wins in _fetch_all. |
9578 | 9607 | # Returns (markdown, links). |
9579 | | - if not _is_port_open(3002) or not _is_port_open(6379): |
| 9608 | + # Gate ONLY on :3002 (the host-published Firecrawl proxy mios-firecrawl |
| 9609 | + # targets). Redis :6379 is the firecrawl pod's INTERNAL job queue, reached |
| 9610 | + # only by the firecrawl-api/worker containers -- never from this host-side |
| 9611 | + # broker -- so probing it here always failed and silently dropped the |
| 9612 | + # firecrawl engine out of the _fetch_all race once the pod was deployed. |
| 9613 | + if not _is_port_open(3002): |
9580 | 9614 | return "", [] |
9581 | 9615 | try: |
9582 | 9616 | p = await asyncio.create_subprocess_exec( |
@@ -17898,7 +17932,7 @@ async def dispatch_mios_verb( |
17898 | 17932 | # #62 HITL gate (off by default -> the helper early-returns, ~zero overhead). |
17899 | 17933 | # In block mode a high-risk verb is REFUSED here (never executed) pending human |
17900 | 17934 | # approval; audit mode logs + proceeds. Keys off the resolved verb above. |
17901 | | - _hitl_reason = _hitl_block_reason(tool) |
| 17935 | + _hitl_reason = _hitl_block_reason(tool, args) |
17902 | 17936 | if _hitl_reason is None and _HITL_ARBITER_URL: |
17903 | 17937 | _hitl_reason = await _hitl_arbiter_verdict(tool, args) # #62 out-of-process arbiter |
17904 | 17938 | if _hitl_reason is not None: |
@@ -28440,6 +28474,25 @@ async def _finalize(emit=None): |
28440 | 28474 | # refusal instead of calling discord_send). An action domain MUST act. |
28441 | 28475 | if not isinstance(pb.get("tool_choice"), dict): |
28442 | 28476 | pb["tool_choice"] = "required" |
| 28477 | + # PRIMARY force-tool opt-out (mirrors the council/secondary downgrade at |
| 28478 | + # _sec_body): llama.cpp 200-ACCEPTS but SILENTLY IGNORES tool_choice, so |
| 28479 | + # 'required'/named reaches :11450 un-forcing -- the force-tool guard is a |
| 28480 | + # no-op on the BACKEND_LIGHT primary path. Downgrade required->auto when the |
| 28481 | + # resolved primary endpoint doesn't honor it (llama.cpp still emits real |
| 28482 | + # tool_calls under 'auto'); leave SGLang/vLLM heavy lanes that DO honor it |
| 28483 | + # untouched. The BACKEND-light lane carries no api='llamacpp' in target_cfg, |
| 28484 | + # so synthesize that cfg from the SSOT flag so the helper recognizes it. |
| 28485 | + _pc = pb.get("tool_choice") |
| 28486 | + if _pc not in ("none", "auto", None): |
| 28487 | + _prim_cfg = ({"api": "llamacpp"} |
| 28488 | + if (_BACKEND_IS_LIGHT |
| 28489 | + and str(target_endpoint).rstrip("/") |
| 28490 | + == str(BACKEND).rstrip("/")) |
| 28491 | + else target_cfg) |
| 28492 | + if not _endpoint_supports_tool_choice( |
| 28493 | + str(target_endpoint or ""), _prim_cfg, |
| 28494 | + _agent_offload_engine(_prim_cfg)): |
| 28495 | + pb["tool_choice"] = "auto" |
28443 | 28496 | # Universal agent contract FIRST (operator 2026-05-30 ".md presented |
28444 | 28497 | # to every agent"): the primary + every council secondary lead with |
28445 | 28498 | # the overlay contract (global tools, live internet, delegation, no |
|
0 commit comments