-
Notifications
You must be signed in to change notification settings - Fork 1
ENG-856 - Agent tool derivation namespaces operation ids itself #280
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
837f0c8
8dc9775
a77e7c2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -77,25 +77,61 @@ def _validate_agent_tools(api: FastAPI) -> None: | |
| # The provider logs component-fn errors instead of raising, so derived tools | ||
| # cannot refuse boot; validate the routes before derivation. Inclusion is | ||
| # deferred (api.routes holds unresolved routers), so the contexts iterator | ||
| # is the one view with every route's merged tags — and the loader tags each | ||
| # extension route with its extension's name, so the tag names the owner. | ||
| extension_names = {extension.name for extension in iter_extensions()} | ||
|
|
||
| # is the one view with every route's merged tags. Validation owns only the | ||
| # two demands the author owns — an explicit operation_id and a non-empty | ||
| # docstring; the extension prefix is the framework's to derive, not the | ||
| # author's to repeat (see _namespace_agent_operations). | ||
| for route in iter_route_contexts(api.routes): | ||
| if not isinstance(route.original_route, APIRoute) or "agent" not in route.tags: | ||
| continue | ||
|
|
||
| where = f"{'/'.join(sorted(route.methods or ()))} {route.path}" | ||
| operation_id = route.operation_id | ||
| if not operation_id: | ||
| if not route.operation_id: | ||
| raise InvalidAgentToolError(where, "an explicit operation_id is required") | ||
| if not inspect.getdoc(route.endpoint): | ||
| raise InvalidAgentToolError(where, "a non-empty endpoint docstring is required") | ||
| extension = next((tag for tag in route.tags if tag in extension_names), None) | ||
| if extension and not operation_id.startswith(f"{extension}_"): | ||
| raise InvalidAgentToolError( | ||
| where, f"operation_id {operation_id!r} must start with {extension + '_'!r}" | ||
| ) | ||
|
|
||
|
|
||
| def _namespace_agent_operations(spec: dict, extension_names: set[str]) -> None: | ||
| # Derive each extension-owned agent operation's id to f"{extension}_{operation_id}", | ||
| # so the tool name the provider reads off the spec is namespaced without the | ||
| # author repeating the prefix. The loader tags every extension route with its | ||
| # extension's name, so among an agent operation's tags the one naming an | ||
| # installed extension is the owner; platform agent operations carry no such | ||
| # tag and keep their declared ids. An already-prefixed id passes through, so | ||
| # stable names like ship_start never double — and the namespace is what makes | ||
| # the merged document's operation ids globally unique. | ||
| for operations in spec.get("paths", {}).values(): | ||
| for operation in operations.values(): | ||
| if not isinstance(operation, dict) or "agent" not in operation.get("tags", []): | ||
| continue | ||
| operation_id = operation.get("operationId") | ||
| if not operation_id: | ||
| continue | ||
| extension = next((tag for tag in operation["tags"] if tag in extension_names), None) | ||
| if extension and not operation_id.startswith(f"{extension}_"): | ||
| operation["operationId"] = f"{extension}_{operation_id}" | ||
|
|
||
|
|
||
| def _install_agent_namespacing(api: FastAPI) -> None: | ||
| # The tool name comes from the spec's operation id, so the namespace must | ||
| # land on the document api.openapi() builds — not on FastAPI's cached, merged | ||
| # route contexts, which later generation silently discards. Wrap the app's | ||
| # own generator so the provider here and every later /openapi.json share one | ||
| # namespaced document: each fresh build (including after the openapi_schema | ||
| # reset below) re-derives the ids, and the pass-through check keeps a warm | ||
| # cache idempotent. | ||
| extension_names = {extension.name for extension in iter_extensions()} | ||
| generate = api.openapi | ||
|
|
||
| def namespaced() -> dict: | ||
| if api.openapi_schema: | ||
| return api.openapi_schema | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Low, non-blocking (verification lens open finding): |
||
| spec = generate() | ||
| _namespace_agent_operations(spec, extension_names) | ||
| return spec | ||
|
|
||
| api.openapi = namespaced | ||
|
|
||
|
|
||
| def _annotate(route: HTTPRoute, component: object) -> None: | ||
|
|
@@ -110,6 +146,7 @@ def _annotate(route: HTTPRoute, component: object) -> None: | |
|
|
||
| def create_mcp_app(api: FastAPI) -> StarletteWithLifespan: | ||
| _validate_agent_tools(api) | ||
| _install_agent_namespacing(api) | ||
| # Built directly rather than via from_fastapi, which owns the transport: | ||
| # raise_app_exceptions=False makes an app crash reach the tool as the | ||
| # app's sanitized 500, so no masking is needed and the taxonomy travels. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Low, non-blocking (code-review lens):
_namespace_agent_operationscurrently has exactly one caller (_install_agent_namespacing). It's defensible here since it separates spec-mutation policy from app-wrapping mechanics, but worth a second look if the repo generally avoids single-caller helpers.