oc_chat
Loading...
Searching...
No Matches
tool_registry.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/tool_registry.py
4
5"""
6Build the tool registry from an explicit module allowlist + ``oc_chat.tools`` entry points.
7
8Purpose
9-------
10Bootstrap helper that builds the ``(registry, schemas)`` pair used by
11the ReAct inner loop. Two discovery passes feed the registry:
12
131. **First-party pass** — every module in the explicit
14 :data:`_TOOL_MODULE_ALLOWLIST` that exports ``TOOLS``
15 (``dict[str, Callable[..., dict]]``) and ``SCHEMAS`` (``list[dict]``).
16 This is a deterministic allowlist, **not** a ``tools/*.py`` glob
17 (M8.0 WU-6 / G-2): an agent-written ``oc_chat/tools/*.py`` is never
18 auto-imported, closing the primary F57 in-process-ACE vector. Adding a
19 new first-party tool module requires adding its stem to the allowlist.
202. **Entry-point pass** (Phase K.1b) — every entry point in the
21 ``oc_chat.tools`` group, loaded **only under the permissive profile**
22 (M8.0 WU-6 / G-3: enforcing profiles deny untrusted plugin ``ep.load()``
23 at startup). Third-party packages declare:
24 ``[project.entry-points."oc_chat.tools"] my = "my_pkg.tools"`` and
25 the named module is loaded and merged the same way.
26
27This is logically distinct from tool *dispatch* (which executes a
28single tool call inside the loop, lives in
29:mod:`oc_chat.core.react_loop`) so a sub-agent can construct a
30*subset* registry without touching dispatch code. That separation is
31the reason this lives in its own module rather than next to
32``run_turn``.
33
34Responsibilities
35----------------
36- :func:`build_tool_registry` — orchestrate both discovery passes,
37 detect name collisions, surface failed plugins via L1 ``_dbg``.
38- :func:`failed_plugins` — return ``[(entry_point_name, error_msg), ...]``
39 for the last build, so the startup REPL can surface a notice.
40
41Diagnostics
42-----------
43Domain: OC_CHAT.TOOLS
44Levels:
45 L2 — lifecycle events
46 L3 — semantic details
47 L4 — deep tracing
48
49Contracts
50---------
51- ``ImportError`` on a filesystem tool module is swallowed silently
52 (kept for backwards-compat: a missing optional dep should not break
53 the registry).
54- ``ImportError`` on an entry-point module is logged at L1 with
55 ``override=True``, the plugin is skipped, and the failure is recorded
56 in :func:`failed_plugins` so a presenter can surface a notice.
57- A name collision between any two sources (filesystem ↔ filesystem,
58 filesystem ↔ entry-point, entry-point ↔ entry-point) raises
59 :class:`ValueError` with both source identifiers in the message.
60- Return shape ``(dict, list)`` is the public contract used by
61 :func:`oc_chat.main.main` and any future :class:`Agent` builder.
62- Filesystem-pass order follows ``sorted(...)`` and runs first, so
63 entry-point tools always lose deterministically if a collision is
64 somehow legal in future (currently they raise instead).
65
66Dependencies
67------------
68- oc_diagnostics (structured debug logging via _dbg)
69- importlib.metadata (stdlib, entry-point discovery)
70- oc_chat.core.risk_profile (lazy; resolve_risk_profile gates the entry-point pass to the permissive profile)
71- oc_chat.tools.ask_user (not imported; allowlisted tool module loaded by name for its TOOLS and SCHEMAS)
72- oc_chat.tools.filesystem (not imported; allowlisted tool module loaded by name for its TOOLS and SCHEMAS)
73- oc_chat.tools.memory (not imported; allowlisted tool module loaded by name for its TOOLS and SCHEMAS)
74- oc_chat.tools.oct_tools (not imported; allowlisted tool module loaded by name for its TOOLS and SCHEMAS)
75- oc_chat.tools.planner (not imported; allowlisted tool module loaded by name for its TOOLS and SCHEMAS)
76- oc_chat.tools.shell (not imported; allowlisted tool module loaded by name for its TOOLS and SCHEMAS)
77- oc_chat.tools.web (not imported; allowlisted tool module loaded by name for its TOOLS and SCHEMAS)
78
79See Also
80--------
81- oc_chat/docs/conventions/TOOL_CONTRACT.md — full authoring contract.
82- oc_chat/docs/conventions/CONTRACTS.md §3 — the ``TOOLS`` / ``SCHEMAS`` shape.
83"""
84from __future__ import annotations
85
86import importlib
87import importlib.metadata
88from pathlib import Path
89from types import ModuleType
90from typing import Any
91
92from oc_diagnostics import _dbg
93
94_dbg("OC_CHAT.TOOLS", "module", "loaded", 4)
95
96ENTRY_POINT_GROUP = "oc_chat.tools"
97
98_failed_plugins: list[tuple[str, str]] = []
99"""Entry-point name + error message for each failed plugin import in the last build."""
100
101
102def failed_plugins() -> list[tuple[str, str]]:
103 """Return ``[(entry_point_name, error_message), ...]`` from the last build.
104
105 Empty when every plugin loaded cleanly. Callers (typically the startup
106 flow) surface a notice via ``presenter.show_info`` when this is non-empty.
107 """
108 func = "failed_plugins"
109 _dbg("OC_CHAT.TOOLS", func, f"count={len(_failed_plugins)}", 4)
110 return list(_failed_plugins)
111
112
114 mod: ModuleType,
115 source: str,
116 registry: dict[str, Any],
117 schemas: list[dict],
118 tool_sources: dict[str, str],
119) -> None:
120 """Merge ``mod.TOOLS`` + ``mod.SCHEMAS`` into the accumulators.
121
122 Raises ``ValueError`` on any name collision, with both source
123 identifiers in the message so the offending plugin is unambiguous.
124 """
125 func = "_merge_module_tools"
126 mod_tools = getattr(mod, "TOOLS", None)
127 mod_schemas = getattr(mod, "SCHEMAS", None)
128 if mod_tools:
129 for name in mod_tools:
130 if name in registry:
131 raise ValueError(
132 f"Tool name collision: '{name}' is registered by "
133 f"{tool_sources[name]} and also by {source}. "
134 f"Tool names must be globally unique across the "
135 f"filesystem scan and every entry point."
136 )
137 registry[name] = mod_tools[name]
138 tool_sources[name] = source
139 if mod_schemas:
140 schemas.extend(mod_schemas)
141 _dbg(
142 "OC_CHAT.TOOLS",
143 func,
144 f"merged {source}: tools={len(mod_tools or {})} schemas={len(mod_schemas or [])}",
145 3,
146 )
147
148
149#: M8.0 WU-6 / G-2 — the explicit first-party tool-module allowlist (replaces the
150#: legacy ``tools/*.py`` glob). Every module here declares ``TOOLS``/``SCHEMAS``;
151#: an agent-written ``oc_chat/tools/*.py`` is NOT on the list, so it is never
152#: auto-imported. Adding a new first-party tool module requires adding its stem here.
153_TOOL_MODULE_ALLOWLIST: tuple[str, ...] = (
154 "ask_user", "filesystem", "memory", "oct_tools", "planner", "shell", "web",
155)
156
157
158def build_tool_registry() -> tuple[dict, list]:
159 """Build the tool registry from the explicit module allowlist and entry points.
160
161 First-party pass imports the explicit :data:`_TOOL_MODULE_ALLOWLIST` (M8.0
162 WU-6 / G-2 — no ``tools/*.py`` glob, so an agent-written tool module is not
163 auto-imported); entry-point pass scans ``[project.entry-points."oc_chat.tools"]``
164 (Phase K.1b) **only under the permissive profile** (G-3: enforcing profiles
165 deny untrusted plugin code at startup). Both must declare ``TOOLS`` (dict) and
166 ``SCHEMAS`` (list) at module level.
167
168 Returns ``(registry, schemas)``. Raises :class:`ValueError` on any
169 tool-name collision. Entry-point import failures are logged at L1
170 and exposed via :func:`failed_plugins`.
171 """
172 func = "build_tool_registry"
173 registry: dict = {}
174 schemas: list = []
175 tool_sources: dict[str, str] = {}
176
177 _failed_plugins.clear()
178
179 # ── First-party pass: an EXPLICIT module allowlist (M8.0 WU-6 / G-2) ──────
180 # Deterministic registry: NOT a ``tools/*.py`` glob. An agent that writes a new
181 # ``oc_chat/tools/<x>.py`` does not get it imported or registered — that auto-glob
182 # was the primary F57 in-process-ACE vector, live even inside the sandbox / under
183 # permissive. A NEW legitimate tool module must be ADDED to
184 # :data:`_TOOL_MODULE_ALLOWLIST` (the deliberate deterministic-registry discipline).
185 for stem in _TOOL_MODULE_ALLOWLIST:
186 mod_name = f"oc_chat.tools.{stem}"
187 try:
188 mod = importlib.import_module(mod_name)
189 except ImportError as e:
190 # VALIDATION_ERROR — tool's optional dep missing; skip and continue.
191 _dbg("OC_CHAT.TOOLS", func, f"VALIDATION_ERROR: {mod_name}: {e}", 3)
192 continue
194 mod,
195 source=f"allowlist:{mod_name}",
196 registry=registry,
197 schemas=schemas,
198 tool_sources=tool_sources,
199 )
200
201 # ── Entry-point pass (Phase K.1b) — profile-gated (M8.0 WU-6 / G-3) ──────
202 # Under guarded/isolated, do NOT ``ep.load()`` untrusted third-party plugin code
203 # at startup (deny-by-default; an operator-trust custody record is M8.3 / G-6).
204 # permissive loads plugins as before (dogfooding accepts the in-process residual).
205 from oc_chat.core.risk_profile import resolve_risk_profile
206 if resolve_risk_profile() != "permissive":
207 _dbg("OC_CHAT.TOOLS", func,
208 "entry-point plugin pass skipped under an enforcing profile (G-3)", 2)
209 else:
210 try:
211 eps = importlib.metadata.entry_points(group=ENTRY_POINT_GROUP)
212 except TypeError:
213 # Python < 3.10 returned dict-of-list; we require 3.10+ but be defensive
214 eps = importlib.metadata.entry_points().get(ENTRY_POINT_GROUP, [])
215
216 for ep in eps:
217 try:
218 mod = ep.load()
219 except Exception as e:
220 # Plugin failed to import — log L1 (visible at default verbosity),
221 # record for startup surfacing, and skip. Never crash OC Chat
222 # because a third-party plugin is broken.
223 err = f"{type(e).__name__}: {e}"
224 _failed_plugins.append((ep.name, err))
225 _dbg(
226 "OC_CHAT.TOOLS",
227 func,
228 f"VALIDATION_ERROR: entry-point '{ep.name}' failed to load: {err}",
229 1,
230 override=True,
231 )
232 continue
234 mod,
235 source=f"entry-point:{ep.name}",
236 registry=registry,
237 schemas=schemas,
238 tool_sources=tool_sources,
239 )
240 _dbg("OC_CHAT.TOOLS", func, f"loaded entry-point {ep.name}", 2)
241
242 _dbg(
243 "OC_CHAT.TOOLS",
244 func,
245 f"discovered tools={len(registry)} schemas={len(schemas)} failed_plugins={len(_failed_plugins)}",
246 2,
247 )
248 return registry, schemas
list[tuple[str, str]] failed_plugins()
tuple[dict, list] build_tool_registry()
None _merge_module_tools(ModuleType mod, str source, dict[str, Any] registry, list[dict] schemas, dict[str, str] tool_sources)