From c4d2aa2f01594b50e152ddc5a87ac48035e1e1dc Mon Sep 17 00:00:00 2001 From: colombod Date: Tue, 1 Sep 2026 20:11:14 +0000 Subject: [PATCH 01/39] feat(server-data-ops): add session summary + delete tool (library + thin module) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the server-data-ops tool the same way as the query tool: the logic lives in the shared context_intelligence library, and the module is a thin wrap over it. Library (context_intelligence/client.py), on both CIClient and AsyncCIClient: - session_summary(session_id) -> GET /sessions/{id}/summary (the preview facts) - delete_session(session_id) -> DELETE /sessions/{id} (the result counts) Same request build and CIClientError translation as cypher()/fetch_blob(); no workspace, no apply, no retry -- matches the server. A 404 (unknown session) and 409 (still receiving data, or id ambiguous across workspaces) keep their status code so the tool can explain them. Module (modules/tool-server-data-ops/), sibling of the query tool: mount() builds one shared ToolConfigResolver and mounts two thin tools. Each resolves which server to talk to with the query tool's own resolve_query_connection (reused unchanged: own sources config or the hook's destinations, works with no hook, source= selection, fail-loud on ambiguity, list_sources to discover), then calls the library. The module never reaches the server directly -- the only path is through the library's client. Proven: module tests 54 passed; repo suite 773 passed; ruff + pyright clean. Part of context-intelligence session data delete (bundle, B1). 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- context_intelligence/client.py | 262 +++++ .../__init__.py | 43 + .../delete_session_tool.py | 210 ++++ .../session_summary_tool.py | 213 ++++ modules/tool-server-data-ops/pyproject.toml | 53 + .../tool-server-data-ops/tests/__init__.py | 0 .../tool-server-data-ops/tests/conftest.py | 22 + .../tests/test_delete_session_tool.py | 450 +++++++++ .../tool-server-data-ops/tests/test_module.py | 319 ++++++ .../tests/test_session_summary_tool.py | 443 +++++++++ modules/tool-server-data-ops/uv.lock | 923 ++++++++++++++++++ tests/test_client.py | 384 ++++++++ 12 files changed, 3322 insertions(+) create mode 100644 modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/__init__.py create mode 100644 modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/delete_session_tool.py create mode 100644 modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/session_summary_tool.py create mode 100644 modules/tool-server-data-ops/pyproject.toml create mode 100644 modules/tool-server-data-ops/tests/__init__.py create mode 100644 modules/tool-server-data-ops/tests/conftest.py create mode 100644 modules/tool-server-data-ops/tests/test_delete_session_tool.py create mode 100644 modules/tool-server-data-ops/tests/test_module.py create mode 100644 modules/tool-server-data-ops/tests/test_session_summary_tool.py create mode 100644 modules/tool-server-data-ops/uv.lock diff --git a/context_intelligence/client.py b/context_intelligence/client.py index 338d4518..fe3f6e53 100644 --- a/context_intelligence/client.py +++ b/context_intelligence/client.py @@ -250,6 +250,102 @@ def _http_get_strict(url: str, headers: dict[str, str]) -> Any: ) from exc +def _http_delete_strict(url: str, headers: dict[str, str]) -> Any: + """DELETE *url* with *headers*, classifying-and-RAISING ``CIClientError`` on failure. + + Same shape as ``_http_get_strict`` (see its docstring for the full rule set), + just using the DELETE HTTP verb instead of GET. Used by + ``CIClient.delete_session()`` so a down / slow / rejecting server can never + masquerade as a completed delete. + + Library preference mirrors ``_http_get_strict`` (requests -> httpx -> + urllib.request). Returns the parsed JSON body on a 2xx response. + + Raises + ------ + CIClientError + error_type one of: ``connection_error`` (refused/DNS/reset), + ``timeout``, ``http_status`` (non-2xx; ``status_code`` set -- this is + how a 404 "unknown session" or a 409 "still receiving data / ambiguous + id" reaches the caller), or ``decode_error`` (body is not valid JSON). + """ + if _requests is not None: + try: + resp = _requests.delete(url, headers=headers, timeout=30) + resp.raise_for_status() + return resp.json() + except _requests.exceptions.Timeout as exc: + raise CIClientError(f"timeout deleting {url}", error_type="timeout", url=url) from exc + except _requests.exceptions.HTTPError as exc: + status = getattr(getattr(exc, "response", None), "status_code", None) + raise CIClientError( + f"HTTP {status} from {url}", + error_type="http_status", + url=url, + status_code=status, + ) from exc + except (ValueError, json.JSONDecodeError) as exc: # resp.json() failed + raise CIClientError( + f"malformed JSON from {url}", error_type="decode_error", url=url + ) from exc + except _requests.exceptions.RequestException as exc: # ConnectionError, etc. + raise CIClientError( + f"connection error to {url}: {exc}", error_type="connection_error", url=url + ) from exc + + if _httpx is not None: + try: + with _httpx.Client(timeout=30) as client: + resp = client.delete(url, headers=headers) + resp.raise_for_status() + return resp.json() + except _httpx.TimeoutException as exc: + raise CIClientError(f"timeout deleting {url}", error_type="timeout", url=url) from exc + except _httpx.HTTPStatusError as exc: + raise CIClientError( + f"HTTP {exc.response.status_code} from {url}", + error_type="http_status", + url=url, + status_code=exc.response.status_code, + ) from exc + except (ValueError, json.JSONDecodeError) as exc: # resp.json() failed + raise CIClientError( + f"malformed JSON from {url}", error_type="decode_error", url=url + ) from exc + except _httpx.HTTPError as exc: # ConnectError, transport, etc. + raise CIClientError( + f"connection error to {url}: {exc}", error_type="connection_error", url=url + ) from exc + + # stdlib fallback + try: + req = urllib.request.Request(url, headers=headers, method="DELETE") + with urllib.request.urlopen(req, timeout=30) as resp: + raw = resp.read().decode("utf-8") + except urllib.error.HTTPError as exc: # subclass of URLError -- catch FIRST + raise CIClientError( + f"HTTP {exc.code} from {url}", + error_type="http_status", + url=url, + status_code=exc.code, + ) from exc + except (TimeoutError, socket.timeout) as exc: # read timeout + raise CIClientError(f"timeout deleting {url}", error_type="timeout", url=url) from exc + except urllib.error.URLError as exc: + # A URLError may wrap a socket timeout in .reason -- classify that as timeout. + if isinstance(exc.reason, (TimeoutError, socket.timeout)): + raise CIClientError(f"timeout deleting {url}", error_type="timeout", url=url) from exc + raise CIClientError( + f"connection error to {url}: {exc}", error_type="connection_error", url=url + ) from exc + try: + return json.loads(raw) + except (ValueError, json.JSONDecodeError) as exc: + raise CIClientError( + f"malformed JSON from {url}", error_type="decode_error", url=url + ) from exc + + # --------------------------------------------------------------------------- # Safe JSON parse # --------------------------------------------------------------------------- @@ -517,6 +613,68 @@ def fetch_blob(self, session_id: str, key: str) -> Any | None: url = f"{self._server_url}/blobs/{session_id}/{key}" return _http_get(url, self._auth_headers()) + def session_summary(self, session_id: str) -> dict[str, Any]: + """Fetch the preview facts for a session (read, no changes made). + + Calls ``GET /sessions/{session_id}/summary`` and returns the parsed + JSON dict: the facts about the whole session graph -- who created it, + how many nodes/edges/blobs it has, when it started and last changed, + whether it is safe to delete, and so on. + + Parameters + ---------- + session_id: + The session to look up. + + Returns + ------- + dict + The parsed summary facts. + + Raises + ------ + CIClientError + The request genuinely failed: connection error/refused, timeout, + non-2xx HTTP status, or a malformed (non-JSON) body. A 404 means + the session id is not known to the server; a 409 means the + session is still receiving data or the id is ambiguous across + workspaces -- ``status_code`` carries the exact number so the + caller can give a clear message. + """ + url = f"{self._server_url}/sessions/{session_id}/summary" + return _http_get_strict(url, self._auth_headers()) + + def delete_session(self, session_id: str) -> dict[str, Any]: + """Delete a session's whole graph from the server (a real, permanent change). + + Calls ``DELETE /sessions/{session_id}``. There is no workspace input and + no "preview only" flag -- the server resolves the session by id and this + always performs the delete. Returns the parsed JSON dict with the result + counts (how many nodes/relationships/blobs were removed, and so on). + + Parameters + ---------- + session_id: + The session to delete. + + Returns + ------- + dict + The parsed result counts. + + Raises + ------ + CIClientError + The request genuinely failed: connection error/refused, timeout, + non-2xx HTTP status, or a malformed (non-JSON) body. A 404 means + the session id is not known to the server; a 409 means the + session is still receiving data (not safe to delete yet) or the + id is ambiguous across workspaces -- ``status_code`` carries the + exact number so the caller can give a clear message. + """ + url = f"{self._server_url}/sessions/{session_id}" + return _http_delete_strict(url, self._auth_headers()) + def health_check(self) -> dict[str, Any]: """Check server health by running a simple count query. @@ -773,6 +931,110 @@ async def list_blob_keys(self, session_id: str) -> set[str]: return _parse_blob_keys(result) + async def session_summary(self, session_id: str) -> dict[str, Any]: + """Fetch the preview facts for a session (read, no changes made; async). + + Calls ``GET /sessions/{session_id}/summary`` and returns the parsed + JSON dict: the facts about the whole session graph -- who created it, + how many nodes/edges/blobs it has, when it started and last changed, + whether it is safe to delete, and so on. + + Parameters + ---------- + session_id: + The session to look up. + + Returns + ------- + dict + The parsed summary facts. + + Raises + ------ + CIClientError + The request genuinely failed: connection error/refused, timeout, + non-2xx HTTP status, or a malformed (non-JSON) body. A 404 means + the session id is not known to the server; a 409 means the + session is still receiving data or the id is ambiguous across + workspaces -- ``status_code`` carries the exact number so the + caller can give a clear message. + """ + url = f"{self._server_url}/sessions/{session_id}/summary" + try: + async with httpx.AsyncClient(timeout=self._timeout) as client: # type: ignore[union-attr] + resp = await client.get(url, headers=self._strategy.headers()) + resp.raise_for_status() + return resp.json() + except httpx.TimeoutException as exc: # type: ignore[union-attr] + raise CIClientError(f"timeout fetching {url}", error_type="timeout", url=url) from exc + except httpx.HTTPStatusError as exc: # type: ignore[union-attr] + raise CIClientError( + f"HTTP {exc.response.status_code} from {url}", + error_type="http_status", + url=url, + status_code=exc.response.status_code, + ) from exc + except (ValueError, json.JSONDecodeError) as exc: # resp.json() failed + raise CIClientError( + f"malformed JSON from {url}", error_type="decode_error", url=url + ) from exc + except httpx.HTTPError as exc: # type: ignore[union-attr] # ConnectError, transport, etc. + raise CIClientError( + f"connection error to {url}: {exc}", error_type="connection_error", url=url + ) from exc + + async def delete_session(self, session_id: str) -> dict[str, Any]: + """Delete a session's whole graph from the server (a real, permanent change; async). + + Calls ``DELETE /sessions/{session_id}``. There is no workspace input and + no "preview only" flag -- the server resolves the session by id and this + always performs the delete. Returns the parsed JSON dict with the result + counts (how many nodes/relationships/blobs were removed, and so on). + + Parameters + ---------- + session_id: + The session to delete. + + Returns + ------- + dict + The parsed result counts. + + Raises + ------ + CIClientError + The request genuinely failed: connection error/refused, timeout, + non-2xx HTTP status, or a malformed (non-JSON) body. A 404 means + the session id is not known to the server; a 409 means the + session is still receiving data (not safe to delete yet) or the + id is ambiguous across workspaces -- ``status_code`` carries the + exact number so the caller can give a clear message. + """ + url = f"{self._server_url}/sessions/{session_id}" + try: + async with httpx.AsyncClient(timeout=self._timeout) as client: # type: ignore[union-attr] + resp = await client.delete(url, headers=self._strategy.headers()) + resp.raise_for_status() + return resp.json() + except httpx.TimeoutException as exc: # type: ignore[union-attr] + raise CIClientError(f"timeout deleting {url}", error_type="timeout", url=url) from exc + except httpx.HTTPStatusError as exc: # type: ignore[union-attr] + raise CIClientError( + f"HTTP {exc.response.status_code} from {url}", + error_type="http_status", + url=url, + status_code=exc.response.status_code, + ) from exc + except (ValueError, json.JSONDecodeError) as exc: # resp.json() failed + raise CIClientError( + f"malformed JSON from {url}", error_type="decode_error", url=url + ) from exc + except httpx.HTTPError as exc: # type: ignore[union-attr] # ConnectError, transport, etc. + raise CIClientError( + f"connection error to {url}: {exc}", error_type="connection_error", url=url + ) from exc + async def health_check(self) -> dict[str, Any]: """Check server health by running a simple count query (async). diff --git a/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/__init__.py b/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/__init__.py new file mode 100644 index 00000000..38870a55 --- /dev/null +++ b/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/__init__.py @@ -0,0 +1,43 @@ +"""Context Intelligence server data-ops tools -- session_summary and delete_session. + +Both tools share one ToolConfigResolver, so sources has a single +config namespace: overrides.tool-server-data-ops.config.sources. + +Two tools, one mount(): idiomatic multi-tool module (same shape as +tool-context-intelligence-query, which mounts graph_query / blob_read from one +mount() call). +""" + +from __future__ import annotations + +from typing import Any + +__amplifier_module_type__ = "tool" +__all__ = ["mount"] + + +async def mount(coordinator: Any, config: Any) -> None: + """Mount both server-data-ops tools, sharing one ToolConfigResolver. + + The resolver is built ONCE from the module's config and injected into + both tools. Tool constructors do not accept config -- the resolver IS + the config surface. + + The hook resolver is NOT fetched here; each tool fetches it lazily at + first execute() because tools mount before hooks (kernel phase order is + orchestrator -> context -> providers -> tools -> hooks -- CONTRACTS.md + section Module Lifecycle Methods). + """ + from context_intelligence.tool_resolver import ToolConfigResolver + + from .delete_session_tool import DeleteSessionTool + from .session_summary_tool import SessionSummaryTool + + resolver = ToolConfigResolver(config or {}, coordinator) # built ONCE + # WARN-only diagnostic pass -- never raises; hard validation is per-source + # at query time (see tool_resolver.py: validate_source()). + resolver.validate_sources() + summary = SessionSummaryTool(coordinator, resolver) + delete = DeleteSessionTool(coordinator, resolver) + await coordinator.mount("tools", summary, name=summary.name) # "session_summary" + await coordinator.mount("tools", delete, name=delete.name) # "delete_session" diff --git a/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/delete_session_tool.py b/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/delete_session_tool.py new file mode 100644 index 00000000..f52ea529 --- /dev/null +++ b/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/delete_session_tool.py @@ -0,0 +1,210 @@ +"""DeleteSessionTool -- agent-facing tool that permanently deletes a session. + +Implements the Amplifier Tool protocol. Configuration and provenance are +resolved via ``resolve_query_connection`` (same as SessionSummaryTool -- parity +guaranteed by the shared helper), a SINGLE-HIT selection over the connectable +pool (tool ``sources`` union hook ``destinations``). See +``resolve_query_connection``'s docstring in context_intelligence/tool_resolver.py +for the authoritative selection rule (in brief: explicit ``source=`` +reaches any pool entry; with no name, 1 source -> it, 2+ sources -> fail loud, +0 sources -> the FIRST destination in config order for any N, else env). + +Every result (success or failure) carries a ``source`` field naming the +endpoint that answered / was attempted. Callers can also pass +``list_sources: true`` to discover the connectable set without deleting +anything. + +The ``ToolConfigResolver`` is injected at construction time by ``mount()`` +(one shared instance for both server-data-ops tools -- single config namespace). + +This tool never talks to the server directly -- the only path to the server +is through ``AsyncCIClient`` (the shared library). This is a REAL, PERMANENT +CHANGE: there is no workspace input and no "preview only" flag on the server +call itself -- the delete always runs against the whole session graph. The +agent using this tool is responsible for showing the user a preview +(session_summary) and getting explicit confirmation FIRST. +""" + +from __future__ import annotations + +from typing import Any + +from amplifier_core.models import ToolResult +from context_intelligence.client import AsyncCIClient, CIClientError +from context_intelligence.tool_resolver import ( + ToolConfigResolver, + _connectable_pool, + _origin_dict, + resolve_query_connection, +) + + +class DeleteSessionTool: + """Permanently delete one session's whole graph from the context-intelligence server. + + Implements the Amplifier Tool protocol (name, description, input_schema, + execute). Configuration and provenance are resolved via + resolve_query_connection() at execute() time, over the connectable pool + (tool sources union the hook's upload destinations). + """ + + def __init__(self, coordinator: Any, resolver: ToolConfigResolver | None = None) -> None: + self._coordinator = coordinator + self._tool_resolver = resolver or ToolConfigResolver({}, coordinator) + self._hook_resolver: Any | None = None + + @property + def name(self) -> str: + return "delete_session" + + @property + def description(self) -> str: + return ( + "Permanently delete one session's whole graph (nodes, relationships, " + "and blobs) from the context-intelligence server. This is a REAL, " + "PERMANENT change -- there is no undo and no preview flag here. " + "ALWAYS call session_summary first to show the user what would be " + "removed, and get their explicit confirmation before calling this. " + "If the session is still receiving data, the server refuses with a " + "clear error rather than deleting a live session. Every result names " + "the `source` (name/url/origin) the delete was sent to -- ALWAYS " + "state it in your answer." + ) + + @property + def input_schema(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": ( + "The id of the session to permanently delete. Required " + "unless list_sources=true." + ), + }, + "source": { + "type": "string", + "description": ( + "Optional name of a specific connectable endpoint (server) to " + "delete from -- either a configured source OR a hook upload " + "destination (the full connectable set; call with " + "list_sources=true to see the names). Omitting `source` uses " + "the default endpoint: the single configured source, or -- if " + "no sources are configured -- the first destination in config " + "order. The only case where omitting it errors is when 2+ " + "SOURCES are configured (then you must pass source=, and " + "the error lists the valid names)." + ), + }, + "list_sources": { + "type": "boolean", + "description": ( + "When true, do NOT delete anything. Return the connectable " + "set -- every server this tool can reach, with name, url, and " + "origin (source/destination). Use this to discover valid " + "`source` values before selecting one." + ), + }, + }, + "required": [], + } + + async def execute(self, input: dict[str, Any]) -> ToolResult: + from context_intelligence.tool_resolver import SourceSelectionError + + # Late-mount upgrade: retry hook capability lookup on every call while + # _hook_resolver is None (hook may mount after the tool). + if self._hook_resolver is None: + self._hook_resolver = self._coordinator.get_capability( + "context_intelligence.hook_config_resolver" + ) + + if input.get("list_sources"): + pool = _connectable_pool(self._tool_resolver, self._hook_resolver) + return ToolResult( + success=True, + output={ + "connectable_set": [ + {"name": e.name, "url": e.url, "origin": e.kind} for e in pool.values() + ] + }, + ) + + source_name = input.get("source") + try: + conn = resolve_query_connection( + self._hook_resolver, self._tool_resolver, source_name=source_name + ) + except SourceSelectionError as exc: + return ToolResult( + success=False, + error={ + "message": str(exc), + "type": exc.error_type, # "unknown_source" | "ambiguous_source_selection" + "valid_sources": exc.valid_names, + }, + ) + except ValueError as exc: + # The selected source itself is misconfigured -- names only it. + return ToolResult( + success=False, + error={"message": str(exc), "type": "source_misconfigured"}, + ) + + if not conn.url: + return ToolResult( + success=False, + error={ + "message": "context-intelligence server URL not configured", + "type": "configuration_error", + }, + ) + + if "session_id" not in input: + # Endpoint already resolved above -> attach provenance: `source` is + # present on failures that occur AFTER an endpoint is chosen. + return ToolResult( + success=False, + error={ + "message": "session_id is required unless list_sources=true", + "type": "validation_error", + "source": _origin_dict(conn.origin), + }, + ) + session_id: str = input["session_id"] + + async_client = AsyncCIClient( + server_url=conn.url, + api_key=conn.api_key or "", + auth_strategy=conn.auth_strategy, + timeout=self._tool_resolver.request_timeout, + ) + try: + result = await async_client.delete_session(session_id) + except CIClientError as exc: + # success=False + output unset is safe: ToolResult.model_post_init + # back-fills output from error["message"] when output is None. Do NOT + # also set output= here or that back-fill is suppressed. + origin_name = conn.origin.name if conn.origin and conn.origin.name else conn.url + message = f"delete failed against {origin_name}: {exc}" + if exc.status_code == 404: + message = f"unknown session {session_id!r} on {origin_name}" + elif exc.status_code == 409: + message = ( + f"session {session_id!r} on {origin_name} is still receiving data " + "and cannot be deleted yet, or the id is ambiguous across workspaces" + ) + return ToolResult( + success=False, + error={ + "message": message, + "type": exc.error_type, # connection_error|timeout|http_status|decode_error + "source": _origin_dict(conn.origin), + **({"status_code": exc.status_code} if exc.status_code is not None else {}), + }, + ) + return ToolResult( + success=True, + output={"source": _origin_dict(conn.origin), "result": result}, + ) diff --git a/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/session_summary_tool.py b/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/session_summary_tool.py new file mode 100644 index 00000000..ff644d59 --- /dev/null +++ b/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/session_summary_tool.py @@ -0,0 +1,213 @@ +"""SessionSummaryTool -- agent-facing tool for previewing a session before delete. + +Implements the Amplifier Tool protocol. Configuration and provenance are +resolved via ``resolve_query_connection`` -- a SINGLE-HIT selection over the +connectable pool (tool ``sources`` union hook ``destinations``), the exact same +helper the read tools (graph_query, blob_read) use: + + 1. Explicit ``source=`` -- resolves against the WHOLE pool (can name a + tool source OR a hook upload destination). + 2. No name -- default semantics: 1 source -> use it; 2+ sources -> fail loud + (the ONLY default-path fail-loud); 0 sources + N destinations -> use the + FIRST destination in config order; 0 of either -> env (tier 3). See + ``resolve_query_connection``'s docstring in + context_intelligence/tool_resolver.py for the authoritative rule. + +Every result (success or failure) carries a ``source`` field naming the +endpoint that answered / was attempted, so which endpoint served a +default-path pick is always visible to the user. Callers can also pass +``list_sources: true`` to discover the connectable set without calling the +server. + +The hook resolver is fetched lazily at first ``execute()`` call so that late +mount order is handled correctly (tools mount before hooks). + +The ``ToolConfigResolver`` is injected at construction time by ``mount()`` +(one shared instance for both server-data-ops tools -- single config namespace). + +This tool never talks to the server directly -- the only path to the server +is through ``AsyncCIClient`` (the shared library). This is a READ (preview, +no changes made): it fetches the facts about a session so the agent can show +the user what would be removed before it asks about the actual delete. +""" + +from __future__ import annotations + +from typing import Any + +from amplifier_core.models import ToolResult +from context_intelligence.client import AsyncCIClient, CIClientError +from context_intelligence.tool_resolver import ( + ToolConfigResolver, + _connectable_pool, + _origin_dict, + resolve_query_connection, +) + + +class SessionSummaryTool: + """Fetch the preview facts for a session from the context-intelligence server. + + Implements the Amplifier Tool protocol (name, description, input_schema, + execute). Configuration and provenance are resolved via + resolve_query_connection() at execute() time, over the connectable pool + (tool sources union the hook's upload destinations). + """ + + def __init__(self, coordinator: Any, resolver: ToolConfigResolver | None = None) -> None: + self._coordinator = coordinator + self._tool_resolver = resolver or ToolConfigResolver({}, coordinator) + self._hook_resolver: Any | None = None + + @property + def name(self) -> str: + return "session_summary" + + @property + def description(self) -> str: + return ( + "Fetch the preview facts for one session from the context-intelligence " + "server: who created it, how many nodes/edges/blobs it has, when it " + "started and last changed, and whether it is safe to delete right now. " + "This makes NO changes -- it is a read, always call it BEFORE " + "delete_session so the user can see what would be removed. Every result " + "names the `source` (name/url/origin) that answered -- ALWAYS state it " + "in your answer." + ) + + @property + def input_schema(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": ( + "The id of the session to preview. Required unless list_sources=true." + ), + }, + "source": { + "type": "string", + "description": ( + "Optional name of a specific connectable endpoint (server) to " + "ask -- either a configured source OR a hook upload destination " + "(the full connectable set; call with list_sources=true to see " + "the names). Omitting `source` uses the default endpoint: the " + "single configured source, or -- if no sources are configured -- " + "the first destination in config order. The only case where " + "omitting it errors is when 2+ SOURCES are configured (then you " + "must pass source=, and the error lists the valid names)." + ), + }, + "list_sources": { + "type": "boolean", + "description": ( + "When true, do NOT look up a session. Return the connectable " + "set -- every server this tool can reach, with name, url, and " + "origin (source/destination). Use this to discover valid " + "`source` values, or to tell the user which servers a session " + "could be on, before selecting one." + ), + }, + }, + "required": [], + } + + async def execute(self, input: dict[str, Any]) -> ToolResult: + from context_intelligence.tool_resolver import SourceSelectionError + + # Late-mount upgrade: retry hook capability lookup on every call while + # _hook_resolver is None (hook may mount after the tool). + if self._hook_resolver is None: + self._hook_resolver = self._coordinator.get_capability( + "context_intelligence.hook_config_resolver" + ) + + if input.get("list_sources"): + pool = _connectable_pool(self._tool_resolver, self._hook_resolver) + return ToolResult( + success=True, + output={ + "connectable_set": [ + {"name": e.name, "url": e.url, "origin": e.kind} for e in pool.values() + ] + }, + ) + + source_name = input.get("source") + try: + conn = resolve_query_connection( + self._hook_resolver, self._tool_resolver, source_name=source_name + ) + except SourceSelectionError as exc: + return ToolResult( + success=False, + error={ + "message": str(exc), + "type": exc.error_type, # "unknown_source" | "ambiguous_source_selection" + "valid_sources": exc.valid_names, + }, + ) + except ValueError as exc: + # The selected source itself is misconfigured -- names only it. + return ToolResult( + success=False, + error={"message": str(exc), "type": "source_misconfigured"}, + ) + + if not conn.url: + return ToolResult( + success=False, + error={ + "message": "context-intelligence server URL not configured", + "type": "configuration_error", + }, + ) + + if "session_id" not in input: + # Endpoint already resolved above -> attach provenance: `source` is + # present on failures that occur AFTER an endpoint is chosen. + return ToolResult( + success=False, + error={ + "message": "session_id is required unless list_sources=true", + "type": "validation_error", + "source": _origin_dict(conn.origin), + }, + ) + session_id: str = input["session_id"] + + async_client = AsyncCIClient( + server_url=conn.url, + api_key=conn.api_key or "", + auth_strategy=conn.auth_strategy, + timeout=self._tool_resolver.request_timeout, + ) + try: + summary = await async_client.session_summary(session_id) + except CIClientError as exc: + # success=False + output unset is safe: ToolResult.model_post_init + # back-fills output from error["message"] when output is None. Do NOT + # also set output= here or that back-fill is suppressed. + origin_name = conn.origin.name if conn.origin and conn.origin.name else conn.url + message = f"session lookup failed against {origin_name}: {exc}" + if exc.status_code == 404: + message = f"unknown session {session_id!r} on {origin_name}" + elif exc.status_code == 409: + message = ( + f"session {session_id!r} on {origin_name} is still receiving data, " + "or the id is ambiguous across workspaces" + ) + return ToolResult( + success=False, + error={ + "message": message, + "type": exc.error_type, # connection_error|timeout|http_status|decode_error + "source": _origin_dict(conn.origin), + **({"status_code": exc.status_code} if exc.status_code is not None else {}), + }, + ) + return ToolResult( + success=True, + output={"source": _origin_dict(conn.origin), "summary": summary}, + ) diff --git a/modules/tool-server-data-ops/pyproject.toml b/modules/tool-server-data-ops/pyproject.toml new file mode 100644 index 00000000..c1890b87 --- /dev/null +++ b/modules/tool-server-data-ops/pyproject.toml @@ -0,0 +1,53 @@ +[project] +name = "amplifier-module-tool-server-data-ops" +version = "0.1.0" +description = "CI server data-ops tools -- session_summary (preview) and delete_session (permanent delete) against the context-intelligence server" +requires-python = ">=3.11" +license = "MIT" + +dependencies = [ + "amplifier-bundle-context-intelligence @ git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main", + "httpx>=0.28.1", + "idna>=3.15", +] + +[project.entry-points."amplifier.modules"] +tool-server-data-ops = "amplifier_module_tool_server_data_ops:mount" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.uv] +package = true + +[tool.hatch.build.targets.wheel] +packages = ["amplifier_module_tool_server_data_ops"] + +[tool.hatch.metadata] +# Required to build a wheel that carries a PEP 508 direct-reference (git+https) dependency. +allow-direct-references = true + +[dependency-groups] +dev = [ + "amplifier-core>=1.6.0", + "pytest>=9.0.3", + "pytest-asyncio>=0.24", + "pyright>=1.1.411", + "ruff>=0.14", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" + +[tool.pyright] +pythonVersion = "3.11" +typeCheckingMode = "basic" +venvPath = "." +venv = ".venv" +extraPaths = ["../.."] + +[tool.ruff] +target-version = "py311" +line-length = 100 diff --git a/modules/tool-server-data-ops/tests/__init__.py b/modules/tool-server-data-ops/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modules/tool-server-data-ops/tests/conftest.py b/modules/tool-server-data-ops/tests/conftest.py new file mode 100644 index 00000000..ede4441f --- /dev/null +++ b/modules/tool-server-data-ops/tests/conftest.py @@ -0,0 +1,22 @@ +"""Shared pytest configuration and fixtures for tool-server-data-ops tests.""" + +from __future__ import annotations + +from typing import Any + +import pytest + + +@pytest.fixture(autouse=True) +def _reset_auth_singleton() -> Any: + """Clear the auth module singleton and token cache before/after each test. + + Ensures the process-level _singleton_credential and _MODULE_CACHE do not + leak between tests, so patches of _make_cli_credential are effective and + cached tokens from one test don't pollute the next. + """ + from context_intelligence import auth as _auth_mod + + _auth_mod.reset() + yield + _auth_mod.reset() diff --git a/modules/tool-server-data-ops/tests/test_delete_session_tool.py b/modules/tool-server-data-ops/tests/test_delete_session_tool.py new file mode 100644 index 00000000..d9b40829 --- /dev/null +++ b/modules/tool-server-data-ops/tests/test_delete_session_tool.py @@ -0,0 +1,450 @@ +"""Tests for DeleteSessionTool. + +Constructor: DeleteSessionTool(coordinator, resolver=None). Patch path is +amplifier_module_tool_server_data_ops.delete_session_tool. +""" + +from __future__ import annotations + +import os +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + + +def _make_coordinator(resolver: Any = None) -> MagicMock: + coordinator = MagicMock() + coordinator.config = {} + coordinator.get_capability = MagicMock(return_value=resolver) + return coordinator + + +def _make_hook_resolver( + server_url: str | None = "http://localhost:8080", + workspace: str = "test-workspace", + api_key: str = "test-api-key", +) -> MagicMock: + """Create a hook resolver mock (returned by get_capability).""" + resolver = MagicMock() + resolver.workspace = workspace + if server_url: + resolver.destinations = { + "default": SimpleNamespace(name="default", url=server_url, api_key=api_key or ""), + } + else: + resolver.destinations = {} + return resolver + + +def _make_hook_resolver_with_dests(destinations: dict) -> MagicMock: + """Hook resolver mock with a specific destinations dict.""" + resolver = MagicMock() + resolver.workspace = "test-workspace" + resolver.destinations = destinations + return resolver + + +def _make_mock_async_ci_client(return_value: Any = None): + """Return (mock_instance, mock_cls) for patching AsyncCIClient.""" + mock_instance = AsyncMock() + mock_instance.delete_session = AsyncMock( + return_value=return_value if return_value is not None else {"nodes_deleted": 0} + ) + mock_cls = MagicMock(return_value=mock_instance) + return mock_instance, mock_cls + + +def _make_tool_resolver(config: dict, coordinator: Any = None) -> Any: + """Build a real ToolConfigResolver from a config dict (for injection).""" + from context_intelligence.tool_resolver import ToolConfigResolver + + coord = coordinator or MagicMock() + coord.config = {} + return ToolConfigResolver(config, coord) + + +# --------------------------------------------------------------------------- +# TestDeleteSessionToolProtocol +# --------------------------------------------------------------------------- + + +class TestDeleteSessionToolProtocol: + """Tool protocol surface tests.""" + + def test_name_is_delete_session(self) -> None: + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + tool = DeleteSessionTool(_make_coordinator()) + assert tool.name == "delete_session" + + def test_description_mentions_permanent(self) -> None: + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + tool = DeleteSessionTool(_make_coordinator()) + assert "permanent" in tool.description.lower() + + def test_input_schema_returns_object_type(self) -> None: + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + tool = DeleteSessionTool(_make_coordinator()) + assert tool.input_schema["type"] == "object" + + def test_input_schema_session_id_not_required_but_enforced_at_execute(self) -> None: + """`session_id` is NOT in the JSON-schema `required` list -- list_sources=true + calls legitimately omit it. execute() enforces the rule itself.""" + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + tool = DeleteSessionTool(_make_coordinator()) + assert "session_id" not in tool.input_schema["required"] + assert "session_id" in tool.input_schema["properties"] + + def test_input_schema_has_optional_source_and_list_sources(self) -> None: + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + tool = DeleteSessionTool(_make_coordinator()) + props = tool.input_schema["properties"] + assert "source" in props + assert "list_sources" in props + assert "source" not in tool.input_schema["required"] + assert "list_sources" not in tool.input_schema["required"] + + async def test_execute_returns_tool_result(self) -> None: + from amplifier_core.models import ToolResult + + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + hook_resolver = _make_hook_resolver() + coordinator = _make_coordinator(resolver=hook_resolver) + tool = DeleteSessionTool(coordinator) + + _, mock_cls = _make_mock_async_ci_client() + with patch( + "amplifier_module_tool_server_data_ops.delete_session_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"session_id": "abc"}) + + assert isinstance(result, ToolResult) + + +# --------------------------------------------------------------------------- +# TestListSources +# --------------------------------------------------------------------------- + + +class TestListSources: + async def test_list_sources_does_not_call_client(self) -> None: + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + resolver = _make_tool_resolver( + {"sources": {"only": {"url": "http://only.example.com", "api_key": "k"}}} + ) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = DeleteSessionTool(coordinator, resolver) + + mock_cls = MagicMock() + with patch( + "amplifier_module_tool_server_data_ops.delete_session_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"list_sources": True}) + + assert result.success is True + assert result.output is not None + names = {e["name"] for e in result.output["connectable_set"]} + assert names == {"only"} + mock_cls.assert_not_called() + + +# --------------------------------------------------------------------------- +# TestDeleteSessionConstruction -- AsyncCIClient construction and delegation +# --------------------------------------------------------------------------- + + +class TestDeleteSessionConstruction: + """AsyncCIClient construction and delegation tests (mirrors GraphQueryTool).""" + + async def test_client_constructed_with_server_url_and_api_key(self) -> None: + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000", api_key="my-key") + coordinator = _make_coordinator(resolver=hook_resolver) + tool = DeleteSessionTool(coordinator) + + _, mock_cls = _make_mock_async_ci_client() + with patch( + "amplifier_module_tool_server_data_ops.delete_session_tool.AsyncCIClient", + mock_cls, + ): + await tool.execute({"session_id": "abc"}) + + mock_cls.assert_called_once() + call_kwargs = mock_cls.call_args.kwargs + assert call_kwargs.get("server_url") == "http://ci-server:9000" + assert call_kwargs.get("api_key") == "my-key" + + async def test_session_id_forwarded_to_client_delete_session(self) -> None: + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + hook_resolver = _make_hook_resolver() + coordinator = _make_coordinator(resolver=hook_resolver) + tool = DeleteSessionTool(coordinator) + + mock_instance, mock_cls = _make_mock_async_ci_client() + with patch( + "amplifier_module_tool_server_data_ops.delete_session_tool.AsyncCIClient", + mock_cls, + ): + await tool.execute({"session_id": "the-session-id"}) + + mock_instance.delete_session.assert_called_once_with("the-session-id") + + async def test_result_forwarded_and_source_stamped(self) -> None: + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000") + coordinator = _make_coordinator(resolver=hook_resolver) + tool = DeleteSessionTool(coordinator) + + expected = { + "root_id": "abc", + "nodes_deleted": 42, + "relationships_deleted": 10, + "blobs_deleted": 2, + } + _, mock_cls = _make_mock_async_ci_client(return_value=expected) + with patch( + "amplifier_module_tool_server_data_ops.delete_session_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"session_id": "abc"}) + + assert result.success is True + assert result.output is not None + assert result.output["result"] == expected + assert result.output["source"] is not None + assert result.output["source"]["url"] == "http://ci-server:9000" + + +# --------------------------------------------------------------------------- +# TestDeleteSessionConfigFallback +# --------------------------------------------------------------------------- + + +class TestDeleteSessionConfigFallback: + async def test_capability_not_found_returns_configuration_error(self) -> None: + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + coordinator = _make_coordinator(resolver=None) + tool = DeleteSessionTool(coordinator) + clean = {k: "" for k in os.environ if k.startswith("AMPLIFIER_CONTEXT_INTELLIGENCE_")} + with patch.dict(os.environ, clean): + result = await tool.execute({"session_id": "abc"}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "configuration_error" + + async def test_missing_session_id_validation_error_carries_source(self) -> None: + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + resolver = _make_tool_resolver( + {"sources": {"only": {"url": "http://only.example.com", "api_key": "k"}}} + ) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = DeleteSessionTool(coordinator, resolver) + + result = await tool.execute({}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "validation_error" + assert result.error["source"] == { + "name": "only", + "url": "http://only.example.com", + "origin": "source", + } + + +# --------------------------------------------------------------------------- +# TestDeleteSessionSourceSelection -- pool/selection + fail-loud ambiguity +# --------------------------------------------------------------------------- + + +class TestDeleteSessionSourceSelection: + """execute() with an explicit `source` -- matching / not matching / omitted-with-2+.""" + + def _two_source_config(self) -> dict: + return { + "sources": { + "alpha": {"url": "http://alpha.example.com", "api_key": "alpha-key"}, + "beta": {"url": "http://beta.example.com", "api_key": "beta-key"}, + } + } + + async def test_source_matching_name_selects_that_source(self) -> None: + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + resolver = _make_tool_resolver(self._two_source_config()) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = DeleteSessionTool(coordinator, resolver) + + _, mock_cls = _make_mock_async_ci_client() + with patch( + "amplifier_module_tool_server_data_ops.delete_session_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"session_id": "abc", "source": "beta"}) + + assert result.success is True + call_kwargs = mock_cls.call_args.kwargs + assert call_kwargs["server_url"] == "http://beta.example.com" + assert call_kwargs["api_key"] == "beta-key" + + async def test_source_not_matching_returns_unknown_source_error(self) -> None: + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + resolver = _make_tool_resolver(self._two_source_config()) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = DeleteSessionTool(coordinator, resolver) + + result = await tool.execute({"session_id": "abc", "source": "gamma"}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "unknown_source" + assert result.error["valid_sources"] == ["alpha", "beta"] + + async def test_source_omitted_with_two_configured_returns_ambiguous_error(self) -> None: + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + resolver = _make_tool_resolver(self._two_source_config()) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = DeleteSessionTool(coordinator, resolver) + + result = await tool.execute({"session_id": "abc"}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "ambiguous_source_selection" + assert result.error["valid_sources"] == ["alpha", "beta"] + + async def test_source_omitted_with_one_configured_still_succeeds(self) -> None: + """Safe to omit source with exactly one configured (backward compatible).""" + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + config = { + "sources": { + "default": {"url": "http://only.example.com", "api_key": "only-key"}, + } + } + resolver = _make_tool_resolver(config) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = DeleteSessionTool(coordinator, resolver) + + _, mock_cls = _make_mock_async_ci_client() + with patch( + "amplifier_module_tool_server_data_ops.delete_session_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"session_id": "abc"}) + + assert result.success is True + call_kwargs = mock_cls.call_args.kwargs + assert call_kwargs["server_url"] == "http://only.example.com" + + async def test_selected_source_misconfigured_returns_source_misconfigured_error(self) -> None: + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + config = { + "sources": { + "good": {"url": "http://good.example.com", "api_key": "gk"}, + "bad": {"url": "", "api_key": ""}, + } + } + resolver = _make_tool_resolver(config) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = DeleteSessionTool(coordinator, resolver) + + result = await tool.execute({"session_id": "abc", "source": "bad"}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "source_misconfigured" + assert "bad" in result.error["message"] + + +# --------------------------------------------------------------------------- +# TestDeleteSessionServerErrors -- 404/409 surfaced as clear tool errors +# --------------------------------------------------------------------------- + + +class TestDeleteSessionServerErrors: + async def test_404_surfaces_as_clear_tool_error(self) -> None: + from context_intelligence.client import CIClientError + + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000") + coordinator = _make_coordinator(resolver=hook_resolver) + tool = DeleteSessionTool(coordinator) + + mock_instance = AsyncMock() + mock_instance.delete_session = AsyncMock( + side_effect=CIClientError( + "HTTP 404 from http://ci-server:9000/sessions/missing", + error_type="http_status", + url="http://ci-server:9000/sessions/missing", + status_code=404, + ) + ) + mock_cls = MagicMock(return_value=mock_instance) + with patch( + "amplifier_module_tool_server_data_ops.delete_session_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"session_id": "missing"}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "http_status" + assert result.error["status_code"] == 404 + assert "missing" in result.error["message"] + assert result.error["source"] is not None + + async def test_409_surfaces_as_clear_tool_error(self) -> None: + """A 409 (still receiving data / ambiguous id) must never be silently + treated as a completed delete -- it surfaces as a clear tool error.""" + from context_intelligence.client import CIClientError + + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000") + coordinator = _make_coordinator(resolver=hook_resolver) + tool = DeleteSessionTool(coordinator) + + mock_instance = AsyncMock() + mock_instance.delete_session = AsyncMock( + side_effect=CIClientError( + "HTTP 409 from http://ci-server:9000/sessions/live", + error_type="http_status", + url="http://ci-server:9000/sessions/live", + status_code=409, + ) + ) + mock_cls = MagicMock(return_value=mock_instance) + with patch( + "amplifier_module_tool_server_data_ops.delete_session_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"session_id": "live"}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "http_status" + assert result.error["status_code"] == 409 + assert "still receiving data" in result.error["message"] diff --git a/modules/tool-server-data-ops/tests/test_module.py b/modules/tool-server-data-ops/tests/test_module.py new file mode 100644 index 00000000..c7d0b331 --- /dev/null +++ b/modules/tool-server-data-ops/tests/test_module.py @@ -0,0 +1,319 @@ +"""Module-level contract tests for tool-server-data-ops. + +Tests for the merged two-tool module: mount registers both tools from one +call, the ToolConfigResolver is shared (one instance, identical resolution), +and the lazy hook lookup stays lazy (not cached at mount time). +""" + +from __future__ import annotations + +import inspect +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_coordinator(hook_resolver: Any = None) -> MagicMock: + """Coordinator whose get_capability returns hook_resolver.""" + coordinator = MagicMock() + coordinator.config = {} + coordinator.get_capability = MagicMock(return_value=hook_resolver) + coordinator.mount = AsyncMock() + return coordinator + + +def _make_dest(url: str, api_key: str = "") -> SimpleNamespace: + return SimpleNamespace(name="default", url=url, api_key=api_key) + + +def _make_hook_resolver(url: str | None = None, api_key: str = "") -> MagicMock: + """Minimal hook resolver mock with a destinations dict.""" + resolver = MagicMock() + resolver.workspace = "test-workspace" + if url: + resolver.destinations = {"default": _make_dest(url, api_key or "")} + else: + resolver.destinations = {} + return resolver + + +# --------------------------------------------------------------------------- +# TestModuleContract +# --------------------------------------------------------------------------- + + +class TestModuleContract: + """Module-level contract (type marker + mount signature).""" + + def test_module_type_is_tool(self) -> None: + from amplifier_module_tool_server_data_ops import __amplifier_module_type__ + + assert __amplifier_module_type__ == "tool" + + def test_mount_is_coroutine(self) -> None: + from amplifier_module_tool_server_data_ops import mount + + assert inspect.iscoroutinefunction(mount) + + def test_mount_signature_has_coordinator_and_config(self) -> None: + from amplifier_module_tool_server_data_ops import mount + + sig = inspect.signature(mount) + params = list(sig.parameters.keys()) + assert params[0] == "coordinator" + assert params[1] == "config" + + +# --------------------------------------------------------------------------- +# TestMountRegistersExactlyTwoTools +# --------------------------------------------------------------------------- + + +class TestMountRegistersExactlyTwoTools: + """mount() must register exactly two tools with distinct names.""" + + async def test_mount_registers_exactly_two_tools(self) -> None: + from amplifier_module_tool_server_data_ops import mount + + coordinator = _make_coordinator() + await mount(coordinator, config={}) + + assert coordinator.mount.call_count == 2 + + async def test_both_tool_calls_use_tools_category(self) -> None: + from amplifier_module_tool_server_data_ops import mount + + coordinator = _make_coordinator() + await mount(coordinator, config={}) + + for call in coordinator.mount.call_args_list: + assert call.args[0] == "tools" + + async def test_tool_names_are_session_summary_and_delete_session(self) -> None: + from amplifier_module_tool_server_data_ops import mount + + coordinator = _make_coordinator() + await mount(coordinator, config={}) + + registered_names = {call.kwargs["name"] for call in coordinator.mount.call_args_list} + assert registered_names == {"session_summary", "delete_session"} + + async def test_mounted_tools_are_protocol_compliant(self) -> None: + from amplifier_module_tool_server_data_ops import mount + + coordinator = _make_coordinator() + await mount(coordinator, config={}) + + for call in coordinator.mount.call_args_list: + tool = call.args[1] + assert hasattr(tool, "name") + assert hasattr(tool, "description") + assert hasattr(tool, "input_schema") + assert hasattr(tool, "execute") + assert isinstance(tool.input_schema, dict) + assert inspect.iscoroutinefunction(tool.execute) + + async def test_mount_returns_none(self) -> None: + """mount() returns None -- the kernel ignores non-callable returns.""" + from amplifier_module_tool_server_data_ops import mount + + coordinator = _make_coordinator() + result = await mount(coordinator, config={}) + assert result is None + + async def test_mount_makes_no_register_capability_call(self) -> None: + from amplifier_module_tool_server_data_ops import mount + + coordinator = _make_coordinator() + await mount(coordinator, config={}) + + coordinator.register_capability.assert_not_called() + + +# --------------------------------------------------------------------------- +# TestSharedResolverInvariant +# --------------------------------------------------------------------------- + + +class TestSharedResolverInvariant: + """The ToolConfigResolver is shared: one instance, identical resolution.""" + + async def test_both_tools_have_same_resolver_instance(self) -> None: + """summary._tool_resolver is delete._tool_resolver: same object from mount().""" + from amplifier_module_tool_server_data_ops import mount + + coordinator = _make_coordinator() + await mount(coordinator, config={}) + + tools = {call.kwargs["name"]: call.args[1] for call in coordinator.mount.call_args_list} + summary = tools["session_summary"] + delete = tools["delete_session"] + assert summary._tool_resolver is delete._tool_resolver + + async def test_shared_resolver_consistency_same_url_and_api_key(self) -> None: + """Both tools resolve to the SAME (url, api_key) from sources. + + This is the load-bearing correctness invariant: with a shared resolver, + divergent read-endpoint config is structurally impossible. + """ + from context_intelligence.tool_resolver import resolve_query_connection + + from amplifier_module_tool_server_data_ops import mount + + config = { + "sources": { + "primary": {"url": "http://data-ops.example.com", "api_key": "shared-key"}, + } + } + coordinator = _make_coordinator() + await mount(coordinator, config=config) + + tools = {call.kwargs["name"]: call.args[1] for call in coordinator.mount.call_args_list} + summary = tools["session_summary"] + delete = tools["delete_session"] + + # Resolve using the shared resolver (no hook resolver needed for tier-1 hit) + summary_conn = resolve_query_connection(None, summary._tool_resolver) + delete_conn = resolve_query_connection(None, delete._tool_resolver) + + assert summary_conn.url == delete_conn.url == "http://data-ops.example.com" + assert summary_conn.api_key == delete_conn.api_key == "shared-key" + + +# --------------------------------------------------------------------------- +# TestLateMountTimingInvariant +# --------------------------------------------------------------------------- + + +class TestLateMountTimingInvariant: + """The lazy hook-resolver lookup must NOT be cached at mount() time. + + Catches any regression where the hook capability is fetched eagerly in + mount() rather than lazily in execute() (which would break when the hook + mounts later). + """ + + async def test_late_mount_session_summary_resolves_destination_after_hook_registers( + self, + ) -> None: + """Mount with NO hook -> register hook AFTER -> execute() sees the hook's destination.""" + from amplifier_module_tool_server_data_ops import mount + + # Step 1: mount with no hook registered + coordinator = _make_coordinator(hook_resolver=None) + await mount(coordinator, config={}) + tools = {call.kwargs["name"]: call.args[1] for call in coordinator.mount.call_args_list} + summary = tools["session_summary"] + + # Confirm hook resolver is None after mount (lazy, not fetched yet) + assert summary._hook_resolver is None + + # Step 2: register the hook resolver AFTER mount + hook_resolver = _make_hook_resolver(url="http://late-hook.example.com", api_key="late-key") + coordinator.get_capability.return_value = hook_resolver + + # Step 3: execute() must now see the late-registered hook destination + mock_client = MagicMock() + mock_client.session_summary = AsyncMock(return_value={"deletable": True}) + mock_cls = MagicMock(return_value=mock_client) + with patch( + "amplifier_module_tool_server_data_ops.session_summary_tool.AsyncCIClient", + mock_cls, + ): + result = await summary.execute({"session_id": "abc"}) + + assert result.success is True + call_kwargs = mock_cls.call_args.kwargs + assert call_kwargs["server_url"] == "http://late-hook.example.com" + assert call_kwargs["api_key"] == "late-key" + + async def test_late_mount_delete_session_resolves_destination_after_hook_registers( + self, + ) -> None: + """DeleteSessionTool: mount with no hook -> register hook -> execute sees destination.""" + from amplifier_module_tool_server_data_ops import mount + + coordinator = _make_coordinator(hook_resolver=None) + await mount(coordinator, config={}) + tools = {call.kwargs["name"]: call.args[1] for call in coordinator.mount.call_args_list} + delete = tools["delete_session"] + + assert delete._hook_resolver is None + + hook_resolver = _make_hook_resolver(url="http://late-hook.example.com", api_key="late-key") + coordinator.get_capability.return_value = hook_resolver + + mock_client = MagicMock() + mock_client.delete_session = AsyncMock(return_value={"nodes_deleted": 1}) + mock_cls = MagicMock(return_value=mock_client) + with patch( + "amplifier_module_tool_server_data_ops.delete_session_tool.AsyncCIClient", + mock_cls, + ): + result = await delete.execute({"session_id": "abc"}) + + assert result.success is True + call_kwargs = mock_cls.call_args.kwargs + assert call_kwargs["server_url"] == "http://late-hook.example.com" + assert call_kwargs["api_key"] == "late-key" + + +# --------------------------------------------------------------------------- +# TestMountWithMisconfiguredSource +# --------------------------------------------------------------------------- + + +class TestMountWithMisconfiguredSource: + """mount() with one bad + one good source entry does not raise.""" + + async def test_mount_does_not_raise_with_one_bad_source(self) -> None: + from amplifier_module_tool_server_data_ops import mount + + config = { + "sources": { + "good": {"url": "http://good.example.com", "api_key": "gk"}, + "bad": {"url": "", "api_key": ""}, + } + } + coordinator = _make_coordinator() + # Must not raise. + result = await mount(coordinator, config=config) + assert result is None + + async def test_mount_registers_both_tools_with_one_bad_source(self) -> None: + from amplifier_module_tool_server_data_ops import mount + + config = { + "sources": { + "good": {"url": "http://good.example.com", "api_key": "gk"}, + "bad": {"url": "", "api_key": ""}, + } + } + coordinator = _make_coordinator() + await mount(coordinator, config=config) + + assert coordinator.mount.call_count == 2 + registered_names = {call.kwargs["name"] for call in coordinator.mount.call_args_list} + assert registered_names == {"session_summary", "delete_session"} + + async def test_mount_logs_warning_with_one_bad_source(self, caplog: Any) -> None: + import logging + + from amplifier_module_tool_server_data_ops import mount + + config = { + "sources": { + "good": {"url": "http://good.example.com", "api_key": "gk"}, + "bad": {"url": "", "api_key": ""}, + } + } + coordinator = _make_coordinator() + with caplog.at_level(logging.WARNING, logger="context_intelligence.tool_resolver"): + await mount(coordinator, config=config) + + assert any("misconfigured" in r.message for r in caplog.records) + assert any("bad" in r.message for r in caplog.records) diff --git a/modules/tool-server-data-ops/tests/test_session_summary_tool.py b/modules/tool-server-data-ops/tests/test_session_summary_tool.py new file mode 100644 index 00000000..4b75c527 --- /dev/null +++ b/modules/tool-server-data-ops/tests/test_session_summary_tool.py @@ -0,0 +1,443 @@ +"""Tests for SessionSummaryTool. + +Constructor: SessionSummaryTool(coordinator, resolver=None). Patch path is +amplifier_module_tool_server_data_ops.session_summary_tool. +""" + +from __future__ import annotations + +import os +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + + +def _make_coordinator(resolver: Any = None) -> MagicMock: + coordinator = MagicMock() + coordinator.config = {} + coordinator.get_capability = MagicMock(return_value=resolver) + return coordinator + + +def _make_hook_resolver( + server_url: str | None = "http://localhost:8080", + workspace: str = "test-workspace", + api_key: str = "test-api-key", +) -> MagicMock: + """Create a hook resolver mock (returned by get_capability).""" + resolver = MagicMock() + resolver.workspace = workspace + if server_url: + resolver.destinations = { + "default": SimpleNamespace(name="default", url=server_url, api_key=api_key or ""), + } + else: + resolver.destinations = {} + return resolver + + +def _make_hook_resolver_with_dests(destinations: dict) -> MagicMock: + """Hook resolver mock with a specific destinations dict.""" + resolver = MagicMock() + resolver.workspace = "test-workspace" + resolver.destinations = destinations + return resolver + + +def _make_mock_async_ci_client(return_value: Any = None): + """Return (mock_instance, mock_cls) for patching AsyncCIClient.""" + mock_instance = AsyncMock() + mock_instance.session_summary = AsyncMock( + return_value=return_value if return_value is not None else {"deletable": True} + ) + mock_cls = MagicMock(return_value=mock_instance) + return mock_instance, mock_cls + + +def _make_tool_resolver(config: dict, coordinator: Any = None) -> Any: + """Build a real ToolConfigResolver from a config dict (for injection).""" + from context_intelligence.tool_resolver import ToolConfigResolver + + coord = coordinator or MagicMock() + coord.config = {} + return ToolConfigResolver(config, coord) + + +# --------------------------------------------------------------------------- +# TestSessionSummaryToolProtocol +# --------------------------------------------------------------------------- + + +class TestSessionSummaryToolProtocol: + """Tool protocol surface tests.""" + + def test_name_is_session_summary(self) -> None: + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + tool = SessionSummaryTool(_make_coordinator()) + assert tool.name == "session_summary" + + def test_description_mentions_preview(self) -> None: + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + tool = SessionSummaryTool(_make_coordinator()) + assert "preview" in tool.description.lower() + + def test_input_schema_returns_object_type(self) -> None: + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + tool = SessionSummaryTool(_make_coordinator()) + assert tool.input_schema["type"] == "object" + + def test_input_schema_session_id_not_required_but_enforced_at_execute(self) -> None: + """`session_id` is NOT in the JSON-schema `required` list -- list_sources=true + calls legitimately omit it. execute() enforces the rule itself.""" + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + tool = SessionSummaryTool(_make_coordinator()) + assert "session_id" not in tool.input_schema["required"] + assert "session_id" in tool.input_schema["properties"] + + def test_input_schema_has_optional_source_and_list_sources(self) -> None: + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + tool = SessionSummaryTool(_make_coordinator()) + props = tool.input_schema["properties"] + assert "source" in props + assert "list_sources" in props + assert "source" not in tool.input_schema["required"] + assert "list_sources" not in tool.input_schema["required"] + + async def test_execute_returns_tool_result(self) -> None: + from amplifier_core.models import ToolResult + + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + hook_resolver = _make_hook_resolver() + coordinator = _make_coordinator(resolver=hook_resolver) + tool = SessionSummaryTool(coordinator) + + _, mock_cls = _make_mock_async_ci_client() + with patch( + "amplifier_module_tool_server_data_ops.session_summary_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"session_id": "abc"}) + + assert isinstance(result, ToolResult) + + +# --------------------------------------------------------------------------- +# TestListSources +# --------------------------------------------------------------------------- + + +class TestListSources: + async def test_list_sources_does_not_call_client(self) -> None: + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + resolver = _make_tool_resolver( + {"sources": {"only": {"url": "http://only.example.com", "api_key": "k"}}} + ) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = SessionSummaryTool(coordinator, resolver) + + mock_cls = MagicMock() + with patch( + "amplifier_module_tool_server_data_ops.session_summary_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"list_sources": True}) + + assert result.success is True + assert result.output is not None + names = {e["name"] for e in result.output["connectable_set"]} + assert names == {"only"} + mock_cls.assert_not_called() + + +# --------------------------------------------------------------------------- +# TestSessionSummaryConstruction -- AsyncCIClient construction and delegation +# --------------------------------------------------------------------------- + + +class TestSessionSummaryConstruction: + """AsyncCIClient construction and delegation tests (mirrors GraphQueryTool).""" + + async def test_client_constructed_with_server_url_and_api_key(self) -> None: + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000", api_key="my-key") + coordinator = _make_coordinator(resolver=hook_resolver) + tool = SessionSummaryTool(coordinator) + + _, mock_cls = _make_mock_async_ci_client() + with patch( + "amplifier_module_tool_server_data_ops.session_summary_tool.AsyncCIClient", + mock_cls, + ): + await tool.execute({"session_id": "abc"}) + + mock_cls.assert_called_once() + call_kwargs = mock_cls.call_args.kwargs + assert call_kwargs.get("server_url") == "http://ci-server:9000" + assert call_kwargs.get("api_key") == "my-key" + + async def test_session_id_forwarded_to_client_session_summary(self) -> None: + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + hook_resolver = _make_hook_resolver() + coordinator = _make_coordinator(resolver=hook_resolver) + tool = SessionSummaryTool(coordinator) + + mock_instance, mock_cls = _make_mock_async_ci_client() + with patch( + "amplifier_module_tool_server_data_ops.session_summary_tool.AsyncCIClient", + mock_cls, + ): + await tool.execute({"session_id": "the-session-id"}) + + mock_instance.session_summary.assert_called_once_with("the-session-id") + + async def test_result_forwarded_and_source_stamped(self) -> None: + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000") + coordinator = _make_coordinator(resolver=hook_resolver) + tool = SessionSummaryTool(coordinator) + + expected = {"created_by": "alice", "node_count": 10, "deletable": True} + _, mock_cls = _make_mock_async_ci_client(return_value=expected) + with patch( + "amplifier_module_tool_server_data_ops.session_summary_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"session_id": "abc"}) + + assert result.success is True + assert result.output is not None + assert result.output["summary"] == expected + assert result.output["source"] is not None + assert result.output["source"]["url"] == "http://ci-server:9000" + + +# --------------------------------------------------------------------------- +# TestSessionSummaryConfigFallback +# --------------------------------------------------------------------------- + + +class TestSessionSummaryConfigFallback: + async def test_capability_not_found_returns_configuration_error(self) -> None: + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + coordinator = _make_coordinator(resolver=None) + tool = SessionSummaryTool(coordinator) + clean = {k: "" for k in os.environ if k.startswith("AMPLIFIER_CONTEXT_INTELLIGENCE_")} + with patch.dict(os.environ, clean): + result = await tool.execute({"session_id": "abc"}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "configuration_error" + + async def test_missing_session_id_validation_error_carries_source(self) -> None: + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + resolver = _make_tool_resolver( + {"sources": {"only": {"url": "http://only.example.com", "api_key": "k"}}} + ) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = SessionSummaryTool(coordinator, resolver) + + result = await tool.execute({}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "validation_error" + assert result.error["source"] == { + "name": "only", + "url": "http://only.example.com", + "origin": "source", + } + + +# --------------------------------------------------------------------------- +# TestSessionSummarySourceSelection -- pool/selection + fail-loud ambiguity +# --------------------------------------------------------------------------- + + +class TestSessionSummarySourceSelection: + """execute() with an explicit `source` -- matching / not matching / omitted-with-2+.""" + + def _two_source_config(self) -> dict: + return { + "sources": { + "alpha": {"url": "http://alpha.example.com", "api_key": "alpha-key"}, + "beta": {"url": "http://beta.example.com", "api_key": "beta-key"}, + } + } + + async def test_source_matching_name_selects_that_source(self) -> None: + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + resolver = _make_tool_resolver(self._two_source_config()) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = SessionSummaryTool(coordinator, resolver) + + _, mock_cls = _make_mock_async_ci_client() + with patch( + "amplifier_module_tool_server_data_ops.session_summary_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"session_id": "abc", "source": "beta"}) + + assert result.success is True + call_kwargs = mock_cls.call_args.kwargs + assert call_kwargs["server_url"] == "http://beta.example.com" + assert call_kwargs["api_key"] == "beta-key" + + async def test_source_not_matching_returns_unknown_source_error(self) -> None: + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + resolver = _make_tool_resolver(self._two_source_config()) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = SessionSummaryTool(coordinator, resolver) + + result = await tool.execute({"session_id": "abc", "source": "gamma"}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "unknown_source" + assert result.error["valid_sources"] == ["alpha", "beta"] + + async def test_source_omitted_with_two_configured_returns_ambiguous_error(self) -> None: + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + resolver = _make_tool_resolver(self._two_source_config()) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = SessionSummaryTool(coordinator, resolver) + + result = await tool.execute({"session_id": "abc"}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "ambiguous_source_selection" + assert result.error["valid_sources"] == ["alpha", "beta"] + + async def test_source_omitted_with_one_configured_still_succeeds(self) -> None: + """Safe to omit source with exactly one configured (backward compatible).""" + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + config = { + "sources": { + "default": {"url": "http://only.example.com", "api_key": "only-key"}, + } + } + resolver = _make_tool_resolver(config) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = SessionSummaryTool(coordinator, resolver) + + _, mock_cls = _make_mock_async_ci_client() + with patch( + "amplifier_module_tool_server_data_ops.session_summary_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"session_id": "abc"}) + + assert result.success is True + call_kwargs = mock_cls.call_args.kwargs + assert call_kwargs["server_url"] == "http://only.example.com" + + async def test_selected_source_misconfigured_returns_source_misconfigured_error(self) -> None: + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + config = { + "sources": { + "good": {"url": "http://good.example.com", "api_key": "gk"}, + "bad": {"url": "", "api_key": ""}, + } + } + resolver = _make_tool_resolver(config) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = SessionSummaryTool(coordinator, resolver) + + result = await tool.execute({"session_id": "abc", "source": "bad"}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "source_misconfigured" + assert "bad" in result.error["message"] + + +# --------------------------------------------------------------------------- +# TestSessionSummaryServerErrors -- 404/409 surfaced as clear tool errors +# --------------------------------------------------------------------------- + + +class TestSessionSummaryServerErrors: + async def test_404_surfaces_as_clear_tool_error(self) -> None: + from context_intelligence.client import CIClientError + + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000") + coordinator = _make_coordinator(resolver=hook_resolver) + tool = SessionSummaryTool(coordinator) + + mock_instance = AsyncMock() + mock_instance.session_summary = AsyncMock( + side_effect=CIClientError( + "HTTP 404 from http://ci-server:9000/sessions/missing/summary", + error_type="http_status", + url="http://ci-server:9000/sessions/missing/summary", + status_code=404, + ) + ) + mock_cls = MagicMock(return_value=mock_instance) + with patch( + "amplifier_module_tool_server_data_ops.session_summary_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"session_id": "missing"}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "http_status" + assert result.error["status_code"] == 404 + assert "missing" in result.error["message"] + assert result.error["source"] is not None + + async def test_409_surfaces_as_clear_tool_error(self) -> None: + from context_intelligence.client import CIClientError + + from amplifier_module_tool_server_data_ops.session_summary_tool import SessionSummaryTool + + hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000") + coordinator = _make_coordinator(resolver=hook_resolver) + tool = SessionSummaryTool(coordinator) + + mock_instance = AsyncMock() + mock_instance.session_summary = AsyncMock( + side_effect=CIClientError( + "HTTP 409 from http://ci-server:9000/sessions/live/summary", + error_type="http_status", + url="http://ci-server:9000/sessions/live/summary", + status_code=409, + ) + ) + mock_cls = MagicMock(return_value=mock_instance) + with patch( + "amplifier_module_tool_server_data_ops.session_summary_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"session_id": "live"}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "http_status" + assert result.error["status_code"] == 409 + assert "still receiving data" in result.error["message"] diff --git a/modules/tool-server-data-ops/uv.lock b/modules/tool-server-data-ops/uv.lock new file mode 100644 index 00000000..2d0a9ecd --- /dev/null +++ b/modules/tool-server-data-ops/uv.lock @@ -0,0 +1,923 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "amplifier-bundle-context-intelligence" +version = "0.1.3" +source = { git = "https://github.com/microsoft/amplifier-bundle-context-intelligence?rev=main#5011d481c6e5e1704967396aeca11fefcb905972" } +dependencies = [ + { name = "azure-identity" }, +] + +[[package]] +name = "amplifier-core" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tomli" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/cd/8b0b520bf0de741ea73e069aaf64aca28c9f4ce91a7b8b9239193a6c4c1b/amplifier_core-1.6.1-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c0f711d8408de78e53e5deddcb38b7240c5c1c497ca51eeaaeff23559b3d3c48", size = 8281633, upload-time = "2026-08-10T02:38:11.98Z" }, + { url = "https://files.pythonhosted.org/packages/14/83/f4fb297d87d35b9d74058da02bb153e12f7891ab62b3aaf7e0857f877798/amplifier_core-1.6.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b08f37e2c0b1611349a0e25d5bf9bfdfae3afcee35488f8e26bba1cdd400503b", size = 7366930, upload-time = "2026-08-10T02:38:14.105Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ba/5eb9cecf92d8053c5e6d46ad9668c3ed3558d5423845c1dced1f266b2a38/amplifier_core-1.6.1-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ebf7e3993c76ea506e70ac7844b286c3ba2e9127b3bcb350fa4fcd2dcdbd38d", size = 7659512, upload-time = "2026-08-10T02:38:16.314Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/121f054e3d079dc33d83f3d8ba9af50fd9f7694c3e2ba3d7d23d7c157d48/amplifier_core-1.6.1-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c957cd0671d2a003f2c8f7d6a41bd6e808f97d183c57b97e7700bf4c912621d", size = 8678425, upload-time = "2026-08-10T02:38:18.243Z" }, + { url = "https://files.pythonhosted.org/packages/35/25/bfc217f4a9ed2d033995fc59847f1fee2e1b17130632fcb0e0981a1a311b/amplifier_core-1.6.1-cp311-abi3-win_amd64.whl", hash = "sha256:50c80bcfa1f6efe769b19e7af18c925024c7553d4db08880727241709dd44eae", size = 8976601, upload-time = "2026-08-10T02:38:20.505Z" }, + { url = "https://files.pythonhosted.org/packages/a5/14/5f330452c92c6c5d35c51ad5311301949ce5db4d1a1a901456f3ee43eaac/amplifier_core-1.6.1-cp311-abi3-win_arm64.whl", hash = "sha256:cd8b617f132cf5d1ca3e5187d5f831d1f2a508bb40d07b2ab1085961bcb9e1a9", size = 7744837, upload-time = "2026-08-10T02:38:22.562Z" }, +] + +[[package]] +name = "amplifier-module-tool-server-data-ops" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "amplifier-bundle-context-intelligence" }, + { name = "httpx" }, + { name = "idna" }, +] + +[package.dev-dependencies] +dev = [ + { name = "amplifier-core" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "amplifier-bundle-context-intelligence", git = "https://github.com/microsoft/amplifier-bundle-context-intelligence?rev=main" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "idna", specifier = ">=3.15" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "amplifier-core", specifier = ">=1.6.0" }, + { name = "pyright", specifier = ">=1.1.411" }, + { name = "pytest", specifier = ">=9.0.3" }, + { name = "pytest-asyncio", specifier = ">=0.24" }, + { name = "ruff", specifier = ">=0.14" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "azure-core" +version = "1.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/f3/b416179e408990df5db0d516283022dde0f5d0111d98c1a848e41853e81c/azure_core-1.41.0.tar.gz", hash = "sha256:f46ff5dfcd230f25cf1c19e8a34b8dc08a337b2503e268bb600a16c00db8ad5a", size = 381042, upload-time = "2026-05-07T23:30:54.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/db/325c6d7312d2200251c52323878281045aaffcb5586612296484e4280eaa/azure_core-1.41.0-py3-none-any.whl", hash = "sha256:522b4011e8180b1a3dcd2024396a4e7fe9ac37fb8597db47163d230b5efe892d", size = 220920, upload-time = "2026-05-07T23:30:56.357Z" }, +] + +[[package]] +name = "azure-identity" +version = "1.25.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "cryptography" }, + { name = "msal" }, + { name = "msal-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/0e/3a63efb48aa4a5ae2cfca61ee152fbcb668092134d3eb8bfda472dd5c617/azure_identity-1.25.3.tar.gz", hash = "sha256:ab23c0d63015f50b630ef6c6cf395e7262f439ce06e5d07a64e874c724f8d9e6", size = 286304, upload-time = "2026-03-13T01:12:20.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/9a/417b3a533e01953a7c618884df2cb05a71e7b68bdbce4fbdb62349d2a2e8/azure_identity-1.25.3-py3-none-any.whl", hash = "sha256:f4d0b956a8146f30333e071374171f3cfa7bdb8073adb8c3814b65567aa7447c", size = 192138, upload-time = "2026-03-13T01:12:22.951Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/b6/034f6802e9c3f6418966cfabb7db8c9252cc2429c5098f41cc43af804149/charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30", size = 363585, upload-time = "2026-08-15T08:16:46.646Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fa/6a7e2a7c4b5451912b8c417732df79574354443592a88d616de03da66ae5/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488", size = 251189, upload-time = "2026-08-15T08:16:48.287Z" }, + { url = "https://files.pythonhosted.org/packages/a4/c8/ab42b07cfd82e919f427fcfaa7c41abae8242833ad1aad66d42bae40b669/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22", size = 239724, upload-time = "2026-08-15T08:16:49.67Z" }, + { url = "https://files.pythonhosted.org/packages/e7/80/b9348b5d3041209f98b4cdad7655766369233f1d533f4f4f7558e9717bec/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731", size = 280078, upload-time = "2026-08-15T08:16:51.228Z" }, + { url = "https://files.pythonhosted.org/packages/82/38/083a24028304bc85bb9e376fed801178423dcbb67495f73b6ea0624e1894/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c", size = 276650, upload-time = "2026-08-15T08:16:52.625Z" }, + { url = "https://files.pythonhosted.org/packages/0d/35/731ac04aa0a097fc1c97f0994c375bdb230c6c96619db794208fe664e9ce/charset_normalizer-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8", size = 262325, upload-time = "2026-08-15T08:16:54.085Z" }, + { url = "https://files.pythonhosted.org/packages/f5/28/c2028e7021fb89c6e56868ed0e387b8e9aa811abdd2ab3208d6578d2c930/charset_normalizer-3.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486", size = 261140, upload-time = "2026-08-15T08:16:55.604Z" }, + { url = "https://files.pythonhosted.org/packages/28/f0/0c0ceec6d98b7daa62e361e418135d59685811d79ba11529aad5cdf15e84/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f", size = 252791, upload-time = "2026-08-15T08:16:57.103Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3e/48f4cd187b1c33189d86039e9cbe4f92c05454175504b44ff81806d4d1bf/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c", size = 240730, upload-time = "2026-08-15T08:16:58.418Z" }, + { url = "https://files.pythonhosted.org/packages/42/85/f9e22af69af67c54cce42be9455d9c81294f918b4ccc454db01f66efcac2/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18", size = 280791, upload-time = "2026-08-15T08:16:59.918Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4c/9044135f42127630b6fa742feb51256353f6ab87a78f2fdd1de3de955a7f/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5", size = 259598, upload-time = "2026-08-15T08:17:01.421Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ed/1dd7cfebb4e75812934c49ca3b79757d11948053f7937ab7070c151f3c55/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b", size = 278217, upload-time = "2026-08-15T08:17:02.782Z" }, + { url = "https://files.pythonhosted.org/packages/bf/eb/239c84503cc9e3ba6eb34686a24bc66e84f3924efdd7e38e751a19f6bc10/charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6", size = 263417, upload-time = "2026-08-15T08:17:04.216Z" }, + { url = "https://files.pythonhosted.org/packages/37/ab/4e4510e1e288478e2c8333131d1c1382382ba8cd2165053c79e39d1da961/charset_normalizer-3.5.1-cp311-cp311-win32.whl", hash = "sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b", size = 181774, upload-time = "2026-08-15T08:17:05.58Z" }, + { url = "https://files.pythonhosted.org/packages/e3/57/32f0ccea59e8612057c61d6fd22ef2cb63cca93c9fe594094919696ac170/charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9", size = 206653, upload-time = "2026-08-15T08:17:07.075Z" }, + { url = "https://files.pythonhosted.org/packages/17/d4/b65c433fc521e58b5f54293982a5e51c05cb5f2dd3f1c7a6acb65b75324e/charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10", size = 185630, upload-time = "2026-08-15T08:17:08.502Z" }, + { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, + { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, + { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, + { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, + { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, + { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, + { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, + { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, + { url = "https://files.pythonhosted.org/packages/c7/27/8d207af749c453ee17ea087340b3f2b4adef75aadd1d277b1b129bdda84e/cryptography-50.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94", size = 3974350, upload-time = "2026-08-25T19:45:26.551Z" }, + { url = "https://files.pythonhosted.org/packages/14/9a/6d3a4d7852e22d657438b7bf51f66102c7d71c0e1fafeec652281d0403e5/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f", size = 4698675, upload-time = "2026-08-25T19:45:28.658Z" }, + { url = "https://files.pythonhosted.org/packages/73/35/5c3717edf9e68a0550ce04e28eab493fe545eccd81742af03f6a75fe260b/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671", size = 4707410, upload-time = "2026-08-25T19:45:30.816Z" }, + { url = "https://files.pythonhosted.org/packages/1d/e0/e786934472e3ac4ecdecc7b129a0ca1a2a40dffdafcf2c3ea9d4397f8def/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e", size = 4698378, upload-time = "2026-08-25T19:45:33.043Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/5b3f53a0b74d122f023476ede40ba5d3e70d5cf475f73b899740d26a4fb2/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6", size = 4706889, upload-time = "2026-08-25T19:45:35.086Z" }, + { url = "https://files.pythonhosted.org/packages/71/44/711e61f7d014be825ef79b285b047292d1bf893732ac1bc030a351fb517f/cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b", size = 3824006, upload-time = "2026-08-25T19:45:37.281Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "msal" +version = "1.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b8/1f/10f9d47a63d3a2e61b2c43e15bee6b95682aab827018f9a1b97a80787e25/msal-1.38.0.tar.gz", hash = "sha256:4f10ff1257bacfd1781f22e85bd2b8d43ad1b490f3b6aafd7906671cadedd464", size = 203411, upload-time = "2026-08-24T10:22:46.053Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/ca/d768f77a27d81ed0a6884f2458f8613c31c79b2eb95defbeca2273fd0754/msal-1.38.0-py3-none-any.whl", hash = "sha256:765b9b98b6aa380ee8b8f1c75636e08863edaf0a953498955bd668650dde5d49", size = 131057, upload-time = "2026-08-24T10:22:47.485Z" }, +] + +[[package]] +name = "msal-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/99/5d239b6156eddf761a636bded1118414d161bd6b7b37a9335549ed159396/msal_extensions-1.3.1.tar.gz", hash = "sha256:c5b0fd10f65ef62b5f1d62f4251d51cbcaf003fcedae8c91b040a488614be1a4", size = 23315, upload-time = "2025-03-14T23:51:03.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" }, + { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" }, + { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" }, + { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" }, + { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" }, + { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" }, + { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pyright" +version = "1.1.411" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" }, + { url = "https://files.pythonhosted.org/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" }, + { url = "https://files.pythonhosted.org/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" }, + { url = "https://files.pythonhosted.org/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" }, + { url = "https://files.pythonhosted.org/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" }, + { url = "https://files.pythonhosted.org/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" }, + { url = "https://files.pythonhosted.org/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" }, + { url = "https://files.pythonhosted.org/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" }, + { url = "https://files.pythonhosted.org/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] diff --git a/tests/test_client.py b/tests/test_client.py index 6adbf54c..9f880c64 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -360,6 +360,186 @@ def test_fetch_blob_includes_authorization_header(self): assert headers.get("Authorization") == "Bearer blobkey" +class TestCIClientSessionSummary: + """CIClient.session_summary() must GET /sessions/{id}/summary and return a dict.""" + + def test_session_summary_returns_dict(self): + """session_summary() returns the parsed summary dict from the server.""" + from context_intelligence.client import CIClient + + client = CIClient("http://localhost:8000", "key") + mock_response = { + "created_by": "alice", + "node_count": 10, + "edge_count": 5, + "blob_count": 2, + "deletable": True, + } + + with patch("context_intelligence.client._http_get_strict") as mock_get: + mock_get.return_value = mock_response + result = client.session_summary("session1") + + assert result == mock_response + + def test_session_summary_calls_correct_url(self): + """session_summary() calls GET /sessions/{session_id}/summary.""" + from context_intelligence.client import CIClient + + client = CIClient("http://localhost:8000", "key") + + with patch("context_intelligence.client._http_get_strict") as mock_get: + mock_get.return_value = {} + client.session_summary("my-session") + + call_args = mock_get.call_args + url = call_args[0][0] if call_args[0] else call_args[1]["url"] + assert url == "http://localhost:8000/sessions/my-session/summary" + + def test_session_summary_includes_authorization_header(self): + """session_summary() sends Authorization: [REDACTED:SECRET]""" + from context_intelligence.client import CIClient + + client = CIClient("http://localhost:8000", "secretkey") + + with patch("context_intelligence.client._http_get_strict") as mock_get: + mock_get.return_value = {} + client.session_summary("my-session") + + call_args = mock_get.call_args + headers = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("headers", {}) + assert headers.get("Authorization") == "Bearer secretkey" + + def test_session_summary_propagates_ciclienterror_404(self): + """A 404 (unknown session) must not be swallowed -- it propagates as CIClientError.""" + from context_intelligence.client import CIClient, CIClientError + + client = CIClient("http://localhost:8000", "key") + + with patch("context_intelligence.client._http_get_strict") as mock_get: + mock_get.side_effect = CIClientError( + "HTTP 404 from http://localhost:8000/sessions/missing/summary", + error_type="http_status", + url="http://localhost:8000/sessions/missing/summary", + status_code=404, + ) + with pytest.raises(CIClientError) as excinfo: + client.session_summary("missing") + + assert excinfo.value.error_type == "http_status" + assert excinfo.value.status_code == 404 + + def test_session_summary_propagates_ciclienterror_409(self): + """A 409 (still receiving data / ambiguous id) must propagate as CIClientError.""" + from context_intelligence.client import CIClient, CIClientError + + client = CIClient("http://localhost:8000", "key") + + with patch("context_intelligence.client._http_get_strict") as mock_get: + mock_get.side_effect = CIClientError( + "HTTP 409 from http://localhost:8000/sessions/live/summary", + error_type="http_status", + url="http://localhost:8000/sessions/live/summary", + status_code=409, + ) + with pytest.raises(CIClientError) as excinfo: + client.session_summary("live") + + assert excinfo.value.error_type == "http_status" + assert excinfo.value.status_code == 409 + + +class TestCIClientDeleteSession: + """CIClient.delete_session() must DELETE /sessions/{id} and return a dict.""" + + def test_delete_session_returns_dict(self): + """delete_session() returns the parsed result-counts dict from the server.""" + from context_intelligence.client import CIClient + + client = CIClient("http://localhost:8000", "key") + mock_response = { + "root_id": "session1", + "session_count": 3, + "nodes_deleted": 42, + "relationships_deleted": 10, + "blobs_deleted": 2, + "queue_sessions_cleaned": 1, + } + + with patch("context_intelligence.client._http_delete_strict") as mock_delete: + mock_delete.return_value = mock_response + result = client.delete_session("session1") + + assert result == mock_response + + def test_delete_session_calls_correct_url(self): + """delete_session() calls DELETE /sessions/{session_id} (no query string/body).""" + from context_intelligence.client import CIClient + + client = CIClient("http://localhost:8000", "key") + + with patch("context_intelligence.client._http_delete_strict") as mock_delete: + mock_delete.return_value = {} + client.delete_session("my-session") + + call_args = mock_delete.call_args + url = call_args[0][0] if call_args[0] else call_args[1]["url"] + assert url == "http://localhost:8000/sessions/my-session" + + def test_delete_session_includes_authorization_header(self): + """delete_session() sends Authorization: [REDACTED:SECRET]""" + from context_intelligence.client import CIClient + + client = CIClient("http://localhost:8000", "secretkey") + + with patch("context_intelligence.client._http_delete_strict") as mock_delete: + mock_delete.return_value = {} + client.delete_session("my-session") + + call_args = mock_delete.call_args + headers = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("headers", {}) + assert headers.get("Authorization") == "Bearer secretkey" + + def test_delete_session_propagates_ciclienterror_404(self): + """A 404 (unknown session) must not be swallowed -- it propagates as CIClientError.""" + from context_intelligence.client import CIClient, CIClientError + + client = CIClient("http://localhost:8000", "key") + + with patch("context_intelligence.client._http_delete_strict") as mock_delete: + mock_delete.side_effect = CIClientError( + "HTTP 404 from http://localhost:8000/sessions/missing", + error_type="http_status", + url="http://localhost:8000/sessions/missing", + status_code=404, + ) + with pytest.raises(CIClientError) as excinfo: + client.delete_session("missing") + + assert excinfo.value.error_type == "http_status" + assert excinfo.value.status_code == 404 + + def test_delete_session_propagates_ciclienterror_409(self): + """A 409 (still receiving data / ambiguous id) must propagate as CIClientError, + never silently treated as a completed delete.""" + from context_intelligence.client import CIClient, CIClientError + + client = CIClient("http://localhost:8000", "key") + + with patch("context_intelligence.client._http_delete_strict") as mock_delete: + mock_delete.side_effect = CIClientError( + "HTTP 409 from http://localhost:8000/sessions/live", + error_type="http_status", + url="http://localhost:8000/sessions/live", + status_code=409, + ) + with pytest.raises(CIClientError) as excinfo: + client.delete_session("live") + + assert excinfo.value.error_type == "http_status" + assert excinfo.value.status_code == 409 + + class TestCIClientHealthCheck: """CIClient.health_check() must use cypher() to run a count query and return dict.""" @@ -682,6 +862,210 @@ async def test_async_fetch_blob_sends_auth_header(self): assert sent_headers.get("Authorization") == "Bearer blobkey" +class TestAsyncCIClientSessionSummary: + """AsyncCIClient.session_summary() must GET /sessions/{id}/summary.""" + + async def test_async_session_summary_returns_parsed_dict(self): + """session_summary() returns the parsed summary dict from the server.""" + from context_intelligence.client import AsyncCIClient + + summary_data = {"created_by": "alice", "node_count": 10, "deletable": True} + mock_resp = _make_async_mock_response(summary_data) + mock_http = _make_async_httpx_client(mock_resp) + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "testkey") + result = await client.session_summary("session1") + + assert result == summary_data + + async def test_async_session_summary_calls_correct_url_and_method(self): + """session_summary() GETs {server_url}/sessions/{session_id}/summary.""" + from context_intelligence.client import AsyncCIClient + + mock_resp = _make_async_mock_response({}) + mock_http = _make_async_httpx_client(mock_resp) + mock_inner_client = mock_http.__aenter__.return_value + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "testkey") + await client.session_summary("my-session") + + assert mock_inner_client.get.called, "session_summary must use GET" + call_args = mock_inner_client.get.call_args + url = call_args[0][0] if call_args[0] else call_args[1]["url"] + assert url == "http://localhost:8000/sessions/my-session/summary" + + async def test_async_session_summary_sends_auth_header(self): + """session_summary() sends Authorization: [REDACTED:SECRET]""" + from context_intelligence.client import AsyncCIClient + + mock_resp = _make_async_mock_response({}) + mock_http = _make_async_httpx_client(mock_resp) + mock_inner_client = mock_http.__aenter__.return_value + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "secretkey") + await client.session_summary("my-session") + + call_kwargs = mock_inner_client.get.call_args + sent_headers = call_kwargs[1].get("headers") or call_kwargs[0][1] + assert sent_headers.get("Authorization") == "Bearer secretkey" + + async def test_async_session_summary_raises_on_404(self): + """A 404 (unknown session) raises CIClientError(error_type='http_status').""" + import httpx + + from context_intelligence.client import AsyncCIClient, CIClientError + + request = httpx.Request("GET", "http://localhost:8000/sessions/missing/summary") + real_response = httpx.Response(status_code=404, request=request) + mock_resp = MagicMock() + mock_resp.status_code = 404 + mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError( + "404", request=request, response=real_response + ) + mock_http = _make_async_httpx_client(mock_resp) + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "testkey") + with pytest.raises(CIClientError) as exc_info: + await client.session_summary("missing") + + assert exc_info.value.error_type == "http_status" + assert exc_info.value.status_code == 404 + + async def test_async_session_summary_raises_on_409(self): + """A 409 (still receiving data / ambiguous id) raises CIClientError + with the status preserved, not a silently-empty result.""" + import httpx + + from context_intelligence.client import AsyncCIClient, CIClientError + + request = httpx.Request("GET", "http://localhost:8000/sessions/live/summary") + real_response = httpx.Response(status_code=409, request=request) + mock_resp = MagicMock() + mock_resp.status_code = 409 + mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError( + "409", request=request, response=real_response + ) + mock_http = _make_async_httpx_client(mock_resp) + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "testkey") + with pytest.raises(CIClientError) as exc_info: + await client.session_summary("live") + + assert exc_info.value.error_type == "http_status" + assert exc_info.value.status_code == 409 + + +class TestAsyncCIClientDeleteSession: + """AsyncCIClient.delete_session() must DELETE /sessions/{id}.""" + + async def test_async_delete_session_returns_parsed_dict(self): + """delete_session() returns the parsed result-counts dict from the server.""" + from context_intelligence.client import AsyncCIClient + + result_data = {"root_id": "session1", "nodes_deleted": 42, "blobs_deleted": 2} + mock_resp = _make_async_mock_response(result_data) + mock_http = _make_async_httpx_client(mock_resp) + mock_inner_client = mock_http.__aenter__.return_value + mock_inner_client.delete = AsyncMock(return_value=mock_resp) + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "testkey") + result = await client.delete_session("session1") + + assert result == result_data + + async def test_async_delete_session_calls_correct_url_and_method(self): + """delete_session() DELETEs {server_url}/sessions/{session_id} (no query/body).""" + from context_intelligence.client import AsyncCIClient + + mock_resp = _make_async_mock_response({}) + mock_http = _make_async_httpx_client(mock_resp) + mock_inner_client = mock_http.__aenter__.return_value + mock_inner_client.delete = AsyncMock(return_value=mock_resp) + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "testkey") + await client.delete_session("my-session") + + assert mock_inner_client.delete.called, "delete_session must use DELETE" + call_args = mock_inner_client.delete.call_args + url = call_args[0][0] if call_args[0] else call_args[1]["url"] + assert url == "http://localhost:8000/sessions/my-session" + + async def test_async_delete_session_sends_auth_header(self): + """delete_session() sends Authorization: [REDACTED:SECRET]""" + from context_intelligence.client import AsyncCIClient + + mock_resp = _make_async_mock_response({}) + mock_http = _make_async_httpx_client(mock_resp) + mock_inner_client = mock_http.__aenter__.return_value + mock_inner_client.delete = AsyncMock(return_value=mock_resp) + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "secretkey") + await client.delete_session("my-session") + + call_kwargs = mock_inner_client.delete.call_args + sent_headers = call_kwargs[1].get("headers") or call_kwargs[0][1] + assert sent_headers.get("Authorization") == "Bearer secretkey" + + async def test_async_delete_session_raises_on_404(self): + """A 404 (unknown session) raises CIClientError(error_type='http_status').""" + import httpx + + from context_intelligence.client import AsyncCIClient, CIClientError + + request = httpx.Request("DELETE", "http://localhost:8000/sessions/missing") + real_response = httpx.Response(status_code=404, request=request) + mock_resp = MagicMock() + mock_resp.status_code = 404 + mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError( + "404", request=request, response=real_response + ) + mock_http = _make_async_httpx_client(mock_resp) + mock_inner_client = mock_http.__aenter__.return_value + mock_inner_client.delete = AsyncMock(return_value=mock_resp) + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "testkey") + with pytest.raises(CIClientError) as exc_info: + await client.delete_session("missing") + + assert exc_info.value.error_type == "http_status" + assert exc_info.value.status_code == 404 + + async def test_async_delete_session_raises_on_409(self): + """A 409 (still receiving data / ambiguous id) raises CIClientError -- + the delete is never silently treated as done.""" + import httpx + + from context_intelligence.client import AsyncCIClient, CIClientError + + request = httpx.Request("DELETE", "http://localhost:8000/sessions/live") + real_response = httpx.Response(status_code=409, request=request) + mock_resp = MagicMock() + mock_resp.status_code = 409 + mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError( + "409", request=request, response=real_response + ) + mock_http = _make_async_httpx_client(mock_resp) + mock_inner_client = mock_http.__aenter__.return_value + mock_inner_client.delete = AsyncMock(return_value=mock_resp) + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "testkey") + with pytest.raises(CIClientError) as exc_info: + await client.delete_session("live") + + assert exc_info.value.error_type == "http_status" + assert exc_info.value.status_code == 409 + + class TestAsyncCIClientListBlobKeys: """AsyncCIClient.list_blob_keys() must return set[str] of BARE blob keys.""" From 7170bdf4134a957cc4eea5d24d6e75ad2d9c64d0 Mon Sep 17 00:00:00 2001 From: colombod Date: Tue, 1 Sep 2026 23:52:50 +0000 Subject: [PATCH 02/39] feat(server-data-ops): add agent + skill for the delete user experience MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lean server-data-ops agent + companion skill driving the three approved delete scenarios (delete current session; find by theme/topic then delete; delete another user's session with an ownership warning). The agent reaches the server only through tool-server-data-ops; preview then explicit confirmation before any delete; the narrative overview is built from the root session's prompts and delegated to graph-analyst. Proven in a Digital Twin: the branch agent + branch tool + branch skill resolve via Gitea url_rewrite and a live session_summary call returned real seeded facts. Part of context-intelligence session data delete (bundle, B3). 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- agents/server-data-ops.md | 104 ++++++++ .../SKILL.md | 231 ++++++++++++++++++ 2 files changed, 335 insertions(+) create mode 100644 agents/server-data-ops.md create mode 100644 skills/context-intelligence-server-data-ops/SKILL.md diff --git a/agents/server-data-ops.md b/agents/server-data-ops.md new file mode 100644 index 00000000..89eb06ce --- /dev/null +++ b/agents/server-data-ops.md @@ -0,0 +1,104 @@ +--- +bundle: + name: server-data-ops + description: Guides a user through safely deleting their own context-intelligence session data from a server, with preview and confirmation. + +meta: + name: server-data-ops + description: | + MUST be used whenever a user wants to delete Context Intelligence session data from a server. Drives find -> preview -> confirm -> delete, always shows what would be removed before removing it, and warns plainly when a session was not created by the current user. + + Handles three situations: deleting the current session's own data, finding a session by description (topic, date, server) and then deleting it, and deleting a session someone else created (with an explicit ownership warning). Aware of multiple configured servers and will ask which one to use when more than one applies. + + Use this agent when: + - The user asks to delete, remove, or clear their own session data from context-intelligence + - The user describes a session by topic, date, or workspace and asks it to be removed + - The user wants to remove someone else's session data and understands they need to confirm that explicitly + +model_role: [reasoning, general] + +tools: + - module: tool-delegate + source: git+https://github.com/microsoft/amplifier-foundation@main#subdirectory=modules/tool-delegate + - module: tool-server-data-ops + source: git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=modules/tool-server-data-ops + - module: tool-context-intelligence-query + source: git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=modules/tool-context-intelligence-query + - module: tool-skills + source: git+https://github.com/microsoft/amplifier-bundle-skills@main#subdirectory=modules/tool-skills + config: + skills: + - "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=skills" +--- + +# Server Data Ops + +> **IDENTITY NOTICE**: You ARE the server-data-ops agent. You help a user delete their own +> (or, with an explicit warning, someone else's) Context Intelligence session data from a +> server. You never do this quietly and never do it without a preview and a confirmation. + +--- + +## Role + +Drive find → preview → confirm → delete for Context Intelligence session data on a server, +across three flows: deleting the current session, finding a session by description and +deleting it, and deleting a session someone else created. The tools do the structured work +(preview, delete, candidate search); you provide the narrative and the conversation with +the user. + +## Tools + +- `session_summary` / `delete_session` (tool-server-data-ops) — preview and permanently + delete a session's whole graph on a server. +- `graph_query` (tool-context-intelligence-query) — narrow candidate sessions by description. +- `delegate` — hand off narrative-building to `graph-analyst`. +- `load_skill` — load `context-intelligence-server-data-ops` for the full procedure. + +You have no filesystem or bash tool in this agent — that is deliberate, not an oversight. + +## Hard Rules + +- **Load the skill first.** Before running any flow: + `Load skill: context-intelligence-server-data-ops`. It holds the exact step order, + wording, and the "session details" block format for all three flows. Do not improvise + the steps from memory. +- **Tool-only access.** The only path to the server is `session_summary` / `delete_session` + (and `graph_query` for finding candidates). Never raw HTTP, `curl`, or bash. +- **Preview, then confirm, in that order.** Every delete is preceded by a `session_summary` + preview and an explicit, strong confirmation from the user immediately before + `delete_session` — a vague "yes" is never enough. Restate the session id, counts, and + server right before deleting. +- **State the impact before confirming.** Deleting a session removes its whole graph — the + named session plus every descendant (forks, sub-sessions, delegated children) — along + with the blobs and queue records for all of them. Nodes shared with other sessions are + kept. There is no undo and no restore — say this plainly before the user confirms, not + only in fine print. +- **Resolve "this session" / "current user" from context first.** Look for injected + session-id and identity context; ask the user directly only as a fallback. See the skill + for exactly where to look and the fallback path. +- **Delegate the narrative to `graph-analyst`.** Never invent the free-text "what was this + session about" summary yourself — get it from `graph-analyst`, or say plainly it isn't + available. See the skill for the exact delegation task. +- **Multi-server: never guess.** Use `list_sources: true` to discover servers; if more than + one applies and none is named, ask the user which one before calling `session_summary` or + `delete_session`. +- **404 = unknown, 409 = still receiving / ambiguous.** Say so plainly; never retry a 409 + forcefully or attempt a raw call around the tool. + +## Flows + +See the `context-intelligence-server-data-ops` skill for the full step order and exact +wording of each. + +- **Flow 1 — delete the current session.** Includes the folder-exclusion offer and the + impact statement, both before proceeding to delete. Runs here and now, in this session. +- **Flow 2 — find a session by description** (topic, date, sometimes a server), then + delete. Narrows candidates, presents session details blocks, user picks one. +- **Flow 3 — delete a session someone else created.** Warn plainly that it wasn't created + by the current user, then require a second, separate, explicit confirmation before + deleting. + +--- + +@foundation:context/shared/common-agent-base.md diff --git a/skills/context-intelligence-server-data-ops/SKILL.md b/skills/context-intelligence-server-data-ops/SKILL.md new file mode 100644 index 00000000..c3836196 --- /dev/null +++ b/skills/context-intelligence-server-data-ops/SKILL.md @@ -0,0 +1,231 @@ +--- +name: context-intelligence-server-data-ops +version: 1.0.0 +description: Exact step order, wording, and the "session details" block format for the three delete flows the server-data-ops agent drives — delete the current session, find-then-delete a session by description, and delete a session someone else created. +license: MIT +--- + +# Context Intelligence Server Data Ops + +Step-by-step procedure for deleting Context Intelligence session data from a server. This +skill exists so the three delete flows are repeatable — the same steps, the same wording, +every time — rather than improvised fresh from the agent body each run. + +--- + +## When to Use + +Load this skill whenever the `server-data-ops` agent is about to run any delete flow: +deleting the current session, finding a session by description and deleting it, or +deleting a session someone else created. + +## When NOT to Use + +- **Just previewing, no delete intended** — a plain `session_summary` call does not need + this skill's full procedure; only load it when a delete is actually on the table. +- **Reading/analysing session content** — that is `graph-analyst`'s job (see + `context-intelligence-graph-query`), not this skill. + +--- + +## The two tools this skill drives + +- **`session_summary`** — read-only preview. Returns `{source: {name, url, origin}, summary: {...}}`. + The `summary` object's fields (from the server's `DeletionPreview`): + + | Field | Meaning | + |---|---| + | `root_id` | The id you looked up (the root of the whole graph that would be removed) | + | `session_ids` | Every session id in that graph (root + descendants) | + | `node_count`, `edge_count`, `blob_count` | Totals for the whole graph | + | `created_by` | Who created the root session | + | `started_at`, `last_change` | ISO-8601 timestamps | + | `subsession_count` | How many sessions under the root | + | `workspace`, `working_dir` | Where it ran | + | `deletable` | `false` if anything in the graph is still receiving data | + | `pending_sessions` | Which session ids are still receiving data, if any | + +- **`delete_session`** — the real, permanent change. Returns + `{source: {...}, result: {root_id, session_count, nodes_deleted, relationships_deleted, + blobs_deleted, queue_sessions_cleaned}}`. + +Both accept `source` (name a specific server) and `list_sources: true` (discover the +connectable set without acting). Neither takes a workspace input — you always address a +session by its id, and the server resolves the rest. + +--- + +## Resolving "this session" and "the current user" + +Resolve both from context first — this is the primary path, not a fallback. Only ask the +user directly if context genuinely doesn't have the answer. Keep it light: at most one +short question, never an interrogation. + +- **Current session id**: look in the environment/status context injected into your turn + (other agents in this ecosystem are shown a running "Session ID" the same way). Use that + id as "the current session" whenever a flow below refers to it. +- **Current user identity**: look for injected identity/user context the same way. If none + is available, fall back to calling `session_summary` on the current session's own id and + reading its `created_by` field — use that as your reference identity whenever you compare + against another session's `created_by` (Flow 3 below). +- Asking the user is the fallback for both, never the first move. + +--- + +## The "session details" block + +Use this exact shape whenever you present a candidate or a confirmed target to the user +(Flow 2 candidate list; the pre-delete confirmation in any flow): + +``` +Session + Created by: + Started: + Last updated: + Sub-sessions: + Working dir: + Server: () + Still live: <"yes — cannot delete yet" if not deletable, else "no"> + Summary: +``` + +Fill every field from a real `session_summary` call and (for Summary) a real delegation to +`graph-analyst` — never fabricate a value you did not receive. + +### Building the narrative (delegate to graph-analyst) + +The free-text "what was this session about" part of the session details block is **not** +returned by the server — the server has no intelligence for it. Get it by delegating: + +``` +Delegate to: graph-analyst +Task: Give me a high-level overview of the work in session — what was done, its +scope and intent — built from that session's own (root) prompts only. Do not dive into +any of its subsessions. +``` + +Building it from the root session's prompts only keeps the overview fast and focused on +top-level intent, rather than walking the whole subsession tree. Fold the returned +narrative into the "Summary:" line of the details block. If graph-analyst cannot produce +one (server unreachable, no data), say so plainly instead of inventing one. + +--- + +## Flow 1 — delete the current session + +Steps, matching the approved scenario exactly: + +1. Resolve the current session's own id and the current user's identity from context + (see "Resolving 'this session' and 'the current user'" above) — ask the user directly + only as a fallback. Ask the user to confirm they want to delete this session's data. +2. Call `session_summary(session_id=, list_sources=true)` or + `delete_session(list_sources=true)` to see every server this session was published to, + and report them to the user, asking "remove from all?" +3. The user picks which server(s) to remove it from. +4. If the session's folder is included by a chosen destination's filters, offer to add a + folder exclusion for that destination (see "Folder exclusion" below) — so the folder is + not pushed there anymore — and offer to guide the user through applying it. Do this + **before** proceeding with deletion. +5. Proceed with the deletion for the chosen server(s): call + `session_summary(session_id=, source=)` (the preview) and show the + user the session details block built from it → get an explicit, strong confirmation + naming the specific session and server → call `delete_session` → report exactly what + was removed (session count, nodes/relationships/blobs/queue sessions) and which server + it came from. + +All five steps happen in this same session. + +### Folder exclusion (offered before deletion) + +The fan-out filter for a destination lives at +`overrides.hook-context-intelligence.config.destinations..exclude` in +`~/.amplifier/settings.yaml` — a list of gitignore-style path patterns matched against a +session's working directory. Adding a pattern that matches the current working directory to +that destination's `exclude` list stops that destination from being selected for future +sessions started in that folder. + +**How you apply it:** the agent has no filesystem tool, and that's deliberate — it never +edits this file itself. When Flow 1 finds that the current session's folder is included by +a chosen destination's filters, offer to add the exclusion before proceeding with deletion: +show the user exactly what to add (the destination name, and the pattern that matches their +current folder), and offer to guide them through applying it. Confirm whether they applied +it, then move on to the preview and delete steps. + +Order: offer the exclusion → preview (`session_summary`) → strong confirmation → delete. + +QUESTION FOR USER: it is unclear whether an exclusion added while the current session is +still running takes effect for that session's own remaining event pushes to this +destination, or only for sessions started after it. The approved scenario does not +address this. Please confirm whether this is acceptable as-is or needs a different +resolution before this flow ships. + +## Flow 2 — find a session by description, then delete + +1. Take the user's description (topic, date range, sometimes a named server/workspace). +2. Narrow candidates with `graph_query`. Reliable scoping fields on `Session` nodes: + `workspace`, `created_by`, `started_at`/`last_updated` (wrap date literals in + `datetime()`). Do **not** filter on a raw graph `working_dir` property — it is not + reliably populated in the graph (see the `context-intelligence-graph-query` skill); the + working directory you show the user comes from `session_summary`, not from Cypher. + Cap the candidate set to a small number (a handful) before doing per-candidate work. +3. For each shortlisted candidate: call `session_summary(session_id=)` for the + accurate facts, and delegate to `graph-analyst` for a narrative (see "Building the + narrative" above). Build a session details block for each. +4. Present the candidates (their details blocks) to the user and let them pick one. +5. Re-run `session_summary` on the chosen id, right before delete — a fresh preview, not + the one from the candidate list, in case anything changed in between. +6. Get an explicit, strong confirmation: restate exactly what will be permanently removed + (session id, counts) and from which server, and require a clear go-ahead — a plain "yes" + with no restatement is not enough. +7. Call `delete_session` on the chosen id and server. +8. Report exactly what was removed and from which server. + +## Flow 3 — delete a session someone else created + +1. Run Flow 1 or Flow 2 up through the preview step (`session_summary`), but do **not** + ask for the delete confirmation yet. +2. Compare the previewed session's `created_by` to the current user (see "Resolving 'this + session' and 'the current user'" above). +3. If they match, continue as Flow 1/2 normally (single confirmation). +4. If they do **not** match: + - State plainly: "this session was created by ``, not you." + - Ask a **separate, explicit, strong** confirmation — restating what will be permanently + removed and from which server — that the user still wants to delete someone else's + data, before proceeding. + - Only call `delete_session` after that second, explicit confirmation. + +--- + +## Multi-server handling (all flows) + +- `list_sources: true` on either tool returns the connectable set: every server this agent + can reach, each with `name`, `url`, `origin` (`source` or `destination`). +- Passing `source=` addresses one specific server by name from that set. +- Omitting `source` uses a default: the single configured tool source if there is exactly + one, otherwise the first configured destination. If two or more tool **sources** are + configured and none is named, the tool refuses and lists the valid names — pass one. +- Always state, in your reply to the user, which server (`source.name`) answered or was + acted on. + +## Errors to expect and how to talk about them + +- **404** — the session id is not known to that server. Say so plainly; check for a typo + or ask whether it might be on a different server. +- **409** — the session is still receiving data (cannot be deleted yet), or its id is + ambiguous across workspaces. Never force it or retry aggressively — tell the user it is + still live. +- **`ambiguous_source_selection` / `unknown_source`** — a source-selection problem, not a + server error. Call with `list_sources: true` and ask the user to pick a valid name. + +--- + +## Design notes + +- **Current session id / current user identity** — resolved from injected environment/ + status context first (see "Resolving 'this session' and 'the current user'" above); + asking the user is the fallback, never the first move. +- **The folder-exclusion mechanism** — the agent has no filesystem tool, deliberately. It + shows the user the exact setting to add and asks them to apply it; it never edits + `~/.amplifier/settings.yaml` itself (see "Folder exclusion" under Flow 1 above). +- **Folder-exclusion timing** — still an open question; see the QUESTION FOR USER note + under "Folder exclusion" above. Needs a decision before this flow ships. From 4f07e4f4fc39f4b8f564df55f5bac7061481b3d0 Mon Sep 17 00:00:00 2001 From: colombod Date: Tue, 1 Sep 2026 23:59:19 +0000 Subject: [PATCH 03/39] refactor(behaviors): both behaviors carry the three agents (no import) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The analysis and navigation behaviors each independently list the same three agents (graph-analyst, session-navigator, server-data-ops). The analysis behavior no longer imports the navigation behavior; each lists the agents itself, so they are two independent, equal copies. This registers the new server-data-ops agent so a user reaches it the same way as the others, and ships with no foundation change; the convergence eases the later transition. Descriptions updated to match. Part of context-intelligence session data delete (bundle, B4). 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- behaviors/context-intelligence-analysis.yaml | 15 ++++++++------- behaviors/context-intelligence-navigation.yaml | 10 +++++++--- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/behaviors/context-intelligence-analysis.yaml b/behaviors/context-intelligence-analysis.yaml index 4161ad8d..1f13e9d9 100644 --- a/behaviors/context-intelligence-analysis.yaml +++ b/behaviors/context-intelligence-analysis.yaml @@ -2,17 +2,18 @@ bundle: name: context-intelligence-analysis-behavior version: 0.1.0 description: > - LAYER 2 of 3. Adds graph-analyst + graph skills (blob-reading, graph-query, - session-reconstruction, workflow-pattern-analysis). Includes - context-intelligence-navigation. Use for graph read/query/exploration - without the design mode. - -includes: - - bundle: context-intelligence:behaviors/context-intelligence-navigation + Carries the three context-intelligence agents (graph-analyst, + session-navigator, server-data-ops) plus the graph skills (blob-reading, + graph-query, session-reconstruction, workflow-pattern-analysis). Does not + import the navigation behavior; it lists the three agents itself. Use for + graph read/query/exploration and session data operations without the design + mode. agents: include: - context-intelligence:graph-analyst + - context-intelligence:session-navigator + - context-intelligence:server-data-ops tools: - module: tool-skills diff --git a/behaviors/context-intelligence-navigation.yaml b/behaviors/context-intelligence-navigation.yaml index 65592b5d..d0e57d73 100644 --- a/behaviors/context-intelligence-navigation.yaml +++ b/behaviors/context-intelligence-navigation.yaml @@ -2,13 +2,17 @@ bundle: name: context-intelligence-navigation-behavior version: 0.1.0 description: > - LAYER 1 of 3 (innermost). Adds session-navigator (reads raw session JSONL - on disk, no graph server). Includes: nothing. Use alone for local/offline - navigation fallback. + Carries the three context-intelligence agents (graph-analyst, + session-navigator, server-data-ops) as an independent, equal copy of the + analysis behavior's agent set. Imports nothing. session-navigator reads raw + session JSONL on disk with no graph server; use for local/offline navigation + plus graph analysis and session data operations. agents: include: + - context-intelligence:graph-analyst - context-intelligence:session-navigator + - context-intelligence:server-data-ops tools: - module: tool-delegate From ba4bedb4885178829e46388163ff0d90c7992fca Mon Sep 17 00:00:00 2001 From: colombod Date: Wed, 2 Sep 2026 00:37:14 +0000 Subject: [PATCH 04/39] fix(server-data-ops): enforce load-bearing delete rules in the agent body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Digital Twin evaluation caught two real bugs, both from the agent not loading its own skill and improvising from its body: - All-servers completeness: a session can live on more than one server. The agent now checks every configured server, surfaces each one the session is on, deletes from each chosen server and verifies, and never reports the deletion complete while another server still holds it. (Previously it deleted from one of two servers and told the user 'done' -- a false success for a delete feature.) - Flow 1 folder-exclusion offer and Flow 2 root-prompt narrative (via graph-analyst) are now hard rules in the agent body, not skill-only steps the agent could skip. The three rules now live in the agent body so they hold even if the skill is not loaded; the skill keeps the detailed step order and now matches. Preview, the explicit confirmation gate, and the ownership warning are unchanged. Part of context-intelligence session data delete (bundle, B3). 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- agents/server-data-ops.md | 74 +++++++++++++++---- .../SKILL.md | 38 +++++++--- 2 files changed, 86 insertions(+), 26 deletions(-) diff --git a/agents/server-data-ops.md b/agents/server-data-ops.md index 89eb06ce..3ffa159e 100644 --- a/agents/server-data-ops.md +++ b/agents/server-data-ops.md @@ -59,10 +59,37 @@ You have no filesystem or bash tool in this agent — that is deliberate, not an ## Hard Rules -- **Load the skill first.** Before running any flow: - `Load skill: context-intelligence-server-data-ops`. It holds the exact step order, - wording, and the "session details" block format for all three flows. Do not improvise - the steps from memory. +The three correctness rules below — **all-servers completeness**, the **folder-exclusion +offer**, and the **graph-analyst narrative** — stand on their own **even if the skill +below is never loaded, fails to load, or you forget mid-conversation**. They are written +out in full here, in the agent body, precisely so they do not depend on that load +succeeding. Loading the skill is still required (it has the exact step order and +wording), but it is a step-order reference, not the thing that makes these three rules +true — do not treat it as covering them for you. + +- **Load the skill first — before anything else this turn.** + `Load skill: context-intelligence-server-data-ops`. Do this before you say anything to + the user about what you're about to do. It holds the exact step order, wording, and the + "session details" block format. But the skill is a step-order reference, not a safety + net — the rules in this section apply whether or not the load succeeds, and are never + something you improvise past from memory instead. + +- **ALL-SERVERS COMPLETENESS — the single most important rule in this file.** A session + can exist on more than one configured server. Before you ever tell the user a deletion + is "done": + 1. Call `session_summary` (or `delete_session`) with `list_sources: true` to see the + full connectable set, and check the target session against **every** server in it — + not just the one that seems obvious, not just the one the user happened to name. + 2. If the session exists on more than one server, **name all of them to the user** and + ask which to delete from (or "all"). + 3. Delete from **each** server the user chose, and verify **each one individually** + (a fresh `session_summary`, or the `delete_session` result) — one delete succeeding + says nothing about whether the others did. + 4. **Never say "done," "nothing else was touched," or anything implying full removal** + while the session still exists on a server you didn't act on or didn't check. If the + user chose on purpose to leave a server alone, say so explicitly ("it still exists on + `` — you asked me to leave that one alone"). Silence must never imply + the data is fully gone when it isn't. - **Tool-only access.** The only path to the server is `session_summary` / `delete_session` (and `graph_query` for finding candidates). Never raw HTTP, `curl`, or bash. - **Preview, then confirm, in that order.** Every delete is preceded by a `session_summary` @@ -74,27 +101,46 @@ You have no filesystem or bash tool in this agent — that is deliberate, not an with the blobs and queue records for all of them. Nodes shared with other sessions are kept. There is no undo and no restore — say this plainly before the user confirms, not only in fine print. +- **Flow 1 — offer the folder exclusion before deleting; this is not optional color.** + When the request is about the current session / "this working directory," before + deleting anything: check whether the session's working directory is covered by the + chosen destination's push filters, and if so, **offer** to add an exclusion. Show the + user the exact setting — + `overrides.hook-context-intelligence.config.destinations..exclude` in + `~/.amplifier/settings.yaml`, a gitignore-style pattern list matched against the working + directory. You have no filesystem tool and never edit this file yourself — show the + setting, offer to guide them through applying it, confirm whether they did, and only + then move to preview and delete. Do this every time this flow runs. +- **Every "session details" block needs a real narrative from `graph-analyst` — never + silently drop it.** Whenever you present a session details block (Flow 2 candidates, or + the pre-delete confirmation in any flow), its "Summary" line must come from delegating + to `graph-analyst` for a high-level overview built from that session's own **root** + prompts only (not its subsessions). If `graph-analyst` can't produce one, write + "not available" in that line — never fabricate one, and never leave the line out + entirely. - **Resolve "this session" / "current user" from context first.** Look for injected session-id and identity context; ask the user directly only as a fallback. See the skill for exactly where to look and the fallback path. -- **Delegate the narrative to `graph-analyst`.** Never invent the free-text "what was this - session about" summary yourself — get it from `graph-analyst`, or say plainly it isn't - available. See the skill for the exact delegation task. -- **Multi-server: never guess.** Use `list_sources: true` to discover servers; if more than - one applies and none is named, ask the user which one before calling `session_summary` or - `delete_session`. +- **Multi-server source selection: never guess which server to call.** Separate from the + all-servers completeness rule above: when a single `session_summary` or `delete_session` + call needs a `source` and none was named, use `list_sources: true` to discover the valid + names and ask the user which one applies — never guess or default silently. - **404 = unknown, 409 = still receiving / ambiguous.** Say so plainly; never retry a 409 forcefully or attempt a raw call around the tool. ## Flows See the `context-intelligence-server-data-ops` skill for the full step order and exact -wording of each. +wording of each. The Hard Rules above (all-servers completeness, the folder-exclusion +offer, the graph-analyst narrative) apply within every flow below regardless of whether +the skill loaded — they are not extra detail the skill adds on top. -- **Flow 1 — delete the current session.** Includes the folder-exclusion offer and the - impact statement, both before proceeding to delete. Runs here and now, in this session. +- **Flow 1 — delete the current session.** Includes the folder-exclusion offer, the + all-servers completeness check, and the impact statement, all before proceeding to + delete. Runs here and now, in this session. - **Flow 2 — find a session by description** (topic, date, sometimes a server), then - delete. Narrows candidates, presents session details blocks, user picks one. + delete. Narrows candidates, presents session details blocks (each with a real + graph-analyst narrative), user picks one. - **Flow 3 — delete a session someone else created.** Warn plainly that it wasn't created by the current user, then require a second, separate, explicit confirmation before deleting. diff --git a/skills/context-intelligence-server-data-ops/SKILL.md b/skills/context-intelligence-server-data-ops/SKILL.md index c3836196..898d3ca3 100644 --- a/skills/context-intelligence-server-data-ops/SKILL.md +++ b/skills/context-intelligence-server-data-ops/SKILL.md @@ -119,21 +119,27 @@ Steps, matching the approved scenario exactly: (see "Resolving 'this session' and 'the current user'" above) — ask the user directly only as a fallback. Ask the user to confirm they want to delete this session's data. 2. Call `session_summary(session_id=, list_sources=true)` or - `delete_session(list_sources=true)` to see every server this session was published to, - and report them to the user, asking "remove from all?" + `delete_session(list_sources=true)` to see **every** server in the connectable set, and + check which of them the session actually exists on — not just the one that seems + obvious. Report all of them to the user by name, asking "remove from all?" (this is + the all-servers completeness rule from the agent body; it applies here regardless of + how many servers turn out to hold the session). 3. The user picks which server(s) to remove it from. 4. If the session's folder is included by a chosen destination's filters, offer to add a folder exclusion for that destination (see "Folder exclusion" below) — so the folder is not pushed there anymore — and offer to guide the user through applying it. Do this **before** proceeding with deletion. -5. Proceed with the deletion for the chosen server(s): call +5. For **each** server the user chose (one at a time, not just the first): call `session_summary(session_id=, source=)` (the preview) and show the user the session details block built from it → get an explicit, strong confirmation - naming the specific session and server → call `delete_session` → report exactly what - was removed (session count, nodes/relationships/blobs/queue sessions) and which server - it came from. + naming the specific session and server → call `delete_session` → verify that server's + own result before moving to the next one. +6. Report exactly what was removed and from which server(s) — and if the session still + exists on any server that was not chosen for deletion, say so explicitly (by name). + Never say "done" or imply full removal while a server you didn't act on (or didn't + check) still holds the session. -All five steps happen in this same session. +All six steps happen in this same session. ### Folder exclusion (offered before deletion) @@ -172,13 +178,21 @@ resolution before this flow ships. accurate facts, and delegate to `graph-analyst` for a narrative (see "Building the narrative" above). Build a session details block for each. 4. Present the candidates (their details blocks) to the user and let them pick one. -5. Re-run `session_summary` on the chosen id, right before delete — a fresh preview, not - the one from the candidate list, in case anything changed in between. -6. Get an explicit, strong confirmation: restate exactly what will be permanently removed +5. All-servers completeness check (same rule as Flow 1): call `session_summary` or + `delete_session` with `list_sources: true` for the chosen id and check it against + **every** server in the connectable set. If it exists on more than one, name all of + them to the user and ask which to delete from (or "all") before continuing. +6. For **each** server chosen: re-run `session_summary` on the chosen id right before + delete — a fresh preview, not the one from the candidate list, in case anything changed + in between. +7. Get an explicit, strong confirmation: restate exactly what will be permanently removed (session id, counts) and from which server, and require a clear go-ahead — a plain "yes" with no restatement is not enough. -7. Call `delete_session` on the chosen id and server. -8. Report exactly what was removed and from which server. +8. Call `delete_session` on the chosen id and server, and verify that server's own result + before moving to the next chosen server. +9. Report exactly what was removed and from which server(s) — and if the session still + exists on any server that was not chosen, say so explicitly by name. Never say "done" + or imply full removal while an unchecked or unchosen server still holds the session. ## Flow 3 — delete a session someone else created From 50253c416dd7464209b58ae3b278f40deda21fdf Mon Sep 17 00:00:00 2001 From: colombod Date: Wed, 2 Sep 2026 06:31:43 +0000 Subject: [PATCH 05/39] feat(server-data-ops): wire server whoami into CI client + tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server now exposes GET /whoami, returning {"contributor_id": } (null when auth is disabled). This wires it through so the delete workflow can resolve "who am I" and compare against a session's created_by for ownership warnings. - context_intelligence/client.py: add whoami() to CIClient (sync, _http_get_strict) and AsyncCIClient (async, inline httpx GET), right beside session_summary()/delete_session(), using the identical request-build + CIClientError translation pattern. No retry, status codes preserved. - modules/tool-server-data-ops: add a third tool `whoami` (whoami_tool.py, class WhoamiTool) mounted by the SAME mount() using the SAME shared ToolConfigResolver as session_summary and delete_session -- one config/resolver, server configuration can never diverge across the three tools. execute() resolves the server exactly like its siblings (list_sources, source= selection, fail-loud ambiguity via resolve_query_connection/_connectable_pool), calls the library whoami(), and returns {"contributor_id": ..., "source": ...}. - Tests: unit tests for the library method (sync + async, mock transport) in tests/test_client.py, and for the tool in modules/tool-server-data-ops/tests/test_whoami_tool.py (happy path, list_sources, source selection/ambiguity fail-loud, config fallback, server error surfacing). Updated test_module.py's tool-count/name assertions and shared-resolver invariant test to cover all three tools (was hardcoded to two). 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- context_intelligence/client.py | 69 +++ .../__init__.py | 16 +- .../whoami_tool.py | 185 ++++++++ .../tool-server-data-ops/tests/test_module.py | 74 ++- .../tests/test_whoami_tool.py | 431 ++++++++++++++++++ tests/test_client.py | 163 +++++++ 6 files changed, 913 insertions(+), 25 deletions(-) create mode 100644 modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/whoami_tool.py create mode 100644 modules/tool-server-data-ops/tests/test_whoami_tool.py diff --git a/context_intelligence/client.py b/context_intelligence/client.py index fe3f6e53..b49c0a36 100644 --- a/context_intelligence/client.py +++ b/context_intelligence/client.py @@ -675,6 +675,30 @@ def delete_session(self, session_id: str) -> dict[str, Any]: url = f"{self._server_url}/sessions/{session_id}" return _http_delete_strict(url, self._auth_headers()) + def whoami(self) -> dict[str, Any]: + """Resolve the authenticated caller's identity from the server. + + Calls ``GET /whoami`` and returns the parsed JSON dict: the server's + view of who is making the request right now (e.g. + ``{"contributor_id": ""}``). ``contributor_id`` is ``None`` + when auth is disabled server-side. + + Returns + ------- + dict + The parsed identity dict. + + Raises + ------ + CIClientError + The request genuinely failed: connection error/refused, timeout, + non-2xx HTTP status, or a malformed (non-JSON) body. + ``status_code`` carries the exact number so the caller can give a + clear message. + """ + url = f"{self._server_url}/whoami" + return _http_get_strict(url, self._auth_headers()) + def health_check(self) -> dict[str, Any]: """Check server health by running a simple count query. @@ -1035,6 +1059,51 @@ async def delete_session(self, session_id: str) -> dict[str, Any]: f"connection error to {url}: {exc}", error_type="connection_error", url=url ) from exc + async def whoami(self) -> dict[str, Any]: + """Resolve the authenticated caller's identity from the server (async). + + Calls ``GET /whoami`` and returns the parsed JSON dict: the server's + view of who is making the request right now (e.g. + ``{"contributor_id": ""}``). ``contributor_id`` is ``None`` + when auth is disabled server-side. + + Returns + ------- + dict + The parsed identity dict. + + Raises + ------ + CIClientError + The request genuinely failed: connection error/refused, timeout, + non-2xx HTTP status, or a malformed (non-JSON) body. + ``status_code`` carries the exact number so the caller can give a + clear message. Honors ``self._timeout`` like ``session_summary()``. + """ + url = f"{self._server_url}/whoami" + try: + async with httpx.AsyncClient(timeout=self._timeout) as client: # type: ignore[union-attr] + resp = await client.get(url, headers=self._strategy.headers()) + resp.raise_for_status() + return resp.json() + except httpx.TimeoutException as exc: # type: ignore[union-attr] + raise CIClientError(f"timeout fetching {url}", error_type="timeout", url=url) from exc + except httpx.HTTPStatusError as exc: # type: ignore[union-attr] + raise CIClientError( + f"HTTP {exc.response.status_code} from {url}", + error_type="http_status", + url=url, + status_code=exc.response.status_code, + ) from exc + except (ValueError, json.JSONDecodeError) as exc: # resp.json() failed + raise CIClientError( + f"malformed JSON from {url}", error_type="decode_error", url=url + ) from exc + except httpx.HTTPError as exc: # type: ignore[union-attr] # ConnectError, transport, etc. + raise CIClientError( + f"connection error to {url}: {exc}", error_type="connection_error", url=url + ) from exc + async def health_check(self) -> dict[str, Any]: """Check server health by running a simple count query (async). diff --git a/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/__init__.py b/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/__init__.py index 38870a55..5bf84a6e 100644 --- a/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/__init__.py +++ b/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/__init__.py @@ -1,9 +1,10 @@ -"""Context Intelligence server data-ops tools -- session_summary and delete_session. +"""Context Intelligence server data-ops tools -- session_summary, delete_session, +and whoami. -Both tools share one ToolConfigResolver, so sources has a single +All three tools share one ToolConfigResolver, so sources has a single config namespace: overrides.tool-server-data-ops.config.sources. -Two tools, one mount(): idiomatic multi-tool module (same shape as +Three tools, one mount(): idiomatic multi-tool module (same shape as tool-context-intelligence-query, which mounts graph_query / blob_read from one mount() call). """ @@ -17,11 +18,11 @@ async def mount(coordinator: Any, config: Any) -> None: - """Mount both server-data-ops tools, sharing one ToolConfigResolver. + """Mount all three server-data-ops tools, sharing one ToolConfigResolver. The resolver is built ONCE from the module's config and injected into - both tools. Tool constructors do not accept config -- the resolver IS - the config surface. + all three tools. Tool constructors do not accept config -- the resolver + IS the config surface. The hook resolver is NOT fetched here; each tool fetches it lazily at first execute() because tools mount before hooks (kernel phase order is @@ -32,6 +33,7 @@ async def mount(coordinator: Any, config: Any) -> None: from .delete_session_tool import DeleteSessionTool from .session_summary_tool import SessionSummaryTool + from .whoami_tool import WhoamiTool resolver = ToolConfigResolver(config or {}, coordinator) # built ONCE # WARN-only diagnostic pass -- never raises; hard validation is per-source @@ -39,5 +41,7 @@ async def mount(coordinator: Any, config: Any) -> None: resolver.validate_sources() summary = SessionSummaryTool(coordinator, resolver) delete = DeleteSessionTool(coordinator, resolver) + whoami = WhoamiTool(coordinator, resolver) await coordinator.mount("tools", summary, name=summary.name) # "session_summary" await coordinator.mount("tools", delete, name=delete.name) # "delete_session" + await coordinator.mount("tools", whoami, name=whoami.name) # "whoami" diff --git a/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/whoami_tool.py b/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/whoami_tool.py new file mode 100644 index 00000000..bcd4e906 --- /dev/null +++ b/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/whoami_tool.py @@ -0,0 +1,185 @@ +"""WhoamiTool -- agent-facing tool that resolves the acting user's identity. + +Implements the Amplifier Tool protocol. Configuration and provenance are +resolved via ``resolve_query_connection`` (same as SessionSummaryTool and +DeleteSessionTool -- parity guaranteed by the shared helper), a SINGLE-HIT +selection over the connectable pool (tool ``sources`` union hook +``destinations``). See ``resolve_query_connection``'s docstring in +context_intelligence/tool_resolver.py for the authoritative selection rule +(in brief: explicit ``source=`` reaches any pool entry; with no name, +1 source -> it, 2+ sources -> fail loud, 0 sources -> the FIRST destination +in config order for any N, else env). + +Every result (success or failure) carries a ``source`` field naming the +endpoint that answered / was attempted. Callers can also pass +``list_sources: true`` to discover the connectable set without calling the +server. + +The ``ToolConfigResolver`` is injected at construction time by ``mount()`` +(one shared instance across all three server-data-ops tools -- single config +namespace). + +This tool never talks to the server directly -- the only path to the server +is through ``AsyncCIClient`` (the shared library). This is a READ (no +changes made): it resolves who the server thinks is making the request, +so an agent (e.g. the delete workflow) can compare it against a session's +``created_by`` for ownership warnings. +""" + +from __future__ import annotations + +from typing import Any + +from amplifier_core.models import ToolResult +from context_intelligence.client import AsyncCIClient, CIClientError +from context_intelligence.tool_resolver import ( + ToolConfigResolver, + _connectable_pool, + _origin_dict, + resolve_query_connection, +) + + +class WhoamiTool: + """Resolve the acting user's identity from the context-intelligence server. + + Implements the Amplifier Tool protocol (name, description, input_schema, + execute). Configuration and provenance are resolved via + resolve_query_connection() at execute() time, over the connectable pool + (tool sources union the hook's upload destinations). + """ + + def __init__(self, coordinator: Any, resolver: ToolConfigResolver | None = None) -> None: + self._coordinator = coordinator + self._tool_resolver = resolver or ToolConfigResolver({}, coordinator) + self._hook_resolver: Any | None = None + + @property + def name(self) -> str: + return "whoami" + + @property + def description(self) -> str: + return ( + "Resolve the authenticated caller's identity for the chosen " + "context-intelligence server -- returns the acting user's " + "`contributor_id` (their github id), or null when auth is " + "disabled server-side. Use this to answer 'who am I' and to " + "compare against a session's `created_by` for ownership " + "warnings before a delete. This makes NO changes -- it is a " + "read. Every result names the `source` (name/url/origin) that " + "answered -- ALWAYS state it in your answer." + ) + + @property + def input_schema(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "source": { + "type": "string", + "description": ( + "Optional name of a specific connectable endpoint (server) to " + "ask -- either a configured source OR a hook upload destination " + "(the full connectable set; call with list_sources=true to see " + "the names). Omitting `source` uses the default endpoint: the " + "single configured source, or -- if no sources are configured -- " + "the first destination in config order. The only case where " + "omitting it errors is when 2+ SOURCES are configured (then you " + "must pass source=, and the error lists the valid names)." + ), + }, + "list_sources": { + "type": "boolean", + "description": ( + "When true, do NOT resolve an identity. Return the connectable " + "set -- every server this tool can reach, with name, url, and " + "origin (source/destination). Use this to discover valid " + "`source` values before selecting one." + ), + }, + }, + "required": [], + } + + async def execute(self, input: dict[str, Any]) -> ToolResult: + from context_intelligence.tool_resolver import SourceSelectionError + + # Late-mount upgrade: retry hook capability lookup on every call while + # _hook_resolver is None (hook may mount after the tool). + if self._hook_resolver is None: + self._hook_resolver = self._coordinator.get_capability( + "context_intelligence.hook_config_resolver" + ) + + if input.get("list_sources"): + pool = _connectable_pool(self._tool_resolver, self._hook_resolver) + return ToolResult( + success=True, + output={ + "connectable_set": [ + {"name": e.name, "url": e.url, "origin": e.kind} for e in pool.values() + ] + }, + ) + + source_name = input.get("source") + try: + conn = resolve_query_connection( + self._hook_resolver, self._tool_resolver, source_name=source_name + ) + except SourceSelectionError as exc: + return ToolResult( + success=False, + error={ + "message": str(exc), + "type": exc.error_type, # "unknown_source" | "ambiguous_source_selection" + "valid_sources": exc.valid_names, + }, + ) + except ValueError as exc: + # The selected source itself is misconfigured -- names only it. + return ToolResult( + success=False, + error={"message": str(exc), "type": "source_misconfigured"}, + ) + + if not conn.url: + return ToolResult( + success=False, + error={ + "message": "context-intelligence server URL not configured", + "type": "configuration_error", + }, + ) + + async_client = AsyncCIClient( + server_url=conn.url, + api_key=conn.api_key or "", + auth_strategy=conn.auth_strategy, + timeout=self._tool_resolver.request_timeout, + ) + try: + identity = await async_client.whoami() + except CIClientError as exc: + # success=False + output unset is safe: ToolResult.model_post_init + # back-fills output from error["message"] when output is None. Do NOT + # also set output= here or that back-fill is suppressed. + origin_name = conn.origin.name if conn.origin and conn.origin.name else conn.url + message = f"whoami lookup failed against {origin_name}: {exc}" + return ToolResult( + success=False, + error={ + "message": message, + "type": exc.error_type, # connection_error|timeout|http_status|decode_error + "source": _origin_dict(conn.origin), + **({"status_code": exc.status_code} if exc.status_code is not None else {}), + }, + ) + return ToolResult( + success=True, + output={ + "contributor_id": identity.get("contributor_id"), + "source": _origin_dict(conn.origin), + }, + ) diff --git a/modules/tool-server-data-ops/tests/test_module.py b/modules/tool-server-data-ops/tests/test_module.py index c7d0b331..824efacd 100644 --- a/modules/tool-server-data-ops/tests/test_module.py +++ b/modules/tool-server-data-ops/tests/test_module.py @@ -1,8 +1,8 @@ """Module-level contract tests for tool-server-data-ops. -Tests for the merged two-tool module: mount registers both tools from one -call, the ToolConfigResolver is shared (one instance, identical resolution), -and the lazy hook lookup stays lazy (not cached at mount time). +Tests for the merged three-tool module: mount registers all three tools from +one call, the ToolConfigResolver is shared (one instance, identical +resolution), and the lazy hook lookup stays lazy (not cached at mount time). """ from __future__ import annotations @@ -69,22 +69,22 @@ def test_mount_signature_has_coordinator_and_config(self) -> None: # --------------------------------------------------------------------------- -# TestMountRegistersExactlyTwoTools +# TestMountRegistersExactlyThreeTools # --------------------------------------------------------------------------- -class TestMountRegistersExactlyTwoTools: - """mount() must register exactly two tools with distinct names.""" +class TestMountRegistersExactlyThreeTools: + """mount() must register exactly three tools with distinct names.""" - async def test_mount_registers_exactly_two_tools(self) -> None: + async def test_mount_registers_exactly_three_tools(self) -> None: from amplifier_module_tool_server_data_ops import mount coordinator = _make_coordinator() await mount(coordinator, config={}) - assert coordinator.mount.call_count == 2 + assert coordinator.mount.call_count == 3 - async def test_both_tool_calls_use_tools_category(self) -> None: + async def test_all_tool_calls_use_tools_category(self) -> None: from amplifier_module_tool_server_data_ops import mount coordinator = _make_coordinator() @@ -93,14 +93,14 @@ async def test_both_tool_calls_use_tools_category(self) -> None: for call in coordinator.mount.call_args_list: assert call.args[0] == "tools" - async def test_tool_names_are_session_summary_and_delete_session(self) -> None: + async def test_tool_names_are_session_summary_delete_session_and_whoami(self) -> None: from amplifier_module_tool_server_data_ops import mount coordinator = _make_coordinator() await mount(coordinator, config={}) registered_names = {call.kwargs["name"] for call in coordinator.mount.call_args_list} - assert registered_names == {"session_summary", "delete_session"} + assert registered_names == {"session_summary", "delete_session", "whoami"} async def test_mounted_tools_are_protocol_compliant(self) -> None: from amplifier_module_tool_server_data_ops import mount @@ -142,8 +142,8 @@ async def test_mount_makes_no_register_capability_call(self) -> None: class TestSharedResolverInvariant: """The ToolConfigResolver is shared: one instance, identical resolution.""" - async def test_both_tools_have_same_resolver_instance(self) -> None: - """summary._tool_resolver is delete._tool_resolver: same object from mount().""" + async def test_all_three_tools_have_same_resolver_instance(self) -> None: + """summary/delete/whoami._tool_resolver are all the SAME object from mount().""" from amplifier_module_tool_server_data_ops import mount coordinator = _make_coordinator() @@ -152,10 +152,12 @@ async def test_both_tools_have_same_resolver_instance(self) -> None: tools = {call.kwargs["name"]: call.args[1] for call in coordinator.mount.call_args_list} summary = tools["session_summary"] delete = tools["delete_session"] + whoami = tools["whoami"] assert summary._tool_resolver is delete._tool_resolver + assert summary._tool_resolver is whoami._tool_resolver async def test_shared_resolver_consistency_same_url_and_api_key(self) -> None: - """Both tools resolve to the SAME (url, api_key) from sources. + """All three tools resolve to the SAME (url, api_key) from sources. This is the load-bearing correctness invariant: with a shared resolver, divergent read-endpoint config is structurally impossible. @@ -175,13 +177,17 @@ async def test_shared_resolver_consistency_same_url_and_api_key(self) -> None: tools = {call.kwargs["name"]: call.args[1] for call in coordinator.mount.call_args_list} summary = tools["session_summary"] delete = tools["delete_session"] + whoami = tools["whoami"] # Resolve using the shared resolver (no hook resolver needed for tier-1 hit) summary_conn = resolve_query_connection(None, summary._tool_resolver) delete_conn = resolve_query_connection(None, delete._tool_resolver) + whoami_conn = resolve_query_connection(None, whoami._tool_resolver) - assert summary_conn.url == delete_conn.url == "http://data-ops.example.com" - assert summary_conn.api_key == delete_conn.api_key == "shared-key" + assert ( + summary_conn.url == delete_conn.url == whoami_conn.url == "http://data-ops.example.com" + ) + assert summary_conn.api_key == delete_conn.api_key == whoami_conn.api_key == "shared-key" # --------------------------------------------------------------------------- @@ -261,6 +267,36 @@ async def test_late_mount_delete_session_resolves_destination_after_hook_registe assert call_kwargs["server_url"] == "http://late-hook.example.com" assert call_kwargs["api_key"] == "late-key" + async def test_late_mount_whoami_resolves_destination_after_hook_registers( + self, + ) -> None: + """WhoamiTool: mount with no hook -> register hook -> execute sees destination.""" + from amplifier_module_tool_server_data_ops import mount + + coordinator = _make_coordinator(hook_resolver=None) + await mount(coordinator, config={}) + tools = {call.kwargs["name"]: call.args[1] for call in coordinator.mount.call_args_list} + whoami = tools["whoami"] + + assert whoami._hook_resolver is None + + hook_resolver = _make_hook_resolver(url="http://late-hook.example.com", api_key="late-key") + coordinator.get_capability.return_value = hook_resolver + + mock_client = MagicMock() + mock_client.whoami = AsyncMock(return_value={"contributor_id": "alice"}) + mock_cls = MagicMock(return_value=mock_client) + with patch( + "amplifier_module_tool_server_data_ops.whoami_tool.AsyncCIClient", + mock_cls, + ): + result = await whoami.execute({}) + + assert result.success is True + call_kwargs = mock_cls.call_args.kwargs + assert call_kwargs["server_url"] == "http://late-hook.example.com" + assert call_kwargs["api_key"] == "late-key" + # --------------------------------------------------------------------------- # TestMountWithMisconfiguredSource @@ -284,7 +320,7 @@ async def test_mount_does_not_raise_with_one_bad_source(self) -> None: result = await mount(coordinator, config=config) assert result is None - async def test_mount_registers_both_tools_with_one_bad_source(self) -> None: + async def test_mount_registers_all_tools_with_one_bad_source(self) -> None: from amplifier_module_tool_server_data_ops import mount config = { @@ -296,9 +332,9 @@ async def test_mount_registers_both_tools_with_one_bad_source(self) -> None: coordinator = _make_coordinator() await mount(coordinator, config=config) - assert coordinator.mount.call_count == 2 + assert coordinator.mount.call_count == 3 registered_names = {call.kwargs["name"] for call in coordinator.mount.call_args_list} - assert registered_names == {"session_summary", "delete_session"} + assert registered_names == {"session_summary", "delete_session", "whoami"} async def test_mount_logs_warning_with_one_bad_source(self, caplog: Any) -> None: import logging diff --git a/modules/tool-server-data-ops/tests/test_whoami_tool.py b/modules/tool-server-data-ops/tests/test_whoami_tool.py new file mode 100644 index 00000000..378117f2 --- /dev/null +++ b/modules/tool-server-data-ops/tests/test_whoami_tool.py @@ -0,0 +1,431 @@ +"""Tests for WhoamiTool. + +Constructor: WhoamiTool(coordinator, resolver=None). Patch path is +amplifier_module_tool_server_data_ops.whoami_tool. +""" + +from __future__ import annotations + +import os +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + + +def _make_coordinator(resolver: Any = None) -> MagicMock: + coordinator = MagicMock() + coordinator.config = {} + coordinator.get_capability = MagicMock(return_value=resolver) + return coordinator + + +def _make_hook_resolver( + server_url: str | None = "http://localhost:8080", + workspace: str = "test-workspace", + api_key: str = "test-api-key", +) -> MagicMock: + """Create a hook resolver mock (returned by get_capability).""" + resolver = MagicMock() + resolver.workspace = workspace + if server_url: + resolver.destinations = { + "default": SimpleNamespace(name="default", url=server_url, api_key=api_key), + } + else: + resolver.destinations = {} + return resolver + + +def _make_hook_resolver_with_dests(destinations: dict) -> MagicMock: + """Hook resolver mock with a specific destinations dict.""" + resolver = MagicMock() + resolver.workspace = "test-workspace" + resolver.destinations = destinations + return resolver + + +def _make_mock_async_ci_client(return_value: Any = None): + """Return (mock_instance, mock_cls) for patching AsyncCIClient.""" + mock_instance = AsyncMock() + mock_instance.whoami = AsyncMock( + return_value=return_value if return_value is not None else {"contributor_id": "alice"} + ) + mock_cls = MagicMock(return_value=mock_instance) + return mock_instance, mock_cls + + +def _make_tool_resolver(config: dict, coordinator: Any = None) -> Any: + """Build a real ToolConfigResolver from a config dict (for injection).""" + from context_intelligence.tool_resolver import ToolConfigResolver + + coord = coordinator or MagicMock() + coord.config = {} + return ToolConfigResolver(config, coord) + + +# --------------------------------------------------------------------------- +# TestWhoamiToolProtocol +# --------------------------------------------------------------------------- + + +class TestWhoamiToolProtocol: + """Tool protocol surface tests.""" + + def test_name_is_whoami(self) -> None: + from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + + tool = WhoamiTool(_make_coordinator()) + assert tool.name == "whoami" + + def test_description_mentions_contributor_id(self) -> None: + from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + + tool = WhoamiTool(_make_coordinator()) + assert "contributor_id" in tool.description + + def test_input_schema_returns_object_type(self) -> None: + from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + + tool = WhoamiTool(_make_coordinator()) + assert tool.input_schema["type"] == "object" + + def test_input_schema_has_optional_source_and_list_sources(self) -> None: + from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + + tool = WhoamiTool(_make_coordinator()) + props = tool.input_schema["properties"] + assert "source" in props + assert "list_sources" in props + assert "source" not in tool.input_schema["required"] + assert "list_sources" not in tool.input_schema["required"] + # whoami takes no session_id -- there is no session to look up. + assert "session_id" not in props + + async def test_execute_returns_tool_result(self) -> None: + from amplifier_core.models import ToolResult + + from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + + hook_resolver = _make_hook_resolver() + coordinator = _make_coordinator(resolver=hook_resolver) + tool = WhoamiTool(coordinator) + + _, mock_cls = _make_mock_async_ci_client() + with patch( + "amplifier_module_tool_server_data_ops.whoami_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({}) + + assert isinstance(result, ToolResult) + + +# --------------------------------------------------------------------------- +# TestListSources +# --------------------------------------------------------------------------- + + +class TestListSources: + async def test_list_sources_does_not_call_client(self) -> None: + from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + + resolver = _make_tool_resolver( + {"sources": {"only": {"url": "http://only.example.com", "api_key": "k"}}} + ) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = WhoamiTool(coordinator, resolver) + + mock_cls = MagicMock() + with patch( + "amplifier_module_tool_server_data_ops.whoami_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"list_sources": True}) + + assert result.success is True + assert result.output is not None + names = {e["name"] for e in result.output["connectable_set"]} + assert names == {"only"} + mock_cls.assert_not_called() + + +# --------------------------------------------------------------------------- +# TestWhoamiConstruction -- AsyncCIClient construction and delegation +# --------------------------------------------------------------------------- + + +class TestWhoamiConstruction: + """AsyncCIClient construction and delegation tests (mirrors SessionSummaryTool).""" + + async def test_client_constructed_with_server_url_and_api_key(self) -> None: + from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + + hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000", api_key="my-key") + coordinator = _make_coordinator(resolver=hook_resolver) + tool = WhoamiTool(coordinator) + + _, mock_cls = _make_mock_async_ci_client() + with patch( + "amplifier_module_tool_server_data_ops.whoami_tool.AsyncCIClient", + mock_cls, + ): + await tool.execute({}) + + mock_cls.assert_called_once() + call_kwargs = mock_cls.call_args.kwargs + assert call_kwargs.get("server_url") == "http://ci-server:9000" + assert call_kwargs.get("api_key") == "my-key" + + async def test_whoami_called_with_no_arguments(self) -> None: + from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + + hook_resolver = _make_hook_resolver() + coordinator = _make_coordinator(resolver=hook_resolver) + tool = WhoamiTool(coordinator) + + mock_instance, mock_cls = _make_mock_async_ci_client() + with patch( + "amplifier_module_tool_server_data_ops.whoami_tool.AsyncCIClient", + mock_cls, + ): + await tool.execute({}) + + mock_instance.whoami.assert_called_once_with() + + async def test_result_forwarded_and_source_stamped(self) -> None: + from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + + hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000") + coordinator = _make_coordinator(resolver=hook_resolver) + tool = WhoamiTool(coordinator) + + _, mock_cls = _make_mock_async_ci_client(return_value={"contributor_id": "alice"}) + with patch( + "amplifier_module_tool_server_data_ops.whoami_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({}) + + assert result.success is True + assert result.output is not None + assert result.output["contributor_id"] == "alice" + assert result.output["source"] is not None + assert result.output["source"]["url"] == "http://ci-server:9000" + + async def test_null_contributor_id_when_auth_disabled(self) -> None: + """Server returns contributor_id: null when auth is disabled -- passed through as-is.""" + from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + + hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000") + coordinator = _make_coordinator(resolver=hook_resolver) + tool = WhoamiTool(coordinator) + + _, mock_cls = _make_mock_async_ci_client(return_value={"contributor_id": None}) + with patch( + "amplifier_module_tool_server_data_ops.whoami_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({}) + + assert result.success is True + assert result.output is not None + assert result.output["contributor_id"] is None + + +# --------------------------------------------------------------------------- +# TestWhoamiConfigFallback +# --------------------------------------------------------------------------- + + +class TestWhoamiConfigFallback: + async def test_capability_not_found_returns_configuration_error(self) -> None: + from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + + coordinator = _make_coordinator(resolver=None) + tool = WhoamiTool(coordinator) + clean = {k: "" for k in os.environ if k.startswith("AMPLIFIER_CONTEXT_INTELLIGENCE_")} + with patch.dict(os.environ, clean): + result = await tool.execute({}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "configuration_error" + + +# --------------------------------------------------------------------------- +# TestWhoamiSourceSelection -- pool/selection + fail-loud ambiguity +# --------------------------------------------------------------------------- + + +class TestWhoamiSourceSelection: + """execute() with an explicit `source` -- matching / not matching / omitted-with-2+.""" + + def _two_source_config(self) -> dict: + return { + "sources": { + "alpha": {"url": "http://alpha.example.com", "api_key": "alpha-key"}, + "beta": {"url": "http://beta.example.com", "api_key": "beta-key"}, + } + } + + async def test_source_matching_name_selects_that_source(self) -> None: + from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + + resolver = _make_tool_resolver(self._two_source_config()) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = WhoamiTool(coordinator, resolver) + + _, mock_cls = _make_mock_async_ci_client() + with patch( + "amplifier_module_tool_server_data_ops.whoami_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"source": "beta"}) + + assert result.success is True + call_kwargs = mock_cls.call_args.kwargs + assert call_kwargs["server_url"] == "http://beta.example.com" + assert call_kwargs["api_key"] == "beta-key" + + async def test_source_not_matching_returns_unknown_source_error(self) -> None: + from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + + resolver = _make_tool_resolver(self._two_source_config()) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = WhoamiTool(coordinator, resolver) + + result = await tool.execute({"source": "gamma"}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "unknown_source" + assert result.error["valid_sources"] == ["alpha", "beta"] + + async def test_source_omitted_with_two_configured_returns_ambiguous_error(self) -> None: + from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + + resolver = _make_tool_resolver(self._two_source_config()) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = WhoamiTool(coordinator, resolver) + + result = await tool.execute({}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "ambiguous_source_selection" + assert result.error["valid_sources"] == ["alpha", "beta"] + + async def test_source_omitted_with_one_configured_still_succeeds(self) -> None: + """Safe to omit source with exactly one configured (backward compatible).""" + from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + + config = { + "sources": { + "default": {"url": "http://only.example.com", "api_key": "only-key"}, + } + } + resolver = _make_tool_resolver(config) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = WhoamiTool(coordinator, resolver) + + _, mock_cls = _make_mock_async_ci_client() + with patch( + "amplifier_module_tool_server_data_ops.whoami_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({}) + + assert result.success is True + call_kwargs = mock_cls.call_args.kwargs + assert call_kwargs["server_url"] == "http://only.example.com" + + async def test_selected_source_misconfigured_returns_source_misconfigured_error(self) -> None: + from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + + config = { + "sources": { + "good": {"url": "http://good.example.com", "api_key": "gk"}, + "bad": {"url": "", "api_key": ""}, + } + } + resolver = _make_tool_resolver(config) + coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) + tool = WhoamiTool(coordinator, resolver) + + result = await tool.execute({"source": "bad"}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "source_misconfigured" + assert "bad" in result.error["message"] + + +# --------------------------------------------------------------------------- +# TestWhoamiServerErrors -- transport/HTTP failures surfaced as clear tool errors +# --------------------------------------------------------------------------- + + +class TestWhoamiServerErrors: + async def test_http_error_surfaces_as_clear_tool_error(self) -> None: + from context_intelligence.client import CIClientError + + from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + + hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000") + coordinator = _make_coordinator(resolver=hook_resolver) + tool = WhoamiTool(coordinator) + + mock_instance = AsyncMock() + mock_instance.whoami = AsyncMock( + side_effect=CIClientError( + "HTTP 500 from http://ci-server:9000/whoami", + error_type="http_status", + url="http://ci-server:9000/whoami", + status_code=500, + ) + ) + mock_cls = MagicMock(return_value=mock_instance) + with patch( + "amplifier_module_tool_server_data_ops.whoami_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "http_status" + assert result.error["status_code"] == 500 + assert result.error["source"] is not None + + async def test_connection_error_surfaces_as_clear_tool_error(self) -> None: + from context_intelligence.client import CIClientError + + from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + + hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000") + coordinator = _make_coordinator(resolver=hook_resolver) + tool = WhoamiTool(coordinator) + + mock_instance = AsyncMock() + mock_instance.whoami = AsyncMock( + side_effect=CIClientError( + "connection error to http://ci-server:9000/whoami: refused", + error_type="connection_error", + url="http://ci-server:9000/whoami", + ) + ) + mock_cls = MagicMock(return_value=mock_instance) + with patch( + "amplifier_module_tool_server_data_ops.whoami_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({}) + + assert result.success is False + assert result.error is not None + assert result.error["type"] == "connection_error" + assert result.error["source"] is not None diff --git a/tests/test_client.py b/tests/test_client.py index 9f880c64..c7d840b2 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -449,6 +449,82 @@ def test_session_summary_propagates_ciclienterror_409(self): assert excinfo.value.status_code == 409 +class TestCIClientWhoami: + """CIClient.whoami() must GET /whoami and return a dict.""" + + def test_whoami_returns_dict(self): + """whoami() returns the parsed identity dict from the server.""" + from context_intelligence.client import CIClient + + client = CIClient("http://localhost:8000", "key") + mock_response = {"contributor_id": "octocat"} + + with patch("context_intelligence.client._http_get_strict") as mock_get: + mock_get.return_value = mock_response + result = client.whoami() + + assert result == mock_response + + def test_whoami_calls_correct_url(self): + """whoami() calls GET /whoami.""" + from context_intelligence.client import CIClient + + client = CIClient("http://localhost:8000", "key") + + with patch("context_intelligence.client._http_get_strict") as mock_get: + mock_get.return_value = {} + client.whoami() + + call_args = mock_get.call_args + url = call_args[0][0] if call_args[0] else call_args[1]["url"] + assert url == "http://localhost:8000/whoami" + + def test_whoami_includes_authorization_header(self): + """whoami() sends Authorization: Bearer .""" + from context_intelligence.client import CIClient + + client = CIClient("http://localhost:8000", "secretkey") + + with patch("context_intelligence.client._http_get_strict") as mock_get: + mock_get.return_value = {} + client.whoami() + + call_args = mock_get.call_args + headers = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("headers", {}) + assert headers.get("Authorization") == "Bearer secretkey" + + def test_whoami_returns_null_contributor_id_when_auth_disabled(self): + """A server with auth disabled returns contributor_id: null -- passed through.""" + from context_intelligence.client import CIClient + + client = CIClient("http://localhost:8000", "key") + + with patch("context_intelligence.client._http_get_strict") as mock_get: + mock_get.return_value = {"contributor_id": None} + result = client.whoami() + + assert result == {"contributor_id": None} + + def test_whoami_propagates_ciclienterror(self): + """A genuine transport/HTTP failure must not be swallowed -- it propagates.""" + from context_intelligence.client import CIClient, CIClientError + + client = CIClient("http://localhost:8000", "key") + + with patch("context_intelligence.client._http_get_strict") as mock_get: + mock_get.side_effect = CIClientError( + "HTTP 500 from http://localhost:8000/whoami", + error_type="http_status", + url="http://localhost:8000/whoami", + status_code=500, + ) + with pytest.raises(CIClientError) as excinfo: + client.whoami() + + assert excinfo.value.error_type == "http_status" + assert excinfo.value.status_code == 500 + + class TestCIClientDeleteSession: """CIClient.delete_session() must DELETE /sessions/{id} and return a dict.""" @@ -960,6 +1036,93 @@ async def test_async_session_summary_raises_on_409(self): assert exc_info.value.status_code == 409 +class TestAsyncCIClientWhoami: + """AsyncCIClient.whoami() must GET /whoami.""" + + async def test_async_whoami_returns_parsed_dict(self): + """whoami() returns the parsed identity dict from the server.""" + from context_intelligence.client import AsyncCIClient + + identity_data = {"contributor_id": "octocat"} + mock_resp = _make_async_mock_response(identity_data) + mock_http = _make_async_httpx_client(mock_resp) + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "testkey") + result = await client.whoami() + + assert result == identity_data + + async def test_async_whoami_calls_correct_url_and_method(self): + """whoami() GETs {server_url}/whoami.""" + from context_intelligence.client import AsyncCIClient + + mock_resp = _make_async_mock_response({}) + mock_http = _make_async_httpx_client(mock_resp) + mock_inner_client = mock_http.__aenter__.return_value + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "testkey") + await client.whoami() + + assert mock_inner_client.get.called, "whoami must use GET" + call_args = mock_inner_client.get.call_args + url = call_args[0][0] if call_args[0] else call_args[1]["url"] + assert url == "http://localhost:8000/whoami" + + async def test_async_whoami_sends_auth_header(self): + """whoami() sends Authorization: Bearer .""" + from context_intelligence.client import AsyncCIClient + + mock_resp = _make_async_mock_response({}) + mock_http = _make_async_httpx_client(mock_resp) + mock_inner_client = mock_http.__aenter__.return_value + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "secretkey") + await client.whoami() + + call_kwargs = mock_inner_client.get.call_args + sent_headers = call_kwargs[1].get("headers") or call_kwargs[0][1] + assert sent_headers.get("Authorization") == "Bearer secretkey" + + async def test_async_whoami_returns_null_contributor_id_when_auth_disabled(self): + """A server with auth disabled returns contributor_id: null -- passed through.""" + from context_intelligence.client import AsyncCIClient + + mock_resp = _make_async_mock_response({"contributor_id": None}) + mock_http = _make_async_httpx_client(mock_resp) + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "testkey") + result = await client.whoami() + + assert result == {"contributor_id": None} + + async def test_async_whoami_raises_on_500(self): + """A genuine HTTP failure raises CIClientError(error_type='http_status').""" + import httpx + + from context_intelligence.client import AsyncCIClient, CIClientError + + request = httpx.Request("GET", "http://localhost:8000/whoami") + real_response = httpx.Response(status_code=500, request=request) + mock_resp = MagicMock() + mock_resp.status_code = 500 + mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError( + "500", request=request, response=real_response + ) + mock_http = _make_async_httpx_client(mock_resp) + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "testkey") + with pytest.raises(CIClientError) as exc_info: + await client.whoami() + + assert exc_info.value.error_type == "http_status" + assert exc_info.value.status_code == 500 + + class TestAsyncCIClientDeleteSession: """AsyncCIClient.delete_session() must DELETE /sessions/{id}.""" From 3082a53921197b3e7218626542c9174fefee7a5b Mon Sep 17 00:00:00 2001 From: colombod Date: Wed, 2 Sep 2026 06:35:39 +0000 Subject: [PATCH 06/39] fix(server-data-ops): whoami-based ownership check, unconditional folder-exclusion offer, forbid raw-prompt narrative MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three agent-behavior gaps exposed by a Digital Twin evaluation: 1. Ownership false positives: the agent warned "created by X, not you" even on the user's own sessions, because it had no way to know who the acting user actually was. Now resolves the acting user via the whoami tool (same server as the session), compares contributor_id to created_by, and only warns on a genuine mismatch. A null contributor_id (auth disabled) asks the user instead of guessing or warning. 2. Flow 1 folder-exclusion offer was silently skipped because it was gated on first proving the folder was filter-included -- something the agent cannot reliably determine. The offer is now unconditional: it always fires for current-session deletes, before deleting. 3. Flow 2 session-details narrative sometimes used a raw quoted prompt instead of a graph-analyst summary. Raw-prompt-quoting is now explicitly forbidden; the Summary line must come from delegating to graph-analyst, falling back to "not available" only if that delegation fails. Updated files: - agents/server-data-ops.md: added the whoami tool to the Tools list, rewrote the ownership Hard Rule and Flow 3 summary, made the folder-exclusion Hard Rule unconditional, and extended the narrative Hard Rule to forbid raw-prompt quoting. - skills/context-intelligence-server-data-ops/SKILL.md: added whoami to the tools section, rewrote "Resolving current user" to use whoami instead of injected context, rewrote Flow 3 step-by-step around the whoami comparison (including the null-contributor_id fallback), made the Flow 1 folder-exclusion step and its subsection unconditional, and added an explicit forbidden-shortcut note under "Building the narrative." No new scope: no hand-off, fresh-session, tombstone, or archive behavior was added. All previously-passing behavior (all-servers completeness, preview-then-confirm, impact statement, tool-only access, 404/409 handling, permanence) is unchanged. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- agents/server-data-ops.md | 96 ++++++++----- .../SKILL.md | 128 ++++++++++++------ 2 files changed, 145 insertions(+), 79 deletions(-) diff --git a/agents/server-data-ops.md b/agents/server-data-ops.md index 3ffa159e..e28ad3c2 100644 --- a/agents/server-data-ops.md +++ b/agents/server-data-ops.md @@ -51,6 +51,9 @@ the user. - `session_summary` / `delete_session` (tool-server-data-ops) — preview and permanently delete a session's whole graph on a server. +- `whoami` (tool-server-data-ops) — resolve the acting user's own identity + (`contributor_id`) for a given server. This is how you find out who "you" are, + so you can compare against a session's `created_by`. - `graph_query` (tool-context-intelligence-query) — narrow candidate sessions by description. - `delegate` — hand off narrative-building to `graph-analyst`. - `load_skill` — load `context-intelligence-server-data-ops` for the full procedure. @@ -59,13 +62,14 @@ You have no filesystem or bash tool in this agent — that is deliberate, not an ## Hard Rules -The three correctness rules below — **all-servers completeness**, the **folder-exclusion -offer**, and the **graph-analyst narrative** — stand on their own **even if the skill -below is never loaded, fails to load, or you forget mid-conversation**. They are written -out in full here, in the agent body, precisely so they do not depend on that load -succeeding. Loading the skill is still required (it has the exact step order and -wording), but it is a step-order reference, not the thing that makes these three rules -true — do not treat it as covering them for you. +The four correctness rules below — **all-servers completeness**, the **folder-exclusion +offer**, the **graph-analyst narrative**, and the **whoami-based ownership check** — +stand on their own **even if the skill below is never loaded, fails to load, or you +forget mid-conversation**. They are written out in full here, in the agent body, +precisely so they do not depend on that load succeeding. Loading the skill is still +required (it has the exact step order and wording), but it is a step-order reference, +not the thing that makes these four rules true — do not treat it as covering them for +you. - **Load the skill first — before anything else this turn.** `Load skill: context-intelligence-server-data-ops`. Do this before you say anything to @@ -101,26 +105,46 @@ true — do not treat it as covering them for you. with the blobs and queue records for all of them. Nodes shared with other sessions are kept. There is no undo and no restore — say this plainly before the user confirms, not only in fine print. -- **Flow 1 — offer the folder exclusion before deleting; this is not optional color.** - When the request is about the current session / "this working directory," before - deleting anything: check whether the session's working directory is covered by the - chosen destination's push filters, and if so, **offer** to add an exclusion. Show the - user the exact setting — +- **Flow 1 — offer the folder exclusion before deleting; this offer is UNCONDITIONAL, + never gated on first checking anything.** Whenever the request is about the current + session / "this working directory," before deleting anything: **always** offer to add + a folder exclusion, whether or not you have (or could have) confirmed the destination's + push filters actually cover this folder. Do not try to first check whether the folder + is filter-included before deciding to offer — you cannot reliably determine that, and + skipping the offer because that check wasn't done (or came back unclear) is exactly the + mistake this rule exists to prevent. Show the user the exact setting — `overrides.hook-context-intelligence.config.destinations..exclude` in - `~/.amplifier/settings.yaml`, a gitignore-style pattern list matched against the working + `~/.amplifier/settings.yaml`, a gitignore-style pattern matched against the working directory. You have no filesystem tool and never edit this file yourself — show the setting, offer to guide them through applying it, confirm whether they did, and only - then move to preview and delete. Do this every time this flow runs. + then move to preview and delete. Do this every single time this flow runs, with no + precondition. - **Every "session details" block needs a real narrative from `graph-analyst` — never - silently drop it.** Whenever you present a session details block (Flow 2 candidates, or - the pre-delete confirmation in any flow), its "Summary" line must come from delegating - to `graph-analyst` for a high-level overview built from that session's own **root** - prompts only (not its subsessions). If `graph-analyst` can't produce one, write - "not available" in that line — never fabricate one, and never leave the line out - entirely. -- **Resolve "this session" / "current user" from context first.** Look for injected - session-id and identity context; ask the user directly only as a fallback. See the skill - for exactly where to look and the fallback path. + silently drop it, and never quote the raw prompt instead.** Whenever you present a + session details block (Flow 2 candidates, or the pre-delete confirmation in any flow), + its "Summary" line **must** come from delegating to `graph-analyst` for a high-level + overview built from that session's own **root** prompts only (not its subsessions). + **Putting the session's raw first prompt text (or any other raw prompt text) straight + into the Summary line is forbidden** — it is not a substitute for delegating, even when + it seems like it would be faster or more accurate. If `graph-analyst` can't produce a + narrative, write "not available" in that line — never fabricate one, never leave the + line out entirely, and never fall back to a raw quote instead. +- **Resolve ownership with `whoami` before deciding whether to warn — never warn on a + guess.** Before deciding whether to show the ownership warning (Flow 3), call the + `whoami` tool for the **same server** the session in question is on, and read its + `contributor_id`. Compare that to the session's `created_by`: + - **Different** → this is a genuine not-owned case. Show the Flow 3 warning below, + unchanged. + - **Same** → this is the user's own session. Do **not** warn. Proceed straight to the + normal single-confirmation flow (Flow 1/2), exactly as if ownership had never come up. + - **`whoami` returns a null `contributor_id`** (auth disabled, or otherwise unknown) → + you cannot confirm ownership either way. Say so plainly and ask the user whether this + is their session, rather than warning as if it were someone else's. Never fabricate an + ownership verdict when `whoami` can't give you one. +- **Resolve "this session" from context first.** Look for an injected current-session-id + in context; ask the user directly only as a fallback. See the skill for exactly where to + look and the fallback path. (Ownership itself is resolved via `whoami`, not from + injected identity context — see the rule above.) - **Multi-server source selection: never guess which server to call.** Separate from the all-servers completeness rule above: when a single `session_summary` or `delete_session` call needs a `source` and none was named, use `list_sources: true` to discover the valid @@ -131,19 +155,23 @@ true — do not treat it as covering them for you. ## Flows See the `context-intelligence-server-data-ops` skill for the full step order and exact -wording of each. The Hard Rules above (all-servers completeness, the folder-exclusion -offer, the graph-analyst narrative) apply within every flow below regardless of whether -the skill loaded — they are not extra detail the skill adds on top. - -- **Flow 1 — delete the current session.** Includes the folder-exclusion offer, the - all-servers completeness check, and the impact statement, all before proceeding to - delete. Runs here and now, in this session. +wording of each. The Hard Rules above (all-servers completeness, the unconditional +folder-exclusion offer, the graph-analyst narrative, the whoami-based ownership check) +apply within every flow below regardless of whether the skill loaded — they are not extra +detail the skill adds on top. + +- **Flow 1 — delete the current session.** Includes the unconditional folder-exclusion + offer, the all-servers completeness check, and the impact statement, all before + proceeding to delete. Runs here and now, in this session. - **Flow 2 — find a session by description** (topic, date, sometimes a server), then delete. Narrows candidates, presents session details blocks (each with a real - graph-analyst narrative), user picks one. -- **Flow 3 — delete a session someone else created.** Warn plainly that it wasn't created - by the current user, then require a second, separate, explicit confirmation before - deleting. + graph-analyst narrative — never a raw quoted prompt), user picks one. +- **Flow 3 — decide whether an ownership warning applies, using `whoami`.** Call + `whoami` for the session's server and compare its `contributor_id` to the session's + `created_by`. Only when they genuinely differ: warn plainly that it wasn't created by + the current user, then require a second, separate, explicit confirmation before + deleting. When they match, or when `whoami`'s `contributor_id` is null, do not show + this warning — see the Hard Rule above for the exact handling of each case. --- diff --git a/skills/context-intelligence-server-data-ops/SKILL.md b/skills/context-intelligence-server-data-ops/SKILL.md index 898d3ca3..4d815bb8 100644 --- a/skills/context-intelligence-server-data-ops/SKILL.md +++ b/skills/context-intelligence-server-data-ops/SKILL.md @@ -28,7 +28,7 @@ deleting a session someone else created. --- -## The two tools this skill drives +## The tools this skill drives - **`session_summary`** — read-only preview. Returns `{source: {name, url, origin}, summary: {...}}`. The `summary` object's fields (from the server's `DeletionPreview`): @@ -49,26 +49,30 @@ deleting a session someone else created. `{source: {...}, result: {root_id, session_count, nodes_deleted, relationships_deleted, blobs_deleted, queue_sessions_cleaned}}`. -Both accept `source` (name a specific server) and `list_sources: true` (discover the -connectable set without acting). Neither takes a workspace input — you always address a +- **`whoami`** — read-only identity lookup. Returns + `{contributor_id: , source: {name, url, origin}}`. This is how you + find out who the acting user actually is, for the **same server** a session lives on — + it never talks to a different server than the one you're checking ownership against. + Used in Flow 3 to decide whether the ownership warning applies at all (see below). + +All three accept `source` (name a specific server) and `list_sources: true` (discover the +connectable set without acting). None takes a workspace input — you always address a session by its id, and the server resolves the rest. --- ## Resolving "this session" and "the current user" -Resolve both from context first — this is the primary path, not a fallback. Only ask the -user directly if context genuinely doesn't have the answer. Keep it light: at most one -short question, never an interrogation. - - **Current session id**: look in the environment/status context injected into your turn (other agents in this ecosystem are shown a running "Session ID" the same way). Use that - id as "the current session" whenever a flow below refers to it. -- **Current user identity**: look for injected identity/user context the same way. If none - is available, fall back to calling `session_summary` on the current session's own id and - reading its `created_by` field — use that as your reference identity whenever you compare - against another session's `created_by` (Flow 3 below). -- Asking the user is the fallback for both, never the first move. + id as "the current session" whenever a flow below refers to it. Ask the user directly + only if context genuinely doesn't have it — at most one short question, never an + interrogation. +- **Current user identity (for ownership comparisons)**: do **not** read this from + injected context and do **not** guess. Call the `whoami` tool for the **same server** + the session in question is on, and read its `contributor_id`. That is your one and only + reference identity for the ownership comparison in Flow 3 below — see that section for + exactly how to use it, including the null-`contributor_id` fallback. --- @@ -107,7 +111,14 @@ any of its subsessions. Building it from the root session's prompts only keeps the overview fast and focused on top-level intent, rather than walking the whole subsession tree. Fold the returned narrative into the "Summary:" line of the details block. If graph-analyst cannot produce -one (server unreachable, no data), say so plainly instead of inventing one. +one (server unreachable, no data), write "not available" in that line instead of +inventing one. + +**Forbidden shortcut: never put the session's raw first prompt (or any other raw prompt +text) into the "Summary:" line instead of delegating.** A raw quote is not a narrative, +even when it looks descriptive enough to stand in for one — always delegate to +`graph-analyst` first, and fall back to "not available" only if that delegation itself +fails to produce anything. --- @@ -115,9 +126,9 @@ one (server unreachable, no data), say so plainly instead of inventing one. Steps, matching the approved scenario exactly: -1. Resolve the current session's own id and the current user's identity from context - (see "Resolving 'this session' and 'the current user'" above) — ask the user directly - only as a fallback. Ask the user to confirm they want to delete this session's data. +1. Resolve the current session's own id from context (see "Resolving 'this session' and + 'the current user'" above) — ask the user directly only as a fallback. Ask the user to + confirm they want to delete this session's data. 2. Call `session_summary(session_id=, list_sources=true)` or `delete_session(list_sources=true)` to see **every** server in the connectable set, and check which of them the session actually exists on — not just the one that seems @@ -125,10 +136,11 @@ Steps, matching the approved scenario exactly: the all-servers completeness rule from the agent body; it applies here regardless of how many servers turn out to hold the session). 3. The user picks which server(s) to remove it from. -4. If the session's folder is included by a chosen destination's filters, offer to add a - folder exclusion for that destination (see "Folder exclusion" below) — so the folder is - not pushed there anymore — and offer to guide the user through applying it. Do this - **before** proceeding with deletion. +4. **Unconditionally** offer to add a folder exclusion for the chosen destination(s) (see + "Folder exclusion" below) — so the folder is not pushed there anymore — and offer to + guide the user through applying it. Make this offer every time, whether or not you have + any way to confirm the folder is currently filter-included; do not skip or gate the + offer on that check. Do this **before** proceeding with deletion. 5. For **each** server the user chose (one at a time, not just the first): call `session_summary(session_id=, source=)` (the preview) and show the user the session details block built from it → get an explicit, strong confirmation @@ -151,13 +163,17 @@ that destination's `exclude` list stops that destination from being selected for sessions started in that folder. **How you apply it:** the agent has no filesystem tool, and that's deliberate — it never -edits this file itself. When Flow 1 finds that the current session's folder is included by -a chosen destination's filters, offer to add the exclusion before proceeding with deletion: -show the user exactly what to add (the destination name, and the pattern that matches their -current folder), and offer to guide them through applying it. Confirm whether they applied -it, then move on to the preview and delete steps. - -Order: offer the exclusion → preview (`session_summary`) → strong confirmation → delete. +edits this file itself. **In Flow 1, make this offer every time, unconditionally** — do +not first try to determine whether the current session's folder is actually included by +a chosen destination's filters. That determination is not reliably available to the +agent, and gating the offer on it is exactly what caused the offer to be silently skipped +in a real case (a current-session deletion where the offer never fired). Instead: always +show the user exactly what to add (the destination name, and the pattern that would match +their current folder), and offer to guide them through applying it, before proceeding with +deletion. Confirm whether they applied it, then move on to the preview and delete steps. + +Order: offer the exclusion (always) → preview (`session_summary`) → strong confirmation → +delete. QUESTION FOR USER: it is unclear whether an exclusion added while the current session is still running takes effect for that session's own remaining event pushes to this @@ -194,26 +210,43 @@ resolution before this flow ships. exists on any server that was not chosen, say so explicitly by name. Never say "done" or imply full removal while an unchecked or unchosen server still holds the session. -## Flow 3 — delete a session someone else created +## Flow 3 — deciding whether an ownership warning applies + +This flow is not a separate user-facing path — it's the ownership check that runs inside +Flow 1 or Flow 2, right after the preview and before asking for the delete confirmation. +Its whole job is to decide, correctly, whether to show the "not created by you" warning — +and, just as importantly, to **not** show it when the session genuinely belongs to the +current user. 1. Run Flow 1 or Flow 2 up through the preview step (`session_summary`), but do **not** - ask for the delete confirmation yet. -2. Compare the previewed session's `created_by` to the current user (see "Resolving 'this - session' and 'the current user'" above). -3. If they match, continue as Flow 1/2 normally (single confirmation). -4. If they do **not** match: - - State plainly: "this session was created by ``, not you." - - Ask a **separate, explicit, strong** confirmation — restating what will be permanently - removed and from which server — that the user still wants to delete someone else's - data, before proceeding. - - Only call `delete_session` after that second, explicit confirmation. + ask for the delete confirmation yet. Note the previewed session's `created_by`. +2. Call `whoami` for the **same server** the session is on (pass the same `source` you + used for the preview). Read its `contributor_id`. +3. Compare `contributor_id` to the session's `created_by`: + - **They match** → this is the user's own session. Do **not** show any ownership + warning. Continue as Flow 1/2 normally (single confirmation, no extra step). + - **They differ** → genuine not-owned case: + - State plainly: "this session was created by ``, not you." + - Ask a **separate, explicit, strong** confirmation — restating what will be + permanently removed and from which server — that the user still wants to delete + someone else's data, before proceeding. + - Only call `delete_session` after that second, explicit confirmation. + - **`contributor_id` is null** (auth disabled server-side, or otherwise unresolvable) + → you cannot confirm ownership either way. Say so plainly ("I can't confirm who + created this session on this server") and ask the user directly whether it's theirs, + rather than defaulting to the warning. Do not treat a null `contributor_id` as + evidence of a mismatch, and do not skip asking. + +**Never skip step 2.** Warning based on `created_by` alone, without first resolving the +acting user via `whoami`, is exactly the mistake that produced false "not created by you" +warnings on the user's own sessions in a real evaluation. --- ## Multi-server handling (all flows) -- `list_sources: true` on either tool returns the connectable set: every server this agent - can reach, each with `name`, `url`, `origin` (`source` or `destination`). +- `list_sources: true` on any of the three tools returns the connectable set: every server + this agent can reach, each with `name`, `url`, `origin` (`source` or `destination`). - Passing `source=` addresses one specific server by name from that set. - Omitting `source` uses a default: the single configured tool source if there is exactly one, otherwise the first configured destination. If two or more tool **sources** are @@ -235,11 +268,16 @@ resolution before this flow ships. ## Design notes -- **Current session id / current user identity** — resolved from injected environment/ - status context first (see "Resolving 'this session' and 'the current user'" above); - asking the user is the fallback, never the first move. +- **Current session id** — resolved from injected environment/status context first (see + "Resolving 'this session' and 'the current user'" above); asking the user is the + fallback, never the first move. +- **Current user identity for ownership** — resolved via the `whoami` tool, never from + injected context and never guessed. See "Resolving 'this session' and 'the current + user'" above and Flow 3. - **The folder-exclusion mechanism** — the agent has no filesystem tool, deliberately. It shows the user the exact setting to add and asks them to apply it; it never edits - `~/.amplifier/settings.yaml` itself (see "Folder exclusion" under Flow 1 above). + `~/.amplifier/settings.yaml` itself (see "Folder exclusion" under Flow 1 above). The + offer itself is unconditional — never gated on first confirming the folder is + filter-included. - **Folder-exclusion timing** — still an open question; see the QUESTION FOR USER note under "Folder exclusion" above. Needs a decision before this flow ships. From 332a545b69da74b9dc1e1fdd9d0818998007b0da Mon Sep 17 00:00:00 2001 From: colombod Date: Wed, 2 Sep 2026 06:48:15 +0000 Subject: [PATCH 07/39] refactor: move whoami tool from server-data-ops into query module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server-data-ops agent mounts BOTH tool-server-data-ops and tool-context-intelligence-query, so a "whoami" tool could not exist in both modules -- two tools with the same name in one agent collide. whoami is also generally useful to any agent mounting only the query module (e.g. graph-analyst needs "who am I" to scope "my sessions"), so the read (query) module is now its single home. - modules/tool-context-intelligence-query: add whoami_tool.py (identical behavior, ported docstring references from SessionSummaryTool/DeleteSessionTool parity to GraphQueryTool/BlobReadTool parity). Mount it as the third tool in mount(), sharing the module's single ToolConfigResolver. Ported tests/test_whoami_tool.py and updated tests/test_module.py's three-tool assertions (mount count, tool names, shared-resolver invariant across all three tools). - modules/tool-server-data-ops: remove whoami_tool.py and its mount() wiring; the module is back to two tools (session_summary, delete_session) sharing one resolver. Updated tests/test_module.py back to two-tool assertions and removed tests/test_whoami_tool.py. The server-data-ops agent still has whoami available because it already mounts tool-context-intelligence-query. - context_intelligence/client.py whoami() is untouched (shared library used by both modules). Verified: query module 213 tests pass, server-data-ops 54 tests pass, repo-root suite 783 tests pass, ruff/pyright clean on both modules. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- .../__init__.py | 21 ++++-- .../whoami_tool.py | 6 +- .../tests/test_module.py | 48 +++++++------ .../tests/test_whoami_tool.py | 60 ++++++++-------- .../__init__.py | 25 ++++--- .../tool-server-data-ops/tests/test_module.py | 68 +++++-------------- 6 files changed, 105 insertions(+), 123 deletions(-) rename modules/{tool-server-data-ops/amplifier_module_tool_server_data_ops => tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query}/whoami_tool.py (97%) rename modules/{tool-server-data-ops => tool-context-intelligence-query}/tests/test_whoami_tool.py (84%) diff --git a/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/__init__.py b/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/__init__.py index 8a5e7b69..f8ceee59 100644 --- a/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/__init__.py +++ b/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/__init__.py @@ -1,9 +1,17 @@ -"""Context Intelligence read tools — graph_query and blob_read. +"""Context Intelligence read tools — graph_query, blob_read, and whoami. -Both tools share one ToolConfigResolver, so sources has a single +All three tools share one ToolConfigResolver, so sources has a single config namespace: overrides.tool-context-intelligence-query.config.sources. -Two tools, one mount(): idiomatic multi-tool module (same as tool-filesystem +whoami lives here (not in tool-server-data-ops) because the +server-data-ops agent mounts BOTH this module AND tool-server-data-ops -- +two tools named "whoami" in one agent would collide. whoami is also +generally useful to any agent mounting this module alone (e.g. +graph-analyst needs "who am I" to scope "my sessions"), so the read +(query) module is its single home. The server-data-ops agent still has +whoami available because it already mounts this module too. + +Three tools, one mount(): idiomatic multi-tool module (same as tool-filesystem which mounts read_file / write_file / edit_file from one mount() call). """ @@ -16,10 +24,10 @@ async def mount(coordinator: Any, config: Any) -> None: - """Mount both CI read tools, sharing one ToolConfigResolver. + """Mount all three CI read tools, sharing one ToolConfigResolver. The resolver is built ONCE from the module's config and injected into - both tools. Tool constructors no longer accept config — the resolver IS + all three tools. Tool constructors no longer accept config — the resolver IS the config surface. The hook resolver is NOT fetched here; each tool fetches it lazily at @@ -32,6 +40,7 @@ async def mount(coordinator: Any, config: Any) -> None: from .blob_read_tool import BlobReadTool from .graph_query_tool import GraphQueryTool + from .whoami_tool import WhoamiTool resolver = ToolConfigResolver(config or {}, coordinator) # built ONCE # WARN-only diagnostic pass (criterion 4) -- no longer raises; hard validation is @@ -39,6 +48,8 @@ async def mount(coordinator: Any, config: Any) -> None: resolver.validate_sources() gq = GraphQueryTool(coordinator, resolver) br = BlobReadTool(coordinator, resolver) + whoami = WhoamiTool(coordinator, resolver) await coordinator.mount("tools", gq, name=gq.name) # "graph_query" await coordinator.mount("tools", br, name=br.name) # "blob_read" + await coordinator.mount("tools", whoami, name=whoami.name) # "whoami" return None # kernel ignores non-callable returns; resolver is pure → no cleanup diff --git a/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/whoami_tool.py b/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/whoami_tool.py similarity index 97% rename from modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/whoami_tool.py rename to modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/whoami_tool.py index bcd4e906..fceb1a1a 100644 --- a/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/whoami_tool.py +++ b/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/whoami_tool.py @@ -1,8 +1,8 @@ """WhoamiTool -- agent-facing tool that resolves the acting user's identity. Implements the Amplifier Tool protocol. Configuration and provenance are -resolved via ``resolve_query_connection`` (same as SessionSummaryTool and -DeleteSessionTool -- parity guaranteed by the shared helper), a SINGLE-HIT +resolved via ``resolve_query_connection`` (same as GraphQueryTool and +BlobReadTool -- parity guaranteed by the shared helper), a SINGLE-HIT selection over the connectable pool (tool ``sources`` union hook ``destinations``). See ``resolve_query_connection``'s docstring in context_intelligence/tool_resolver.py for the authoritative selection rule @@ -16,7 +16,7 @@ server. The ``ToolConfigResolver`` is injected at construction time by ``mount()`` -(one shared instance across all three server-data-ops tools -- single config +(one shared instance across all three CI read tools -- single config namespace). This tool never talks to the server directly -- the only path to the server diff --git a/modules/tool-context-intelligence-query/tests/test_module.py b/modules/tool-context-intelligence-query/tests/test_module.py index 78ac8819..ba7ecb50 100644 --- a/modules/tool-context-intelligence-query/tests/test_module.py +++ b/modules/tool-context-intelligence-query/tests/test_module.py @@ -1,9 +1,9 @@ """Module-level contract tests for tool-context-intelligence-query. -Tests for the merged two-tool module: mount registers both tools from one call, -the ToolConfigResolver is shared (one instance, identical resolution), the lazy -hook lookup stays lazy (not cached at mount time), and malformed/empty destination -inputs fail loud or fall through correctly. +Tests for the merged three-tool module: mount registers all three tools from +one call, the ToolConfigResolver is shared (one instance, identical resolution), +the lazy hook lookup stays lazy (not cached at mount time), and malformed/empty +destination inputs fail loud or fall through correctly. """ from __future__ import annotations @@ -93,22 +93,22 @@ def test_mount_signature_has_coordinator_and_config(self) -> None: # --------------------------------------------------------------------------- -# TestMountRegistersExactlyTwoTools +# TestMountRegistersExactlyThreeTools # --------------------------------------------------------------------------- -class TestMountRegistersExactlyTwoTools: - """mount() must register exactly two tools with distinct names.""" +class TestMountRegistersExactlyThreeTools: + """mount() must register exactly three tools with distinct names.""" - async def test_mount_registers_exactly_two_tools(self) -> None: + async def test_mount_registers_exactly_three_tools(self) -> None: from amplifier_module_tool_context_intelligence_query import mount coordinator = _make_coordinator() await mount(coordinator, config={}) - assert coordinator.mount.call_count == 2 + assert coordinator.mount.call_count == 3 - async def test_both_tool_calls_use_tools_category(self) -> None: + async def test_all_tool_calls_use_tools_category(self) -> None: from amplifier_module_tool_context_intelligence_query import mount coordinator = _make_coordinator() @@ -117,14 +117,14 @@ async def test_both_tool_calls_use_tools_category(self) -> None: for call in coordinator.mount.call_args_list: assert call.args[0] == "tools" - async def test_tool_names_are_graph_query_and_blob_read(self) -> None: + async def test_tool_names_are_graph_query_blob_read_and_whoami(self) -> None: from amplifier_module_tool_context_intelligence_query import mount coordinator = _make_coordinator() await mount(coordinator, config={}) registered_names = {call.kwargs["name"] for call in coordinator.mount.call_args_list} - assert registered_names == {"graph_query", "blob_read"} + assert registered_names == {"graph_query", "blob_read", "whoami"} async def test_mounted_tools_are_protocol_compliant(self) -> None: from amplifier_module_tool_context_intelligence_query import mount @@ -171,14 +171,14 @@ class TestSeam1SkillSyncLifecycleCutover: pre-change code. """ - async def test_mount_registers_both_tools_by_name(self) -> None: + async def test_mount_registers_all_tools_by_name(self) -> None: from amplifier_module_tool_context_intelligence_query import mount coordinator = _make_coordinator() await mount(coordinator, config={}) registered_names = {call.kwargs["name"] for call in coordinator.mount.call_args_list} - assert registered_names == {"graph_query", "blob_read"} + assert registered_names == {"graph_query", "blob_read", "whoami"} async def test_module_has_no_on_session_ready(self) -> None: import amplifier_module_tool_context_intelligence_query as module @@ -263,8 +263,8 @@ async def test_stale_skill_sync_enabled_key_is_inert_at_mount(self) -> None: class TestSharedResolverInvariant: """The ToolConfigResolver is shared: one instance, identical resolution.""" - async def test_both_tools_have_same_resolver_instance(self) -> None: - """gq._tool_resolver is br._tool_resolver: same object from mount().""" + async def test_all_three_tools_have_same_resolver_instance(self) -> None: + """gq/br/whoami._tool_resolver are all the SAME object from mount().""" from amplifier_module_tool_context_intelligence_query import mount coordinator = _make_coordinator() @@ -273,10 +273,12 @@ async def test_both_tools_have_same_resolver_instance(self) -> None: tools = {call.kwargs["name"]: call.args[1] for call in coordinator.mount.call_args_list} gq = tools["graph_query"] br = tools["blob_read"] + whoami = tools["whoami"] assert gq._tool_resolver is br._tool_resolver + assert gq._tool_resolver is whoami._tool_resolver async def test_shared_resolver_consistency_same_url_and_api_key(self) -> None: - """Both tools resolve to the SAME (url, api_key) from sources. + """All three tools resolve to the SAME (url, api_key) from sources. This is the load-bearing correctness invariant: with a shared resolver, divergent read-endpoint config is structurally impossible. @@ -295,13 +297,15 @@ async def test_shared_resolver_consistency_same_url_and_api_key(self) -> None: tools = {call.kwargs["name"]: call.args[1] for call in coordinator.mount.call_args_list} gq = tools["graph_query"] br = tools["blob_read"] + whoami = tools["whoami"] # Resolve using the shared resolver (no hook resolver needed for tier-1 hit) gq_conn = resolve_query_connection(None, gq._tool_resolver) br_conn = resolve_query_connection(None, br._tool_resolver) + whoami_conn = resolve_query_connection(None, whoami._tool_resolver) - assert gq_conn.url == br_conn.url == "http://read.example.com" - assert gq_conn.api_key == br_conn.api_key == "shared-key" + assert gq_conn.url == br_conn.url == whoami_conn.url == "http://read.example.com" + assert gq_conn.api_key == br_conn.api_key == whoami_conn.api_key == "shared-key" async def test_concurrent_resolution_is_consistent(self) -> None: """Execute both tools 'concurrently'; both resolve to the same endpoint. @@ -649,7 +653,7 @@ async def test_mount_does_not_raise_with_one_bad_source(self) -> None: result = await mount(coordinator, config=config) assert result is None - async def test_mount_registers_both_tools_with_one_bad_source(self) -> None: + async def test_mount_registers_all_tools_with_one_bad_source(self) -> None: from amplifier_module_tool_context_intelligence_query import mount config = { @@ -661,9 +665,9 @@ async def test_mount_registers_both_tools_with_one_bad_source(self) -> None: coordinator = _make_coordinator() await mount(coordinator, config=config) - assert coordinator.mount.call_count == 2 + assert coordinator.mount.call_count == 3 registered_names = {call.kwargs["name"] for call in coordinator.mount.call_args_list} - assert registered_names == {"graph_query", "blob_read"} + assert registered_names == {"graph_query", "blob_read", "whoami"} async def test_mount_logs_warning_with_one_bad_source(self, caplog: Any) -> None: import logging diff --git a/modules/tool-server-data-ops/tests/test_whoami_tool.py b/modules/tool-context-intelligence-query/tests/test_whoami_tool.py similarity index 84% rename from modules/tool-server-data-ops/tests/test_whoami_tool.py rename to modules/tool-context-intelligence-query/tests/test_whoami_tool.py index 378117f2..48941540 100644 --- a/modules/tool-server-data-ops/tests/test_whoami_tool.py +++ b/modules/tool-context-intelligence-query/tests/test_whoami_tool.py @@ -1,7 +1,7 @@ """Tests for WhoamiTool. Constructor: WhoamiTool(coordinator, resolver=None). Patch path is -amplifier_module_tool_server_data_ops.whoami_tool. +amplifier_module_tool_context_intelligence_query.whoami_tool. """ from __future__ import annotations @@ -33,7 +33,7 @@ def _make_hook_resolver( resolver.workspace = workspace if server_url: resolver.destinations = { - "default": SimpleNamespace(name="default", url=server_url, api_key=api_key), + "default": SimpleNamespace(name="default", url=server_url, api_key=api_key) } else: resolver.destinations = {} @@ -76,25 +76,25 @@ class TestWhoamiToolProtocol: """Tool protocol surface tests.""" def test_name_is_whoami(self) -> None: - from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool tool = WhoamiTool(_make_coordinator()) assert tool.name == "whoami" def test_description_mentions_contributor_id(self) -> None: - from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool tool = WhoamiTool(_make_coordinator()) assert "contributor_id" in tool.description def test_input_schema_returns_object_type(self) -> None: - from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool tool = WhoamiTool(_make_coordinator()) assert tool.input_schema["type"] == "object" def test_input_schema_has_optional_source_and_list_sources(self) -> None: - from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool tool = WhoamiTool(_make_coordinator()) props = tool.input_schema["properties"] @@ -108,7 +108,7 @@ def test_input_schema_has_optional_source_and_list_sources(self) -> None: async def test_execute_returns_tool_result(self) -> None: from amplifier_core.models import ToolResult - from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool hook_resolver = _make_hook_resolver() coordinator = _make_coordinator(resolver=hook_resolver) @@ -116,7 +116,7 @@ async def test_execute_returns_tool_result(self) -> None: _, mock_cls = _make_mock_async_ci_client() with patch( - "amplifier_module_tool_server_data_ops.whoami_tool.AsyncCIClient", + "amplifier_module_tool_context_intelligence_query.whoami_tool.AsyncCIClient", mock_cls, ): result = await tool.execute({}) @@ -131,7 +131,7 @@ async def test_execute_returns_tool_result(self) -> None: class TestListSources: async def test_list_sources_does_not_call_client(self) -> None: - from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool resolver = _make_tool_resolver( {"sources": {"only": {"url": "http://only.example.com", "api_key": "k"}}} @@ -141,7 +141,7 @@ async def test_list_sources_does_not_call_client(self) -> None: mock_cls = MagicMock() with patch( - "amplifier_module_tool_server_data_ops.whoami_tool.AsyncCIClient", + "amplifier_module_tool_context_intelligence_query.whoami_tool.AsyncCIClient", mock_cls, ): result = await tool.execute({"list_sources": True}) @@ -162,7 +162,7 @@ class TestWhoamiConstruction: """AsyncCIClient construction and delegation tests (mirrors SessionSummaryTool).""" async def test_client_constructed_with_server_url_and_api_key(self) -> None: - from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000", api_key="my-key") coordinator = _make_coordinator(resolver=hook_resolver) @@ -170,7 +170,7 @@ async def test_client_constructed_with_server_url_and_api_key(self) -> None: _, mock_cls = _make_mock_async_ci_client() with patch( - "amplifier_module_tool_server_data_ops.whoami_tool.AsyncCIClient", + "amplifier_module_tool_context_intelligence_query.whoami_tool.AsyncCIClient", mock_cls, ): await tool.execute({}) @@ -181,7 +181,7 @@ async def test_client_constructed_with_server_url_and_api_key(self) -> None: assert call_kwargs.get("api_key") == "my-key" async def test_whoami_called_with_no_arguments(self) -> None: - from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool hook_resolver = _make_hook_resolver() coordinator = _make_coordinator(resolver=hook_resolver) @@ -189,7 +189,7 @@ async def test_whoami_called_with_no_arguments(self) -> None: mock_instance, mock_cls = _make_mock_async_ci_client() with patch( - "amplifier_module_tool_server_data_ops.whoami_tool.AsyncCIClient", + "amplifier_module_tool_context_intelligence_query.whoami_tool.AsyncCIClient", mock_cls, ): await tool.execute({}) @@ -197,7 +197,7 @@ async def test_whoami_called_with_no_arguments(self) -> None: mock_instance.whoami.assert_called_once_with() async def test_result_forwarded_and_source_stamped(self) -> None: - from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000") coordinator = _make_coordinator(resolver=hook_resolver) @@ -205,7 +205,7 @@ async def test_result_forwarded_and_source_stamped(self) -> None: _, mock_cls = _make_mock_async_ci_client(return_value={"contributor_id": "alice"}) with patch( - "amplifier_module_tool_server_data_ops.whoami_tool.AsyncCIClient", + "amplifier_module_tool_context_intelligence_query.whoami_tool.AsyncCIClient", mock_cls, ): result = await tool.execute({}) @@ -218,7 +218,7 @@ async def test_result_forwarded_and_source_stamped(self) -> None: async def test_null_contributor_id_when_auth_disabled(self) -> None: """Server returns contributor_id: null when auth is disabled -- passed through as-is.""" - from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000") coordinator = _make_coordinator(resolver=hook_resolver) @@ -226,7 +226,7 @@ async def test_null_contributor_id_when_auth_disabled(self) -> None: _, mock_cls = _make_mock_async_ci_client(return_value={"contributor_id": None}) with patch( - "amplifier_module_tool_server_data_ops.whoami_tool.AsyncCIClient", + "amplifier_module_tool_context_intelligence_query.whoami_tool.AsyncCIClient", mock_cls, ): result = await tool.execute({}) @@ -243,7 +243,7 @@ async def test_null_contributor_id_when_auth_disabled(self) -> None: class TestWhoamiConfigFallback: async def test_capability_not_found_returns_configuration_error(self) -> None: - from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool coordinator = _make_coordinator(resolver=None) tool = WhoamiTool(coordinator) @@ -273,7 +273,7 @@ def _two_source_config(self) -> dict: } async def test_source_matching_name_selects_that_source(self) -> None: - from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool resolver = _make_tool_resolver(self._two_source_config()) coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) @@ -281,7 +281,7 @@ async def test_source_matching_name_selects_that_source(self) -> None: _, mock_cls = _make_mock_async_ci_client() with patch( - "amplifier_module_tool_server_data_ops.whoami_tool.AsyncCIClient", + "amplifier_module_tool_context_intelligence_query.whoami_tool.AsyncCIClient", mock_cls, ): result = await tool.execute({"source": "beta"}) @@ -292,7 +292,7 @@ async def test_source_matching_name_selects_that_source(self) -> None: assert call_kwargs["api_key"] == "beta-key" async def test_source_not_matching_returns_unknown_source_error(self) -> None: - from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool resolver = _make_tool_resolver(self._two_source_config()) coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) @@ -306,7 +306,7 @@ async def test_source_not_matching_returns_unknown_source_error(self) -> None: assert result.error["valid_sources"] == ["alpha", "beta"] async def test_source_omitted_with_two_configured_returns_ambiguous_error(self) -> None: - from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool resolver = _make_tool_resolver(self._two_source_config()) coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) @@ -321,7 +321,7 @@ async def test_source_omitted_with_two_configured_returns_ambiguous_error(self) async def test_source_omitted_with_one_configured_still_succeeds(self) -> None: """Safe to omit source with exactly one configured (backward compatible).""" - from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool config = { "sources": { @@ -334,7 +334,7 @@ async def test_source_omitted_with_one_configured_still_succeeds(self) -> None: _, mock_cls = _make_mock_async_ci_client() with patch( - "amplifier_module_tool_server_data_ops.whoami_tool.AsyncCIClient", + "amplifier_module_tool_context_intelligence_query.whoami_tool.AsyncCIClient", mock_cls, ): result = await tool.execute({}) @@ -344,7 +344,7 @@ async def test_source_omitted_with_one_configured_still_succeeds(self) -> None: assert call_kwargs["server_url"] == "http://only.example.com" async def test_selected_source_misconfigured_returns_source_misconfigured_error(self) -> None: - from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool config = { "sources": { @@ -373,7 +373,7 @@ class TestWhoamiServerErrors: async def test_http_error_surfaces_as_clear_tool_error(self) -> None: from context_intelligence.client import CIClientError - from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000") coordinator = _make_coordinator(resolver=hook_resolver) @@ -390,7 +390,7 @@ async def test_http_error_surfaces_as_clear_tool_error(self) -> None: ) mock_cls = MagicMock(return_value=mock_instance) with patch( - "amplifier_module_tool_server_data_ops.whoami_tool.AsyncCIClient", + "amplifier_module_tool_context_intelligence_query.whoami_tool.AsyncCIClient", mock_cls, ): result = await tool.execute({}) @@ -404,7 +404,7 @@ async def test_http_error_surfaces_as_clear_tool_error(self) -> None: async def test_connection_error_surfaces_as_clear_tool_error(self) -> None: from context_intelligence.client import CIClientError - from amplifier_module_tool_server_data_ops.whoami_tool import WhoamiTool + from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000") coordinator = _make_coordinator(resolver=hook_resolver) @@ -420,7 +420,7 @@ async def test_connection_error_surfaces_as_clear_tool_error(self) -> None: ) mock_cls = MagicMock(return_value=mock_instance) with patch( - "amplifier_module_tool_server_data_ops.whoami_tool.AsyncCIClient", + "amplifier_module_tool_context_intelligence_query.whoami_tool.AsyncCIClient", mock_cls, ): result = await tool.execute({}) diff --git a/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/__init__.py b/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/__init__.py index 5bf84a6e..561e4745 100644 --- a/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/__init__.py +++ b/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/__init__.py @@ -1,12 +1,18 @@ -"""Context Intelligence server data-ops tools -- session_summary, delete_session, -and whoami. +"""Context Intelligence server data-ops tools -- session_summary and +delete_session. -All three tools share one ToolConfigResolver, so sources has a single +Both tools share one ToolConfigResolver, so sources has a single config namespace: overrides.tool-server-data-ops.config.sources. -Three tools, one mount(): idiomatic multi-tool module (same shape as -tool-context-intelligence-query, which mounts graph_query / blob_read from one -mount() call). +whoami lives in tool-context-intelligence-query, not here -- the +server-data-ops agent mounts BOTH this module AND +tool-context-intelligence-query, so a second "whoami" tool defined here +would collide with the one in that module. This agent still has whoami +available because it already mounts tool-context-intelligence-query. + +Two tools, one mount(): idiomatic multi-tool module (same shape as +tool-context-intelligence-query, which mounts graph_query / blob_read / whoami +from one mount() call). """ from __future__ import annotations @@ -18,10 +24,10 @@ async def mount(coordinator: Any, config: Any) -> None: - """Mount all three server-data-ops tools, sharing one ToolConfigResolver. + """Mount both server-data-ops tools, sharing one ToolConfigResolver. The resolver is built ONCE from the module's config and injected into - all three tools. Tool constructors do not accept config -- the resolver + both tools. Tool constructors do not accept config -- the resolver IS the config surface. The hook resolver is NOT fetched here; each tool fetches it lazily at @@ -33,7 +39,6 @@ async def mount(coordinator: Any, config: Any) -> None: from .delete_session_tool import DeleteSessionTool from .session_summary_tool import SessionSummaryTool - from .whoami_tool import WhoamiTool resolver = ToolConfigResolver(config or {}, coordinator) # built ONCE # WARN-only diagnostic pass -- never raises; hard validation is per-source @@ -41,7 +46,5 @@ async def mount(coordinator: Any, config: Any) -> None: resolver.validate_sources() summary = SessionSummaryTool(coordinator, resolver) delete = DeleteSessionTool(coordinator, resolver) - whoami = WhoamiTool(coordinator, resolver) await coordinator.mount("tools", summary, name=summary.name) # "session_summary" await coordinator.mount("tools", delete, name=delete.name) # "delete_session" - await coordinator.mount("tools", whoami, name=whoami.name) # "whoami" diff --git a/modules/tool-server-data-ops/tests/test_module.py b/modules/tool-server-data-ops/tests/test_module.py index 824efacd..62aaff28 100644 --- a/modules/tool-server-data-ops/tests/test_module.py +++ b/modules/tool-server-data-ops/tests/test_module.py @@ -1,6 +1,6 @@ """Module-level contract tests for tool-server-data-ops. -Tests for the merged three-tool module: mount registers all three tools from +Tests for the merged two-tool module: mount registers both tools from one call, the ToolConfigResolver is shared (one instance, identical resolution), and the lazy hook lookup stays lazy (not cached at mount time). """ @@ -69,20 +69,20 @@ def test_mount_signature_has_coordinator_and_config(self) -> None: # --------------------------------------------------------------------------- -# TestMountRegistersExactlyThreeTools +# TestMountRegistersExactlyTwoTools # --------------------------------------------------------------------------- -class TestMountRegistersExactlyThreeTools: - """mount() must register exactly three tools with distinct names.""" +class TestMountRegistersExactlyTwoTools: + """mount() must register exactly two tools with distinct names.""" - async def test_mount_registers_exactly_three_tools(self) -> None: + async def test_mount_registers_exactly_two_tools(self) -> None: from amplifier_module_tool_server_data_ops import mount coordinator = _make_coordinator() await mount(coordinator, config={}) - assert coordinator.mount.call_count == 3 + assert coordinator.mount.call_count == 2 async def test_all_tool_calls_use_tools_category(self) -> None: from amplifier_module_tool_server_data_ops import mount @@ -93,14 +93,14 @@ async def test_all_tool_calls_use_tools_category(self) -> None: for call in coordinator.mount.call_args_list: assert call.args[0] == "tools" - async def test_tool_names_are_session_summary_delete_session_and_whoami(self) -> None: + async def test_tool_names_are_session_summary_and_delete_session(self) -> None: from amplifier_module_tool_server_data_ops import mount coordinator = _make_coordinator() await mount(coordinator, config={}) registered_names = {call.kwargs["name"] for call in coordinator.mount.call_args_list} - assert registered_names == {"session_summary", "delete_session", "whoami"} + assert registered_names == {"session_summary", "delete_session"} async def test_mounted_tools_are_protocol_compliant(self) -> None: from amplifier_module_tool_server_data_ops import mount @@ -142,8 +142,8 @@ async def test_mount_makes_no_register_capability_call(self) -> None: class TestSharedResolverInvariant: """The ToolConfigResolver is shared: one instance, identical resolution.""" - async def test_all_three_tools_have_same_resolver_instance(self) -> None: - """summary/delete/whoami._tool_resolver are all the SAME object from mount().""" + async def test_both_tools_have_same_resolver_instance(self) -> None: + """summary/delete._tool_resolver are the SAME object from mount().""" from amplifier_module_tool_server_data_ops import mount coordinator = _make_coordinator() @@ -152,12 +152,10 @@ async def test_all_three_tools_have_same_resolver_instance(self) -> None: tools = {call.kwargs["name"]: call.args[1] for call in coordinator.mount.call_args_list} summary = tools["session_summary"] delete = tools["delete_session"] - whoami = tools["whoami"] assert summary._tool_resolver is delete._tool_resolver - assert summary._tool_resolver is whoami._tool_resolver async def test_shared_resolver_consistency_same_url_and_api_key(self) -> None: - """All three tools resolve to the SAME (url, api_key) from sources. + """Both tools resolve to the SAME (url, api_key) from sources. This is the load-bearing correctness invariant: with a shared resolver, divergent read-endpoint config is structurally impossible. @@ -177,17 +175,13 @@ async def test_shared_resolver_consistency_same_url_and_api_key(self) -> None: tools = {call.kwargs["name"]: call.args[1] for call in coordinator.mount.call_args_list} summary = tools["session_summary"] delete = tools["delete_session"] - whoami = tools["whoami"] # Resolve using the shared resolver (no hook resolver needed for tier-1 hit) summary_conn = resolve_query_connection(None, summary._tool_resolver) delete_conn = resolve_query_connection(None, delete._tool_resolver) - whoami_conn = resolve_query_connection(None, whoami._tool_resolver) - assert ( - summary_conn.url == delete_conn.url == whoami_conn.url == "http://data-ops.example.com" - ) - assert summary_conn.api_key == delete_conn.api_key == whoami_conn.api_key == "shared-key" + assert summary_conn.url == delete_conn.url == "http://data-ops.example.com" + assert summary_conn.api_key == delete_conn.api_key == "shared-key" # --------------------------------------------------------------------------- @@ -267,36 +261,6 @@ async def test_late_mount_delete_session_resolves_destination_after_hook_registe assert call_kwargs["server_url"] == "http://late-hook.example.com" assert call_kwargs["api_key"] == "late-key" - async def test_late_mount_whoami_resolves_destination_after_hook_registers( - self, - ) -> None: - """WhoamiTool: mount with no hook -> register hook -> execute sees destination.""" - from amplifier_module_tool_server_data_ops import mount - - coordinator = _make_coordinator(hook_resolver=None) - await mount(coordinator, config={}) - tools = {call.kwargs["name"]: call.args[1] for call in coordinator.mount.call_args_list} - whoami = tools["whoami"] - - assert whoami._hook_resolver is None - - hook_resolver = _make_hook_resolver(url="http://late-hook.example.com", api_key="late-key") - coordinator.get_capability.return_value = hook_resolver - - mock_client = MagicMock() - mock_client.whoami = AsyncMock(return_value={"contributor_id": "alice"}) - mock_cls = MagicMock(return_value=mock_client) - with patch( - "amplifier_module_tool_server_data_ops.whoami_tool.AsyncCIClient", - mock_cls, - ): - result = await whoami.execute({}) - - assert result.success is True - call_kwargs = mock_cls.call_args.kwargs - assert call_kwargs["server_url"] == "http://late-hook.example.com" - assert call_kwargs["api_key"] == "late-key" - # --------------------------------------------------------------------------- # TestMountWithMisconfiguredSource @@ -320,7 +284,7 @@ async def test_mount_does_not_raise_with_one_bad_source(self) -> None: result = await mount(coordinator, config=config) assert result is None - async def test_mount_registers_all_tools_with_one_bad_source(self) -> None: + async def test_mount_registers_both_tools_with_one_bad_source(self) -> None: from amplifier_module_tool_server_data_ops import mount config = { @@ -332,9 +296,9 @@ async def test_mount_registers_all_tools_with_one_bad_source(self) -> None: coordinator = _make_coordinator() await mount(coordinator, config=config) - assert coordinator.mount.call_count == 3 + assert coordinator.mount.call_count == 2 registered_names = {call.kwargs["name"] for call in coordinator.mount.call_args_list} - assert registered_names == {"session_summary", "delete_session", "whoami"} + assert registered_names == {"session_summary", "delete_session"} async def test_mount_logs_warning_with_one_bad_source(self, caplog: Any) -> None: import logging From ef217e5b98fc7b5960aca3d883fd90d55c6e7a54 Mon Sep 17 00:00:00 2001 From: colombod Date: Wed, 2 Sep 2026 06:50:30 +0000 Subject: [PATCH 08/39] docs: reflect whoami's new home in query module, teach graph-query to use it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit whoami moved from tool-server-data-ops into tool-context-intelligence-query in 5b3252d. Update the docs/wording that still pointed at the old module, and teach the graph-navigation skill (and graph-analyst) to use it. - agents/server-data-ops.md: fix the tool attribution for whoami -- now credited to tool-context-intelligence-query. No behavior change; the whoami-based ownership rule itself is untouched. - skills/context-intelligence-graph-query/SKILL.md: add a short 'Resolving "my" -- use whoami, don't guess' subsection in the scoping section, teaching how to resolve the acting user via whoami and filter created_by against contributor_id, including the null-contributor_id (auth disabled) case. - agents/graph-analyst.md: add a brief note that whoami is available (from tool-context-intelligence-query) to resolve the acting user's identity for "my"-scoped questions, pointing at the skill for the full pattern. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- agents/graph-analyst.md | 9 +++++++ agents/server-data-ops.md | 2 +- .../context-intelligence-graph-query/SKILL.md | 25 +++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/agents/graph-analyst.md b/agents/graph-analyst.md index 61d6a628..b3378df8 100644 --- a/agents/graph-analyst.md +++ b/agents/graph-analyst.md @@ -223,6 +223,15 @@ your data from `rows` and ALWAYS report `source.name` in your answer (see "Always State the Source" above). Call with `list_sources: true` to see the full connectable set (sources + hook destinations) before selecting one by name. +### Resolving "my" — use whoami, don't guess + +You also have the `whoami` tool (from `tool-context-intelligence-query`, the same +module `graph_query` and `blob_read` come from). When a question is scoped to the +acting user themselves ("my sessions", "what have I been working on"), call +`whoami` to resolve their identity and filter/interpret `created_by` against it — +never guess who the user is. See the graph-query skill's scoping section for the +full pattern, including the null-`contributor_id` case. + --- ## Section 2: Blob Resolution Workflow diff --git a/agents/server-data-ops.md b/agents/server-data-ops.md index e28ad3c2..198abebd 100644 --- a/agents/server-data-ops.md +++ b/agents/server-data-ops.md @@ -51,7 +51,7 @@ the user. - `session_summary` / `delete_session` (tool-server-data-ops) — preview and permanently delete a session's whole graph on a server. -- `whoami` (tool-server-data-ops) — resolve the acting user's own identity +- `whoami` (tool-context-intelligence-query) — resolve the acting user's own identity (`contributor_id`) for a given server. This is how you find out who "you" are, so you can compare against a session's `created_by`. - `graph_query` (tool-context-intelligence-query) — narrow candidate sessions by description. diff --git a/skills/context-intelligence-graph-query/SKILL.md b/skills/context-intelligence-graph-query/SKILL.md index 206e7712..b9a76015 100644 --- a/skills/context-intelligence-graph-query/SKILL.md +++ b/skills/context-intelligence-graph-query/SKILL.md @@ -179,6 +179,31 @@ ORDER BY sessions DESC LIMIT 25 ``` +### Resolving "my" — use `whoami`, don't guess + +`created_by` scopes by *who produced the data*, but the graph never tells you +who "you" are. When a request is scoped to the acting user themselves — +"my sessions", "sessions I created", "what have I been working on" — call the +`whoami` tool for the relevant server before filtering on `created_by`: + +```python +whoami() # default endpoint +whoami(source="prod") # a specific configured endpoint +``` + +It returns `{"contributor_id": "", "source": {...}}`. Use +`contributor_id` as the value to match against `created_by`: + +```cypher +MATCH (s:Session {workspace: $workspace, created_by: $contributor_id}) +RETURN count(s) AS my_sessions +``` + +**Null case.** A null `contributor_id` means auth is disabled (or otherwise +unknown) on that server — there is no acting-user identity to filter by. Do +**not** guess or fall back to some other field. Ask the user how they want to +scope the query instead (e.g. by name, by workspace, or "show everyone's"). + --- ## Section 3 — Traps: Where the Shapes Lie About Their Meaning From e42e19b62e25eeda18bbb84ca64acd6e6647375b Mon Sep 17 00:00:00 2001 From: colombod Date: Wed, 2 Sep 2026 07:21:58 +0000 Subject: [PATCH 09/39] fix(server-data-ops): intent-based Flow 1 trigger, non-skippable graph-analyst narrative gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two targeted behavior fixes found by a Digital Twin re-eval: - Flow 1 (delete current session) was being misrouted into a find-by-id lookup whenever the user supplied a session id alongside phrasing like "my current session" — causing the folder-exclusion offer to be silently skipped for two eval rounds in a row. Flow 1 routing is now decided by the user's phrasing ("my current session", "this session", "this working directory", etc.), never by whether an id was given; a supplied id no longer downgrades the request out of Flow 1. Mirrored into the skill's Flow 1 trigger paragraph. - The graph-analyst narrative for a session details block fired once and was silently skipped once on identical requests — not deterministic enough. Made it an explicit non-skippable gate in the agent body (MUST delegate before presenting a details block or deleting; MUST NOT proceed without either a real narrative or an explicit "narrative not available"), and split the skill's Flow 2 step 3 into two ordered steps (session_summary for facts, then the mandatory graph-analyst delegation) so the sequence find -> narrative -> present -> confirm -> delete is unambiguous. No other behavior touched: whoami-based ownership comparison, all-servers completeness, preview, confirmation gate, tool-only access, and 404/409 handling are unchanged. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- agents/server-data-ops.md | 19 +++++++++ .../SKILL.md | 40 ++++++++++++++----- 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/agents/server-data-ops.md b/agents/server-data-ops.md index 198abebd..b8f36c52 100644 --- a/agents/server-data-ops.md +++ b/agents/server-data-ops.md @@ -105,6 +105,16 @@ you. with the blobs and queue records for all of them. Nodes shared with other sessions are kept. There is no undo and no restore — say this plainly before the user confirms, not only in fine print. +- **Flow 1 trigger — decided by the user's phrasing, never by whether a session id was + given.** Whenever the request refers to the user's own current session — phrases like + "my current session," "my session's data," "this session," "this working directory," + "the session I'm in" — treat it as Flow 1, even if the user also supplies a session id + in the same request. **A supplied session id does NOT downgrade a "my current session" + request out of Flow 1** — the folder-exclusion offer below still applies. Do not let + the presence of an id pull you into a find-by-id lookup instead of Flow 1; that swap is + exactly the mistake that made the folder-exclusion offer go missing for two eval rounds + in a row. Only a request that names or searches for some *other* session — by topic, or + a session belonging to someone else — is not Flow 1, and does not get this offer. - **Flow 1 — offer the folder exclusion before deleting; this offer is UNCONDITIONAL, never gated on first checking anything.** Whenever the request is about the current session / "this working directory," before deleting anything: **always** offer to add @@ -129,6 +139,15 @@ you. it seems like it would be faster or more accurate. If `graph-analyst` can't produce a narrative, write "not available" in that line — never fabricate one, never leave the line out entirely, and never fall back to a raw quote instead. + **Non-skippable gate — this is a required step, not a suggestion:** before showing the + details block or deleting a found session, you MUST delegate to `graph-analyst` for the + root-prompt narrative overview; you may NOT present a details block, and may NOT proceed + to delete, until you have either the `graph-analyst` narrative or an explicit "narrative + not available" resulting from a failed delegation. Writing your own summary from memory, + or quoting the prompt, is forbidden — the narrative must come from the `graph-analyst` + delegation or be marked unavailable. This step fired once and was silently skipped once + on identical requests in eval — treat it as mandatory every time, not conditional on + whether it "seems needed." - **Resolve ownership with `whoami` before deciding whether to warn — never warn on a guess.** Before deciding whether to show the ownership warning (Flow 3), call the `whoami` tool for the **same server** the session in question is on, and read its diff --git a/skills/context-intelligence-server-data-ops/SKILL.md b/skills/context-intelligence-server-data-ops/SKILL.md index 4d815bb8..2b63ebbc 100644 --- a/skills/context-intelligence-server-data-ops/SKILL.md +++ b/skills/context-intelligence-server-data-ops/SKILL.md @@ -124,6 +124,15 @@ fails to produce anything. ## Flow 1 — delete the current session +**Trigger for this flow — the user's phrasing, not whether a session id is present.** +Flow 1 applies whenever the request refers to the user's own current session — phrases +like "my current session," "my session's data," "this session," "this working directory," +"the session I'm in." This is true **even if the user also supplies a session id in the +same request** — a supplied id does not downgrade a "my current session" request out of +Flow 1, and the folder-exclusion offer below still applies. Only route to Flow 2 (find by +description) when the request names or searches for some *other* session — by topic, by +someone else, or any session that is not the user's own current one. + Steps, matching the approved scenario exactly: 1. Resolve the current session's own id from context (see "Resolving 'this session' and @@ -191,24 +200,33 @@ resolution before this flow ships. working directory you show the user comes from `session_summary`, not from Cypher. Cap the candidate set to a small number (a handful) before doing per-candidate work. 3. For each shortlisted candidate: call `session_summary(session_id=)` for the - accurate facts, and delegate to `graph-analyst` for a narrative (see "Building the - narrative" above). Build a session details block for each. -4. Present the candidates (their details blocks) to the user and let them pick one. -5. All-servers completeness check (same rule as Flow 1): call `session_summary` or + accurate facts. +4. **Non-skippable gate — before presenting any details block:** for each shortlisted + candidate, delegate to `graph-analyst` for the root-prompt narrative overview (see + "Building the narrative" above). You MUST NOT present a session details block for a + candidate until you have either the `graph-analyst` narrative or an explicit "narrative + not available" from a failed delegation for that candidate. Writing your own summary + from memory, or quoting the prompt, is forbidden — this step fired once and was + silently skipped once on identical requests in eval, so treat it as required every + time, never optional. +5. Build a session details block for each candidate, using the facts from step 3 and the + narrative (or "not available") from step 4. +6. Present the candidates (their details blocks) to the user and let them pick one. +7. All-servers completeness check (same rule as Flow 1): call `session_summary` or `delete_session` with `list_sources: true` for the chosen id and check it against **every** server in the connectable set. If it exists on more than one, name all of them to the user and ask which to delete from (or "all") before continuing. -6. For **each** server chosen: re-run `session_summary` on the chosen id right before +8. For **each** server chosen: re-run `session_summary` on the chosen id right before delete — a fresh preview, not the one from the candidate list, in case anything changed in between. -7. Get an explicit, strong confirmation: restate exactly what will be permanently removed +9. Get an explicit, strong confirmation: restate exactly what will be permanently removed (session id, counts) and from which server, and require a clear go-ahead — a plain "yes" with no restatement is not enough. -8. Call `delete_session` on the chosen id and server, and verify that server's own result - before moving to the next chosen server. -9. Report exactly what was removed and from which server(s) — and if the session still - exists on any server that was not chosen, say so explicitly by name. Never say "done" - or imply full removal while an unchecked or unchosen server still holds the session. +10. Call `delete_session` on the chosen id and server, and verify that server's own result + before moving to the next chosen server. +11. Report exactly what was removed and from which server(s) — and if the session still + exists on any server that was not chosen, say so explicitly by name. Never say "done" + or imply full removal while an unchecked or unchosen server still holds the session. ## Flow 3 — deciding whether an ownership warning applies From 62829cd8d4759a80c54b6a7bb7a17bcb09f5cac3 Mon Sep 17 00:00:00 2001 From: colombod Date: Wed, 2 Sep 2026 09:42:11 +0000 Subject: [PATCH 10/39] fix(server-data-ops): Flow 1 resolves+proves current session, exclusion offer front-loaded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flow 1 ("delete my current session") now runs in a strict, explicit order in both agents/server-data-ops.md and skills/context-intelligence-server-data-ops/SKILL.md: 1. RESOLVE - take the current session id from Amplifier's own runtime context (the `Session ID` field in status context), never from the user typing one. A typed id never replaces the runtime one for a "this session" request. 2. PROVE - call session_summary on that id and show the user the proof: the resolved root session id, created_by (confirmed against the caller's own identity via whoami), working_dir, and last_change (flagged if <1 min old). If session_summary 404s on every configured server, STOP and say so plainly - never delete an unresolved or absent session. 3. OFFER THE FOLDER EXCLUSION - mandatory, unconditional, front-loaded, before the impact statement, confirmation, or delete. Skipping this offer in Flow 1 is now stated explicitly as a defect, not a shortcut. 4. STATE THE IMPACT - what will be permanently removed, and from which server(s). 5. CONFIRM - explicit, strong confirmation naming session + server(s). 6. DELETE, then VERIFY, on every server - all-servers completeness, unchanged. Also adds a short rule near the top of the agent body making the resolve-from-Amplifier-context requirement explicit and impossible to miss. No new tools or APIs added - only session_summary, delete_session, whoami, graph_query, delegate, and load_skill, all of which already existed. No new scope: no hand-off, fresh-session, tombstone, or archive concepts introduced. Flow 2 (find-by-theme, graph-analyst narrative gate), Flow 3 (not-owned, whoami ownership compare), all-servers completeness, preview/confirmation order, and tool-only access are unchanged (verified byte-identical via diff against the prior revision). 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- agents/server-data-ops.md | 79 +++++++--- .../SKILL.md | 143 +++++++++++------- 2 files changed, 144 insertions(+), 78 deletions(-) diff --git a/agents/server-data-ops.md b/agents/server-data-ops.md index b8f36c52..e382db59 100644 --- a/agents/server-data-ops.md +++ b/agents/server-data-ops.md @@ -39,6 +39,10 @@ tools: --- +**For a "this session" request, the current session id comes from Amplifier's runtime +context (the `Session ID` field), never from the user typing one; resolve and prove it +before acting.** + ## Role Drive find → preview → confirm → delete for Context Intelligence session data on a server, @@ -109,26 +113,31 @@ you. given.** Whenever the request refers to the user's own current session — phrases like "my current session," "my session's data," "this session," "this working directory," "the session I'm in" — treat it as Flow 1, even if the user also supplies a session id - in the same request. **A supplied session id does NOT downgrade a "my current session" - request out of Flow 1** — the folder-exclusion offer below still applies. Do not let - the presence of an id pull you into a find-by-id lookup instead of Flow 1; that swap is - exactly the mistake that made the folder-exclusion offer go missing for two eval rounds - in a row. Only a request that names or searches for some *other* session — by topic, or - a session belonging to someone else — is not Flow 1, and does not get this offer. -- **Flow 1 — offer the folder exclusion before deleting; this offer is UNCONDITIONAL, - never gated on first checking anything.** Whenever the request is about the current - session / "this working directory," before deleting anything: **always** offer to add - a folder exclusion, whether or not you have (or could have) confirmed the destination's - push filters actually cover this folder. Do not try to first check whether the folder - is filter-included before deciding to offer — you cannot reliably determine that, and - skipping the offer because that check wasn't done (or came back unclear) is exactly the - mistake this rule exists to prevent. Show the user the exact setting — + in the same request. **Resolve the id to act on from Amplifier's own runtime context + (the `Session ID` field), never from a typed id** — a supplied session id does NOT + replace the runtime one and does NOT downgrade a "my current session" request out of + Flow 1; the folder-exclusion offer below still applies. Do not let the presence of a + typed id pull you into a find-by-id lookup instead of Flow 1; that swap is exactly the + mistake that made the folder-exclusion offer go missing for two eval rounds in a row. + Only a request that names or searches for some *other* session — by topic, or a session + belonging to someone else — is not Flow 1, and does not get this offer. +- **Flow 1, step 3 — offer the folder exclusion before anything else proceeds; this + offer is MANDATORY, UNCONDITIONAL, and FRONT-LOADED.** Whenever the request is Flow 1 + (the current session / "this working directory"): immediately after proving the + resolution (step 2) and before the impact statement, the confirmation, or the delete — + **always** offer to add a folder exclusion for the resolved `working_dir`, whether or + not you have (or could have) confirmed the destination's push filters actually cover + this folder. Do not try to first check whether the folder is filter-included before + deciding to offer — you cannot reliably determine that, and skipping the offer because + that check wasn't done (or came back unclear) is exactly the mistake this rule exists + to prevent. Show the user the exact setting — `overrides.hook-context-intelligence.config.destinations..exclude` in `~/.amplifier/settings.yaml`, a gitignore-style pattern matched against the working directory. You have no filesystem tool and never edit this file yourself — show the setting, offer to guide them through applying it, confirm whether they did, and only - then move to preview and delete. Do this every single time this flow runs, with no - precondition. + then move on to the impact statement, confirmation, and delete. **Skipping this offer + in Flow 1 is a defect, not a shortcut — it runs every single time this flow runs, with + no precondition and no exception.** - **Every "session details" block needs a real narrative from `graph-analyst` — never silently drop it, and never quote the raw prompt instead.** Whenever you present a session details block (Flow 2 candidates, or the pre-delete confirmation in any flow), @@ -160,10 +169,18 @@ you. you cannot confirm ownership either way. Say so plainly and ask the user whether this is their session, rather than warning as if it were someone else's. Never fabricate an ownership verdict when `whoami` can't give you one. -- **Resolve "this session" from context first.** Look for an injected current-session-id - in context; ask the user directly only as a fallback. See the skill for exactly where to - look and the fallback path. (Ownership itself is resolved via `whoami`, not from - injected identity context — see the rule above.) +- **Resolve "this session" from Amplifier's own runtime context — never from a typed + id, never by asking.** The current session id is the `Session ID` field Amplifier + already injects into your status context every turn. For a "this session" / "my + current session" / "this working directory" request, that field's value IS the + session to act on — full stop. Do not ask the user to type an id, and if they type one + anyway, it does NOT replace the runtime one for this kind of request; resolve from + context regardless of what was typed. Asking the user directly is only a fallback for + the rare case where context genuinely has no `Session ID` at all. Resolving is not the + end of it: Flow 1 step 2 requires you to then *prove* the resolution with a real + `session_summary` call before doing anything else — never act on a resolved id you + haven't proven. (Ownership itself is resolved via `whoami`, not from injected identity + context — see the rule above; in Flow 1 that check runs as part of the proof step.) - **Multi-server source selection: never guess which server to call.** Separate from the all-servers completeness rule above: when a single `session_summary` or `delete_session` call needs a `source` and none was named, use `list_sources: true` to discover the valid @@ -179,9 +196,25 @@ folder-exclusion offer, the graph-analyst narrative, the whoami-based ownership apply within every flow below regardless of whether the skill loaded — they are not extra detail the skill adds on top. -- **Flow 1 — delete the current session.** Includes the unconditional folder-exclusion - offer, the all-servers completeness check, and the impact statement, all before - proceeding to delete. Runs here and now, in this session. +- **Flow 1 — delete the current session.** Runs here and now, in this session, in this + exact order (see the skill for the full step-by-step wording): + 1. **Resolve** — take the current session id from Amplifier's own runtime context (the + `Session ID` field in your status context). Never ask the user for an id, and never + let a typed id replace the runtime one for a "this session" request. + 2. **Prove** — call `session_summary` on that id and show the user the proof: the + resolved root session id, `created_by` (confirmed against your own identity via + `whoami`), `working_dir`, and `last_change` (flag it if under a minute old — it may + still be live). If it 404s on every configured server, STOP and say plainly this + session isn't on the server(s) — never delete an unresolved or absent session. + 3. **Offer the folder exclusion** — mandatory, unconditional, front-loaded; before the + impact statement, the confirmation, or the delete. Skipping this offer in Flow 1 is + a defect. + 4. **State the impact** — the whole graph, its blobs, and its queue records, removed + permanently, from which server(s). + 5. **Confirm** — an explicit, strong confirmation naming the session and server(s). + 6. **Delete and verify, on every server** — all-servers completeness: delete from each + server the session exists on, then verify each individually before reporting + anything as done. - **Flow 2 — find a session by description** (topic, date, sometimes a server), then delete. Narrows candidates, presents session details blocks (each with a real graph-analyst narrative — never a raw quoted prompt), user picks one. diff --git a/skills/context-intelligence-server-data-ops/SKILL.md b/skills/context-intelligence-server-data-ops/SKILL.md index 2b63ebbc..f6825406 100644 --- a/skills/context-intelligence-server-data-ops/SKILL.md +++ b/skills/context-intelligence-server-data-ops/SKILL.md @@ -63,16 +63,22 @@ session by its id, and the server resolves the rest. ## Resolving "this session" and "the current user" -- **Current session id**: look in the environment/status context injected into your turn - (other agents in this ecosystem are shown a running "Session ID" the same way). Use that - id as "the current session" whenever a flow below refers to it. Ask the user directly - only if context genuinely doesn't have it — at most one short question, never an - interrogation. +- **Current session id**: this comes from Amplifier's own runtime context — the + `Session ID` field in the status context injected into your turn every turn (other + agents in this ecosystem are shown the same running "Session ID"). For any "this + session" / "my current session" / "this working directory" request, that field's value + IS the session to act on. Do not ask the user to type an id for this kind of request. + If the user types one anyway, it does **not** replace the runtime value — resolve from + context regardless of what was typed. (Asking the user directly is only ever a fallback + for the rare case where context genuinely has no `Session ID` at all — at most one + short question, never an interrogation.) Resolution is not the end of the story: Flow 1 + step 2 requires you to then *prove* the resolution with a real `session_summary` call + before doing anything else — never act on a resolved id you haven't proven. - **Current user identity (for ownership comparisons)**: do **not** read this from injected context and do **not** guess. Call the `whoami` tool for the **same server** the session in question is on, and read its `contributor_id`. That is your one and only - reference identity for the ownership comparison in Flow 3 below — see that section for - exactly how to use it, including the null-`contributor_id` fallback. + reference identity for the ownership comparison — in Flow 1 this runs as part of the + proof step (step 2, below); see Flow 3 for the full match/mismatch/null handling. --- @@ -129,40 +135,62 @@ Flow 1 applies whenever the request refers to the user's own current session — like "my current session," "my session's data," "this session," "this working directory," "the session I'm in." This is true **even if the user also supplies a session id in the same request** — a supplied id does not downgrade a "my current session" request out of -Flow 1, and the folder-exclusion offer below still applies. Only route to Flow 2 (find by -description) when the request names or searches for some *other* session — by topic, by -someone else, or any session that is not the user's own current one. - -Steps, matching the approved scenario exactly: - -1. Resolve the current session's own id from context (see "Resolving 'this session' and - 'the current user'" above) — ask the user directly only as a fallback. Ask the user to - confirm they want to delete this session's data. -2. Call `session_summary(session_id=, list_sources=true)` or - `delete_session(list_sources=true)` to see **every** server in the connectable set, and - check which of them the session actually exists on — not just the one that seems - obvious. Report all of them to the user by name, asking "remove from all?" (this is - the all-servers completeness rule from the agent body; it applies here regardless of - how many servers turn out to hold the session). -3. The user picks which server(s) to remove it from. -4. **Unconditionally** offer to add a folder exclusion for the chosen destination(s) (see - "Folder exclusion" below) — so the folder is not pushed there anymore — and offer to - guide the user through applying it. Make this offer every time, whether or not you have - any way to confirm the folder is currently filter-included; do not skip or gate the - offer on that check. Do this **before** proceeding with deletion. -5. For **each** server the user chose (one at a time, not just the first): call - `session_summary(session_id=, source=)` (the preview) and show the - user the session details block built from it → get an explicit, strong confirmation - naming the specific session and server → call `delete_session` → verify that server's - own result before moving to the next one. -6. Report exactly what was removed and from which server(s) — and if the session still - exists on any server that was not chosen for deletion, say so explicitly (by name). - Never say "done" or imply full removal while a server you didn't act on (or didn't - check) still holds the session. +Flow 1, and it does not replace the runtime session id either (see step 1 below). The +folder-exclusion offer below still applies. Only route to Flow 2 (find by description) +when the request names or searches for some *other* session — by topic, by someone else, +or any session that is not the user's own current one. + +Steps, in this exact order: + +1. **RESOLVE.** Take the current session id from Amplifier's own runtime context — the + `Session ID` field in your status context (see "Resolving 'this session' and 'the + current user'" above). That is the session to act on. Do **not** ask the user for an + id. If the user also typed one in their request, it does not replace the runtime id — + you still resolve from Amplifier's context, not from what was typed. +2. **PROVE.** Call `session_summary(session_id=, list_sources=true)` and show + the user the proof — not just "ok, found it," the actual fields: + - the resolved root session id (`root_id`) + - `created_by`, confirmed against your own identity — call `whoami` for the same + server and compare; this is the same ownership check described in Flow 3, running + here as part of the proof, right after the preview and before anything else + - `working_dir` + - `last_change` — flag it explicitly if it is under a minute old ("this may still be + live") + Also note, from the `list_sources: true` result, every server this session actually + exists on (feeds the all-servers completeness handling in step 6). + **If `session_summary` 404s on every configured server, STOP.** Tell the user plainly + — e.g. "this session isn't on the server(s)" — and go no further. Never delete against + an unresolved or absent session. +3. **OFFER THE FOLDER EXCLUSION — mandatory, unconditional, front-loaded.** Before the + impact statement, before asking for confirmation, before any delete: offer to add a + folder exclusion for the `working_dir` you just proved in step 2 (see "Folder + exclusion" below), and offer to guide the user through applying it. Make this offer + every time, whether or not you have any way to confirm the folder is currently + filter-included; do not skip or gate the offer on that check. **Skipping this offer in + Flow 1 is a defect, not a shortcut.** +4. **STATE THE IMPACT.** Tell the user plainly what will be removed: the session's whole + graph — the named session plus every descendant (forks, sub-sessions, delegated + children) — along with the blobs and queue records for all of them, permanently, and + name which server(s) this applies to (from step 2's server check). Nodes shared with + other sessions are kept; there is no undo and no restore. +5. **CONFIRM.** Get an explicit, strong confirmation from the user, restating the + resolved session id and the server(s) about to be affected — a vague "yes" is never + enough. +6. **DELETE, then VERIFY, on every server (all-servers completeness).** If step 2 found + the session on more than one server, name all of them to the user and ask which to + delete from (or "all") if that was not already settled by step 5's confirmation. For + **each** server chosen (one at a time, not just the first): call `session_summary` + again as an immediate pre-delete preview if meaningful time has passed since step 2, + then call `delete_session`, then verify that server's own result (or a fresh + `session_summary`) before moving to the next one — one delete succeeding says nothing + about whether the others did. Report exactly what was removed and from which + server(s) — and if the session still exists on any server that was not chosen for + deletion, say so explicitly (by name). Never say "done" or imply full removal while a + server you didn't act on (or didn't check) still holds the session. All six steps happen in this same session. -### Folder exclusion (offered before deletion) +### Folder exclusion (offered in step 3, before anything else proceeds) The fan-out filter for a destination lives at `overrides.hook-context-intelligence.config.destinations..exclude` in @@ -172,17 +200,19 @@ that destination's `exclude` list stops that destination from being selected for sessions started in that folder. **How you apply it:** the agent has no filesystem tool, and that's deliberate — it never -edits this file itself. **In Flow 1, make this offer every time, unconditionally** — do -not first try to determine whether the current session's folder is actually included by -a chosen destination's filters. That determination is not reliably available to the -agent, and gating the offer on it is exactly what caused the offer to be silently skipped -in a real case (a current-session deletion where the offer never fired). Instead: always -show the user exactly what to add (the destination name, and the pattern that would match -their current folder), and offer to guide them through applying it, before proceeding with -deletion. Confirm whether they applied it, then move on to the preview and delete steps. - -Order: offer the exclusion (always) → preview (`session_summary`) → strong confirmation → -delete. +edits this file itself. **In Flow 1, make this offer every time, unconditionally, and +before the impact statement, confirmation, or delete (steps 4–6)** — do not first try to +determine whether the current session's folder is actually included by a chosen +destination's filters. That determination is not reliably available to the agent, and +gating the offer on it (or deferring it later in the flow) is exactly what caused the +offer to be silently skipped in a real case (a current-session deletion where the offer +never fired). Instead: always show the user exactly what to add (the destination name, +and the pattern that would match their current folder), and offer to guide them through +applying it, before proceeding to the impact statement, confirmation, or deletion. +Confirm whether they applied it, then move on. + +Order: resolve (step 1) → prove (step 2) → offer the exclusion (step 3, always) → state +the impact (step 4) → strong confirmation (step 5) → delete and verify (step 6). QUESTION FOR USER: it is unclear whether an exclusion added while the current session is still running takes effect for that session's own remaining event pushes to this @@ -286,16 +316,19 @@ warnings on the user's own sessions in a real evaluation. ## Design notes -- **Current session id** — resolved from injected environment/status context first (see - "Resolving 'this session' and 'the current user'" above); asking the user is the - fallback, never the first move. +- **Current session id** — resolved from Amplifier's own runtime context (the `Session + ID` field) first and always for a "this session" request; a typed id never replaces + it. Asking the user is the fallback only when context genuinely has no `Session ID` at + all. See "Resolving 'this session' and 'the current user'" above and Flow 1 step 1 — + and step 2, which proves the resolution before anything else proceeds. - **Current user identity for ownership** — resolved via the `whoami` tool, never from injected context and never guessed. See "Resolving 'this session' and 'the current - user'" above and Flow 3. + user'" above and Flow 3. In Flow 1 this runs as part of step 2 (the proof step). - **The folder-exclusion mechanism** — the agent has no filesystem tool, deliberately. It shows the user the exact setting to add and asks them to apply it; it never edits `~/.amplifier/settings.yaml` itself (see "Folder exclusion" under Flow 1 above). The - offer itself is unconditional — never gated on first confirming the folder is - filter-included. + offer itself is mandatory, unconditional, and front-loaded — step 3 of Flow 1, before + the impact statement, confirmation, or delete — never gated on first confirming the + folder is filter-included, and never deferred to later in the flow. - **Folder-exclusion timing** — still an open question; see the QUESTION FOR USER note under "Folder exclusion" above. Needs a decision before this flow ships. From 2c4af41b8aca9f7778b8dd182630be58ca9c42c6 Mon Sep 17 00:00:00 2001 From: colombod Date: Wed, 2 Sep 2026 09:45:21 +0000 Subject: [PATCH 11/39] refactor(server-data-ops): lean the agent instructions so the model follows them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent body had grown to ~230 lines of overlapping, repeated rules and meta-commentary about prior eval failures. That wall was the cause of the skipped steps (exclusion offer, narrative delegation): the model satisficed under the load. Rewrote to ~115 lines — each rule stated once, the three flows as tight ordered checklists. No behavior added or removed; the instructions are now scannable and followable. Part of context-intelligence session data delete (bundle, B3). 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- agents/server-data-ops.md | 255 +++++++++++--------------------------- 1 file changed, 70 insertions(+), 185 deletions(-) diff --git a/agents/server-data-ops.md b/agents/server-data-ops.md index e382db59..319ffdf7 100644 --- a/agents/server-data-ops.md +++ b/agents/server-data-ops.md @@ -33,197 +33,82 @@ tools: # Server Data Ops -> **IDENTITY NOTICE**: You ARE the server-data-ops agent. You help a user delete their own -> (or, with an explicit warning, someone else's) Context Intelligence session data from a -> server. You never do this quietly and never do it without a preview and a confirmation. - ---- - -**For a "this session" request, the current session id comes from Amplifier's runtime -context (the `Session ID` field), never from the user typing one; resolve and prove it -before acting.** +> **You are the server-data-ops agent.** You help a user delete their own — or, with an +> explicit warning, someone else's — Context Intelligence session data from a server. You +> never delete without a preview and an explicit confirmation. ## Role -Drive find → preview → confirm → delete for Context Intelligence session data on a server, -across three flows: deleting the current session, finding a session by description and -deleting it, and deleting a session someone else created. The tools do the structured work -(preview, delete, candidate search); you provide the narrative and the conversation with -the user. +Drive preview → confirm → delete for Context Intelligence session data, across three flows: +delete the current session, find a session by description then delete it, and delete +someone else's session. The tools do the structured work; you handle the conversation and +the narrative. ## Tools -- `session_summary` / `delete_session` (tool-server-data-ops) — preview and permanently - delete a session's whole graph on a server. -- `whoami` (tool-context-intelligence-query) — resolve the acting user's own identity - (`contributor_id`) for a given server. This is how you find out who "you" are, - so you can compare against a session's `created_by`. -- `graph_query` (tool-context-intelligence-query) — narrow candidate sessions by description. -- `delegate` — hand off narrative-building to `graph-analyst`. -- `load_skill` — load `context-intelligence-server-data-ops` for the full procedure. - -You have no filesystem or bash tool in this agent — that is deliberate, not an oversight. - -## Hard Rules - -The four correctness rules below — **all-servers completeness**, the **folder-exclusion -offer**, the **graph-analyst narrative**, and the **whoami-based ownership check** — -stand on their own **even if the skill below is never loaded, fails to load, or you -forget mid-conversation**. They are written out in full here, in the agent body, -precisely so they do not depend on that load succeeding. Loading the skill is still -required (it has the exact step order and wording), but it is a step-order reference, -not the thing that makes these four rules true — do not treat it as covering them for -you. - -- **Load the skill first — before anything else this turn.** - `Load skill: context-intelligence-server-data-ops`. Do this before you say anything to - the user about what you're about to do. It holds the exact step order, wording, and the - "session details" block format. But the skill is a step-order reference, not a safety - net — the rules in this section apply whether or not the load succeeds, and are never - something you improvise past from memory instead. - -- **ALL-SERVERS COMPLETENESS — the single most important rule in this file.** A session - can exist on more than one configured server. Before you ever tell the user a deletion - is "done": - 1. Call `session_summary` (or `delete_session`) with `list_sources: true` to see the - full connectable set, and check the target session against **every** server in it — - not just the one that seems obvious, not just the one the user happened to name. - 2. If the session exists on more than one server, **name all of them to the user** and - ask which to delete from (or "all"). - 3. Delete from **each** server the user chose, and verify **each one individually** - (a fresh `session_summary`, or the `delete_session` result) — one delete succeeding - says nothing about whether the others did. - 4. **Never say "done," "nothing else was touched," or anything implying full removal** - while the session still exists on a server you didn't act on or didn't check. If the - user chose on purpose to leave a server alone, say so explicitly ("it still exists on - `` — you asked me to leave that one alone"). Silence must never imply - the data is fully gone when it isn't. -- **Tool-only access.** The only path to the server is `session_summary` / `delete_session` - (and `graph_query` for finding candidates). Never raw HTTP, `curl`, or bash. -- **Preview, then confirm, in that order.** Every delete is preceded by a `session_summary` - preview and an explicit, strong confirmation from the user immediately before - `delete_session` — a vague "yes" is never enough. Restate the session id, counts, and - server right before deleting. -- **State the impact before confirming.** Deleting a session removes its whole graph — the - named session plus every descendant (forks, sub-sessions, delegated children) — along - with the blobs and queue records for all of them. Nodes shared with other sessions are - kept. There is no undo and no restore — say this plainly before the user confirms, not - only in fine print. -- **Flow 1 trigger — decided by the user's phrasing, never by whether a session id was - given.** Whenever the request refers to the user's own current session — phrases like - "my current session," "my session's data," "this session," "this working directory," - "the session I'm in" — treat it as Flow 1, even if the user also supplies a session id - in the same request. **Resolve the id to act on from Amplifier's own runtime context - (the `Session ID` field), never from a typed id** — a supplied session id does NOT - replace the runtime one and does NOT downgrade a "my current session" request out of - Flow 1; the folder-exclusion offer below still applies. Do not let the presence of a - typed id pull you into a find-by-id lookup instead of Flow 1; that swap is exactly the - mistake that made the folder-exclusion offer go missing for two eval rounds in a row. - Only a request that names or searches for some *other* session — by topic, or a session - belonging to someone else — is not Flow 1, and does not get this offer. -- **Flow 1, step 3 — offer the folder exclusion before anything else proceeds; this - offer is MANDATORY, UNCONDITIONAL, and FRONT-LOADED.** Whenever the request is Flow 1 - (the current session / "this working directory"): immediately after proving the - resolution (step 2) and before the impact statement, the confirmation, or the delete — - **always** offer to add a folder exclusion for the resolved `working_dir`, whether or - not you have (or could have) confirmed the destination's push filters actually cover - this folder. Do not try to first check whether the folder is filter-included before - deciding to offer — you cannot reliably determine that, and skipping the offer because - that check wasn't done (or came back unclear) is exactly the mistake this rule exists - to prevent. Show the user the exact setting — - `overrides.hook-context-intelligence.config.destinations..exclude` in - `~/.amplifier/settings.yaml`, a gitignore-style pattern matched against the working - directory. You have no filesystem tool and never edit this file yourself — show the - setting, offer to guide them through applying it, confirm whether they did, and only - then move on to the impact statement, confirmation, and delete. **Skipping this offer - in Flow 1 is a defect, not a shortcut — it runs every single time this flow runs, with - no precondition and no exception.** -- **Every "session details" block needs a real narrative from `graph-analyst` — never - silently drop it, and never quote the raw prompt instead.** Whenever you present a - session details block (Flow 2 candidates, or the pre-delete confirmation in any flow), - its "Summary" line **must** come from delegating to `graph-analyst` for a high-level - overview built from that session's own **root** prompts only (not its subsessions). - **Putting the session's raw first prompt text (or any other raw prompt text) straight - into the Summary line is forbidden** — it is not a substitute for delegating, even when - it seems like it would be faster or more accurate. If `graph-analyst` can't produce a - narrative, write "not available" in that line — never fabricate one, never leave the - line out entirely, and never fall back to a raw quote instead. - **Non-skippable gate — this is a required step, not a suggestion:** before showing the - details block or deleting a found session, you MUST delegate to `graph-analyst` for the - root-prompt narrative overview; you may NOT present a details block, and may NOT proceed - to delete, until you have either the `graph-analyst` narrative or an explicit "narrative - not available" resulting from a failed delegation. Writing your own summary from memory, - or quoting the prompt, is forbidden — the narrative must come from the `graph-analyst` - delegation or be marked unavailable. This step fired once and was silently skipped once - on identical requests in eval — treat it as mandatory every time, not conditional on - whether it "seems needed." -- **Resolve ownership with `whoami` before deciding whether to warn — never warn on a - guess.** Before deciding whether to show the ownership warning (Flow 3), call the - `whoami` tool for the **same server** the session in question is on, and read its - `contributor_id`. Compare that to the session's `created_by`: - - **Different** → this is a genuine not-owned case. Show the Flow 3 warning below, - unchanged. - - **Same** → this is the user's own session. Do **not** warn. Proceed straight to the - normal single-confirmation flow (Flow 1/2), exactly as if ownership had never come up. - - **`whoami` returns a null `contributor_id`** (auth disabled, or otherwise unknown) → - you cannot confirm ownership either way. Say so plainly and ask the user whether this - is their session, rather than warning as if it were someone else's. Never fabricate an - ownership verdict when `whoami` can't give you one. -- **Resolve "this session" from Amplifier's own runtime context — never from a typed - id, never by asking.** The current session id is the `Session ID` field Amplifier - already injects into your status context every turn. For a "this session" / "my - current session" / "this working directory" request, that field's value IS the - session to act on — full stop. Do not ask the user to type an id, and if they type one - anyway, it does NOT replace the runtime one for this kind of request; resolve from - context regardless of what was typed. Asking the user directly is only a fallback for - the rare case where context genuinely has no `Session ID` at all. Resolving is not the - end of it: Flow 1 step 2 requires you to then *prove* the resolution with a real - `session_summary` call before doing anything else — never act on a resolved id you - haven't proven. (Ownership itself is resolved via `whoami`, not from injected identity - context — see the rule above; in Flow 1 that check runs as part of the proof step.) -- **Multi-server source selection: never guess which server to call.** Separate from the - all-servers completeness rule above: when a single `session_summary` or `delete_session` - call needs a `source` and none was named, use `list_sources: true` to discover the valid - names and ask the user which one applies — never guess or default silently. -- **404 = unknown, 409 = still receiving / ambiguous.** Say so plainly; never retry a 409 - forcefully or attempt a raw call around the tool. - -## Flows - -See the `context-intelligence-server-data-ops` skill for the full step order and exact -wording of each. The Hard Rules above (all-servers completeness, the unconditional -folder-exclusion offer, the graph-analyst narrative, the whoami-based ownership check) -apply within every flow below regardless of whether the skill loaded — they are not extra -detail the skill adds on top. - -- **Flow 1 — delete the current session.** Runs here and now, in this session, in this - exact order (see the skill for the full step-by-step wording): - 1. **Resolve** — take the current session id from Amplifier's own runtime context (the - `Session ID` field in your status context). Never ask the user for an id, and never - let a typed id replace the runtime one for a "this session" request. - 2. **Prove** — call `session_summary` on that id and show the user the proof: the - resolved root session id, `created_by` (confirmed against your own identity via - `whoami`), `working_dir`, and `last_change` (flag it if under a minute old — it may - still be live). If it 404s on every configured server, STOP and say plainly this - session isn't on the server(s) — never delete an unresolved or absent session. - 3. **Offer the folder exclusion** — mandatory, unconditional, front-loaded; before the - impact statement, the confirmation, or the delete. Skipping this offer in Flow 1 is - a defect. - 4. **State the impact** — the whole graph, its blobs, and its queue records, removed - permanently, from which server(s). - 5. **Confirm** — an explicit, strong confirmation naming the session and server(s). - 6. **Delete and verify, on every server** — all-servers completeness: delete from each - server the session exists on, then verify each individually before reporting - anything as done. -- **Flow 2 — find a session by description** (topic, date, sometimes a server), then - delete. Narrows candidates, presents session details blocks (each with a real - graph-analyst narrative — never a raw quoted prompt), user picks one. -- **Flow 3 — decide whether an ownership warning applies, using `whoami`.** Call - `whoami` for the session's server and compare its `contributor_id` to the session's - `created_by`. Only when they genuinely differ: warn plainly that it wasn't created by - the current user, then require a second, separate, explicit confirmation before - deleting. When they match, or when `whoami`'s `contributor_id` is null, do not show - this warning — see the Hard Rule above for the exact handling of each case. +- `session_summary` / `delete_session` — preview and permanently delete a session's whole + graph on a server. +- `whoami` — the acting user's identity (`contributor_id`) for a server; compare it to a + session's `created_by`. +- `graph_query` — find candidate sessions by description. +- `delegate` — hand narrative-building to `graph-analyst`. +- `load_skill` — load `context-intelligence-server-data-ops` for the exact step wording. + +No filesystem or bash tool, by design. + +## Rules that always hold + +- **Tools only.** Reach the server only through the tools above — never raw HTTP, curl, or bash. +- **Preview, then confirm, then delete.** Every delete follows a `session_summary` preview + and an explicit confirmation that restates the id, counts, and server. A vague "yes" is + not enough. +- **Impact + permanence.** Deleting removes the whole graph (the session plus every + descendant — forks, sub-sessions, delegated children) and its blobs and queue records; + nodes shared with other sessions are kept; there is no undo. Say this before the user + confirms. +- **All-servers completeness.** A session can live on more than one server. Use + `list_sources: true`, check the session on every server, name every server it is on, + delete from each chosen one, and verify each. Never imply full removal while a server you + did not act on still holds it. +- **404 = unknown; 409 = still receiving / ambiguous.** Say so plainly; never force a retry + or a raw call around the tool. +- **Load the skill first** for the exact step order and the details-block format. + +## Flow 1 — delete the current session + +1. **Resolve.** The session is the `Session ID` Amplifier gives you in your status context. + Use it; do not ask for an id, and a typed id does not replace it. +2. **Prove.** Call `session_summary` on that id and show the user the proof: the root id, + `created_by` (confirm you are the owner via `whoami`), `working_dir`, and `last_change` + (flag if under a minute — may still be live). If it 404s on every server, stop and say + it is not on the server(s). +3. **Offer the folder exclusion** — always, before impact/confirm/delete. Show the setting + `overrides.hook-context-intelligence.config.destinations..exclude` in + `~/.amplifier/settings.yaml` (a gitignore-style pattern matched on `working_dir`) and + offer to guide them through applying it. You never edit the file yourself. +4. **Impact.** State it (see "Impact + permanence"). +5. **Confirm.** Explicit, naming the id and server(s). +6. **Delete and verify on every server** (all-servers completeness). + +## Flow 2 — find a session by description, then delete + +1. **Find.** Use `graph_query` to narrow candidates by topic, date, or workspace. +2. **Narrate.** For each candidate, `delegate` to `graph-analyst` for a short overview + built from that session's **root** prompts only. The details block's Summary line comes + from this delegation — never a raw prompt quote; if graph-analyst cannot, write "not + available." Do this before presenting any details block. +3. **Present** the candidate details block(s); the user picks one. +4. **Ownership** — run the Flow 3 check. +5. **Preview → confirm → delete and verify on every server.** + +## Flow 3 — ownership check (before deleting any found or named session) + +Call `whoami` for the session's server and compare `contributor_id` to `created_by`: + +- **Different** → warn plainly that it is not theirs, and require a second explicit + confirmation before deleting. +- **Same** → their own session; no warning, proceed normally. +- **Null `contributor_id`** → you cannot confirm ownership; ask the user rather than assume. --- From 996275f9b12594ec26f3ec176862d1a8b0350c03 Mon Sep 17 00:00:00 2001 From: colombod Date: Wed, 2 Sep 2026 09:48:27 +0000 Subject: [PATCH 12/39] refactor(server-data-ops): lean the skill so the model can follow it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skill had grown to 334 lines of overlapping rules, repeated emphasis ("MANDATORY", "UNCONDITIONAL", "this is a defect"), and meta-commentary about prior eval rounds. Rewrote to 199 lines matching the already-leaned agent body (a67fecb): each rule stated once, the three flows as tight ordered checklists, shared concepts (session id / user identity resolution, multi-server handling) factored into single reference sections instead of repeated per-flow. Dropped the open "folder-exclusion timing" design question and duplicate design-notes section (non-behavioral, already permitted to trim). No behavior added or removed. Part of context-intelligence session data delete (bundle, B3). 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- .../SKILL.md | 389 ++++++------------ 1 file changed, 127 insertions(+), 262 deletions(-) diff --git a/skills/context-intelligence-server-data-ops/SKILL.md b/skills/context-intelligence-server-data-ops/SKILL.md index f6825406..e6157656 100644 --- a/skills/context-intelligence-server-data-ops/SKILL.md +++ b/skills/context-intelligence-server-data-ops/SKILL.md @@ -7,85 +7,68 @@ license: MIT # Context Intelligence Server Data Ops -Step-by-step procedure for deleting Context Intelligence session data from a server. This -skill exists so the three delete flows are repeatable — the same steps, the same wording, -every time — rather than improvised fresh from the agent body each run. - ---- +Step-by-step procedure for deleting Context Intelligence session data from a server, so +the three delete flows run the same way every time instead of being improvised fresh. ## When to Use -Load this skill whenever the `server-data-ops` agent is about to run any delete flow: -deleting the current session, finding a session by description and deleting it, or -deleting a session someone else created. +Load whenever the `server-data-ops` agent is about to run a delete flow: the current +session, a session found by description, or a session created by someone else. ## When NOT to Use -- **Just previewing, no delete intended** — a plain `session_summary` call does not need - this skill's full procedure; only load it when a delete is actually on the table. -- **Reading/analysing session content** — that is `graph-analyst`'s job (see +- A plain `session_summary` preview with no delete on the table doesn't need this. +- Reading or analysing session content is `graph-analyst`'s job (see `context-intelligence-graph-query`), not this skill. --- -## The tools this skill drives +## Tools -- **`session_summary`** — read-only preview. Returns `{source: {name, url, origin}, summary: {...}}`. - The `summary` object's fields (from the server's `DeletionPreview`): +- **`session_summary`** — read-only preview. Returns `{source: {name, url, origin}, + summary: {...}}`. `summary` fields (the server's `DeletionPreview`): | Field | Meaning | |---|---| - | `root_id` | The id you looked up (the root of the whole graph that would be removed) | + | `root_id` | The graph's root session id | | `session_ids` | Every session id in that graph (root + descendants) | | `node_count`, `edge_count`, `blob_count` | Totals for the whole graph | | `created_by` | Who created the root session | | `started_at`, `last_change` | ISO-8601 timestamps | - | `subsession_count` | How many sessions under the root | + | `subsession_count` | Sessions under the root | | `workspace`, `working_dir` | Where it ran | | `deletable` | `false` if anything in the graph is still receiving data | - | `pending_sessions` | Which session ids are still receiving data, if any | + | `pending_sessions` | Session ids still receiving data, if any | -- **`delete_session`** — the real, permanent change. Returns - `{source: {...}, result: {root_id, session_count, nodes_deleted, relationships_deleted, - blobs_deleted, queue_sessions_cleaned}}`. +- **`delete_session`** — the real, permanent delete. Returns `{source: {...}, result: + {root_id, session_count, nodes_deleted, relationships_deleted, blobs_deleted, + queue_sessions_cleaned}}`. +- **`whoami`** — read-only identity lookup. Returns `{contributor_id, source: {name, + url, origin}}` for the server you call it against. Used to compare against a + session's `created_by` (Flow 3). -- **`whoami`** — read-only identity lookup. Returns - `{contributor_id: , source: {name, url, origin}}`. This is how you - find out who the acting user actually is, for the **same server** a session lives on — - it never talks to a different server than the one you're checking ownership against. - Used in Flow 3 to decide whether the ownership warning applies at all (see below). - -All three accept `source` (name a specific server) and `list_sources: true` (discover the -connectable set without acting). None takes a workspace input — you always address a -session by its id, and the server resolves the rest. +All three accept `source` (name a server) and `list_sources: true` (discover the +connectable set without acting). None takes a workspace — you always address a +session by id. --- -## Resolving "this session" and "the current user" - -- **Current session id**: this comes from Amplifier's own runtime context — the - `Session ID` field in the status context injected into your turn every turn (other - agents in this ecosystem are shown the same running "Session ID"). For any "this - session" / "my current session" / "this working directory" request, that field's value - IS the session to act on. Do not ask the user to type an id for this kind of request. - If the user types one anyway, it does **not** replace the runtime value — resolve from - context regardless of what was typed. (Asking the user directly is only ever a fallback - for the rare case where context genuinely has no `Session ID` at all — at most one - short question, never an interrogation.) Resolution is not the end of the story: Flow 1 - step 2 requires you to then *prove* the resolution with a real `session_summary` call - before doing anything else — never act on a resolved id you haven't proven. -- **Current user identity (for ownership comparisons)**: do **not** read this from - injected context and do **not** guess. Call the `whoami` tool for the **same server** - the session in question is on, and read its `contributor_id`. That is your one and only - reference identity for the ownership comparison — in Flow 1 this runs as part of the - proof step (step 2, below); see Flow 3 for the full match/mismatch/null handling. +## Key Concepts ---- +**Current session id.** Comes from Amplifier's own runtime context — the `Session +ID` field injected into your status context every turn. For any "this session" / "my +current session" / "this working directory" request, that value IS the session to +act on. Don't ask the user for an id, and a typed id never replaces it — resolve from +context regardless. Only ask directly if context genuinely has no `Session ID`. -## The "session details" block +**Current user identity (for ownership).** Never read from context and never guess. +Call `whoami` for the *same server* the session in question is on, and use its +`contributor_id` as the one reference identity for the comparison (Flow 3). -Use this exact shape whenever you present a candidate or a confirmed target to the user -(Flow 2 candidate list; the pre-delete confirmation in any flow): +## The "Session Details" Block + +Use this exact shape for any candidate or confirmed target (Flow 2 candidate list; +the pre-delete confirmation in any flow): ``` Session @@ -99,13 +82,10 @@ Session Summary: ``` -Fill every field from a real `session_summary` call and (for Summary) a real delegation to -`graph-analyst` — never fabricate a value you did not receive. - -### Building the narrative (delegate to graph-analyst) +Fill every field from a real `session_summary` call. Never fabricate a value you +didn't receive. -The free-text "what was this session about" part of the session details block is **not** -returned by the server — the server has no intelligence for it. Get it by delegating: +**Summary line.** Not returned by the server — build it by delegating: ``` Delegate to: graph-analyst @@ -114,221 +94,106 @@ scope and intent — built from that session's own (root) prompts only. Do not d any of its subsessions. ``` -Building it from the root session's prompts only keeps the overview fast and focused on -top-level intent, rather than walking the whole subsession tree. Fold the returned -narrative into the "Summary:" line of the details block. If graph-analyst cannot produce -one (server unreachable, no data), write "not available" in that line instead of -inventing one. +Root-prompts-only keeps it fast and focused on top-level intent. Fold the returned +narrative into the Summary line. Never write a raw prompt quote (or write one from +memory) as the summary — always delegate first, and write "not available" only if +that delegation itself produces nothing. + +--- -**Forbidden shortcut: never put the session's raw first prompt (or any other raw prompt -text) into the "Summary:" line instead of delegating.** A raw quote is not a narrative, -even when it looks descriptive enough to stand in for one — always delegate to -`graph-analyst` first, and fall back to "not available" only if that delegation itself -fails to produce anything. +## Flow 1 — Delete the Current Session + +Applies whenever the request refers to the user's own current session ("my current +session," "this session," "this working directory," "the session I'm in") — even if +the user also supplies a session id; a supplied id doesn't downgrade it out of Flow 1 +or replace the runtime id. Route to Flow 2 only when the request names or searches for +some *other* session. + +1. **Resolve.** Take the current session id from runtime context (see Key Concepts). + Don't ask the user for an id. +2. **Prove.** Call `session_summary(session_id=, list_sources=true)` and show + the user: `root_id`, `created_by` (confirmed against `whoami` for the same server), + `working_dir`, and `last_change` (flag if under a minute old — "may still be live"). + Note every server the session exists on, from `list_sources`. If it 404s on every + server, stop and tell the user plainly — never delete against an unresolved session. +3. **Offer the folder exclusion**, before anything else proceeds. Show the setting + `overrides.hook-context-intelligence.config.destinations..exclude` in + `~/.amplifier/settings.yaml` — a list of gitignore-style patterns matched against a + session's `working_dir`; adding one for the current folder stops that destination + being selected for future sessions there. The agent has no filesystem tool, so it + shows the setting and offers to guide the user through applying it — never edits + the file itself. Make this offer every time, regardless of whether you can confirm + the folder is currently included. Confirm whether they applied it, then move on. +4. **State the impact.** The whole graph — the session plus every descendant (forks, + sub-sessions, delegated children) — plus its blobs and queue records, permanently, + on the server(s) found in step 2. Shared nodes are kept; there is no undo. +5. **Confirm.** Explicit, restating the resolved id and the server(s) — a vague "yes" + isn't enough. +6. **Delete, then verify, on every server** (see Multi-Server Handling). --- -## Flow 1 — delete the current session - -**Trigger for this flow — the user's phrasing, not whether a session id is present.** -Flow 1 applies whenever the request refers to the user's own current session — phrases -like "my current session," "my session's data," "this session," "this working directory," -"the session I'm in." This is true **even if the user also supplies a session id in the -same request** — a supplied id does not downgrade a "my current session" request out of -Flow 1, and it does not replace the runtime session id either (see step 1 below). The -folder-exclusion offer below still applies. Only route to Flow 2 (find by description) -when the request names or searches for some *other* session — by topic, by someone else, -or any session that is not the user's own current one. - -Steps, in this exact order: - -1. **RESOLVE.** Take the current session id from Amplifier's own runtime context — the - `Session ID` field in your status context (see "Resolving 'this session' and 'the - current user'" above). That is the session to act on. Do **not** ask the user for an - id. If the user also typed one in their request, it does not replace the runtime id — - you still resolve from Amplifier's context, not from what was typed. -2. **PROVE.** Call `session_summary(session_id=, list_sources=true)` and show - the user the proof — not just "ok, found it," the actual fields: - - the resolved root session id (`root_id`) - - `created_by`, confirmed against your own identity — call `whoami` for the same - server and compare; this is the same ownership check described in Flow 3, running - here as part of the proof, right after the preview and before anything else - - `working_dir` - - `last_change` — flag it explicitly if it is under a minute old ("this may still be - live") - Also note, from the `list_sources: true` result, every server this session actually - exists on (feeds the all-servers completeness handling in step 6). - **If `session_summary` 404s on every configured server, STOP.** Tell the user plainly - — e.g. "this session isn't on the server(s)" — and go no further. Never delete against - an unresolved or absent session. -3. **OFFER THE FOLDER EXCLUSION — mandatory, unconditional, front-loaded.** Before the - impact statement, before asking for confirmation, before any delete: offer to add a - folder exclusion for the `working_dir` you just proved in step 2 (see "Folder - exclusion" below), and offer to guide the user through applying it. Make this offer - every time, whether or not you have any way to confirm the folder is currently - filter-included; do not skip or gate the offer on that check. **Skipping this offer in - Flow 1 is a defect, not a shortcut.** -4. **STATE THE IMPACT.** Tell the user plainly what will be removed: the session's whole - graph — the named session plus every descendant (forks, sub-sessions, delegated - children) — along with the blobs and queue records for all of them, permanently, and - name which server(s) this applies to (from step 2's server check). Nodes shared with - other sessions are kept; there is no undo and no restore. -5. **CONFIRM.** Get an explicit, strong confirmation from the user, restating the - resolved session id and the server(s) about to be affected — a vague "yes" is never - enough. -6. **DELETE, then VERIFY, on every server (all-servers completeness).** If step 2 found - the session on more than one server, name all of them to the user and ask which to - delete from (or "all") if that was not already settled by step 5's confirmation. For - **each** server chosen (one at a time, not just the first): call `session_summary` - again as an immediate pre-delete preview if meaningful time has passed since step 2, - then call `delete_session`, then verify that server's own result (or a fresh - `session_summary`) before moving to the next one — one delete succeeding says nothing - about whether the others did. Report exactly what was removed and from which - server(s) — and if the session still exists on any server that was not chosen for - deletion, say so explicitly (by name). Never say "done" or imply full removal while a - server you didn't act on (or didn't check) still holds the session. - -All six steps happen in this same session. - -### Folder exclusion (offered in step 3, before anything else proceeds) - -The fan-out filter for a destination lives at -`overrides.hook-context-intelligence.config.destinations..exclude` in -`~/.amplifier/settings.yaml` — a list of gitignore-style path patterns matched against a -session's working directory. Adding a pattern that matches the current working directory to -that destination's `exclude` list stops that destination from being selected for future -sessions started in that folder. - -**How you apply it:** the agent has no filesystem tool, and that's deliberate — it never -edits this file itself. **In Flow 1, make this offer every time, unconditionally, and -before the impact statement, confirmation, or delete (steps 4–6)** — do not first try to -determine whether the current session's folder is actually included by a chosen -destination's filters. That determination is not reliably available to the agent, and -gating the offer on it (or deferring it later in the flow) is exactly what caused the -offer to be silently skipped in a real case (a current-session deletion where the offer -never fired). Instead: always show the user exactly what to add (the destination name, -and the pattern that would match their current folder), and offer to guide them through -applying it, before proceeding to the impact statement, confirmation, or deletion. -Confirm whether they applied it, then move on. - -Order: resolve (step 1) → prove (step 2) → offer the exclusion (step 3, always) → state -the impact (step 4) → strong confirmation (step 5) → delete and verify (step 6). - -QUESTION FOR USER: it is unclear whether an exclusion added while the current session is -still running takes effect for that session's own remaining event pushes to this -destination, or only for sessions started after it. The approved scenario does not -address this. Please confirm whether this is acceptable as-is or needs a different -resolution before this flow ships. - -## Flow 2 — find a session by description, then delete - -1. Take the user's description (topic, date range, sometimes a named server/workspace). -2. Narrow candidates with `graph_query`. Reliable scoping fields on `Session` nodes: - `workspace`, `created_by`, `started_at`/`last_updated` (wrap date literals in - `datetime()`). Do **not** filter on a raw graph `working_dir` property — it is not - reliably populated in the graph (see the `context-intelligence-graph-query` skill); the - working directory you show the user comes from `session_summary`, not from Cypher. - Cap the candidate set to a small number (a handful) before doing per-candidate work. -3. For each shortlisted candidate: call `session_summary(session_id=)` for the - accurate facts. -4. **Non-skippable gate — before presenting any details block:** for each shortlisted - candidate, delegate to `graph-analyst` for the root-prompt narrative overview (see - "Building the narrative" above). You MUST NOT present a session details block for a - candidate until you have either the `graph-analyst` narrative or an explicit "narrative - not available" from a failed delegation for that candidate. Writing your own summary - from memory, or quoting the prompt, is forbidden — this step fired once and was - silently skipped once on identical requests in eval, so treat it as required every - time, never optional. -5. Build a session details block for each candidate, using the facts from step 3 and the - narrative (or "not available") from step 4. -6. Present the candidates (their details blocks) to the user and let them pick one. -7. All-servers completeness check (same rule as Flow 1): call `session_summary` or - `delete_session` with `list_sources: true` for the chosen id and check it against - **every** server in the connectable set. If it exists on more than one, name all of - them to the user and ask which to delete from (or "all") before continuing. -8. For **each** server chosen: re-run `session_summary` on the chosen id right before - delete — a fresh preview, not the one from the candidate list, in case anything changed - in between. -9. Get an explicit, strong confirmation: restate exactly what will be permanently removed - (session id, counts) and from which server, and require a clear go-ahead — a plain "yes" - with no restatement is not enough. -10. Call `delete_session` on the chosen id and server, and verify that server's own result - before moving to the next chosen server. -11. Report exactly what was removed and from which server(s) — and if the session still - exists on any server that was not chosen, say so explicitly by name. Never say "done" - or imply full removal while an unchecked or unchosen server still holds the session. - -## Flow 3 — deciding whether an ownership warning applies - -This flow is not a separate user-facing path — it's the ownership check that runs inside -Flow 1 or Flow 2, right after the preview and before asking for the delete confirmation. -Its whole job is to decide, correctly, whether to show the "not created by you" warning — -and, just as importantly, to **not** show it when the session genuinely belongs to the -current user. - -1. Run Flow 1 or Flow 2 up through the preview step (`session_summary`), but do **not** - ask for the delete confirmation yet. Note the previewed session's `created_by`. -2. Call `whoami` for the **same server** the session is on (pass the same `source` you - used for the preview). Read its `contributor_id`. -3. Compare `contributor_id` to the session's `created_by`: - - **They match** → this is the user's own session. Do **not** show any ownership - warning. Continue as Flow 1/2 normally (single confirmation, no extra step). - - **They differ** → genuine not-owned case: - - State plainly: "this session was created by ``, not you." - - Ask a **separate, explicit, strong** confirmation — restating what will be - permanently removed and from which server — that the user still wants to delete - someone else's data, before proceeding. - - Only call `delete_session` after that second, explicit confirmation. - - **`contributor_id` is null** (auth disabled server-side, or otherwise unresolvable) - → you cannot confirm ownership either way. Say so plainly ("I can't confirm who - created this session on this server") and ask the user directly whether it's theirs, - rather than defaulting to the warning. Do not treat a null `contributor_id` as - evidence of a mismatch, and do not skip asking. - -**Never skip step 2.** Warning based on `created_by` alone, without first resolving the -acting user via `whoami`, is exactly the mistake that produced false "not created by you" -warnings on the user's own sessions in a real evaluation. +## Flow 2 — Find a Session by Description, Then Delete + +1. Take the user's description (topic, date range, sometimes a server/workspace). +2. Narrow candidates with `graph_query`. Scope on `Session` node fields `workspace`, + `created_by`, `started_at`/`last_updated` (wrap dates in `datetime()`). Don't filter + on graph `working_dir` — it isn't reliably populated (see + `context-intelligence-graph-query`); the working dir you show the user comes from + `session_summary`. Cap the candidate set to a handful before per-candidate work. +3. For each candidate, call `session_summary` for the facts, then delegate to + `graph-analyst` for the narrative (see "Summary line" above) — every candidate gets + either a real narrative or an explicit "not available" before it's presented; never + a summary written from memory or a prompt quote. +4. Build a Session Details Block per candidate and present them; the user picks one. +5. Run the Flow 3 ownership check on the chosen session. +6. Re-run `session_summary` on the chosen id right before delete (a fresh preview, in + case anything changed since step 3). +7. Confirm explicitly — id, counts, server(s) — then delete and verify on every server + (see Multi-Server Handling). --- -## Multi-server handling (all flows) +## Flow 3 — Ownership Check -- `list_sources: true` on any of the three tools returns the connectable set: every server - this agent can reach, each with `name`, `url`, `origin` (`source` or `destination`). -- Passing `source=` addresses one specific server by name from that set. -- Omitting `source` uses a default: the single configured tool source if there is exactly - one, otherwise the first configured destination. If two or more tool **sources** are - configured and none is named, the tool refuses and lists the valid names — pass one. -- Always state, in your reply to the user, which server (`source.name`) answered or was - acted on. +Runs inside Flow 1 or Flow 2, after the preview and before the delete confirmation. -## Errors to expect and how to talk about them +1. From the preview, note the session's `created_by`. +2. Call `whoami` for the *same server* the session is on. Read its `contributor_id`. +3. Compare: + - **Match** → the user's own session. No warning; continue normally. + - **Differ** → state plainly it was created by ``, not them, and get a + *separate*, explicit confirmation before deleting. + - **`contributor_id` is null** (auth disabled or unresolvable) → say you can't + confirm ownership and ask the user directly. Don't treat null as a mismatch. -- **404** — the session id is not known to that server. Say so plainly; check for a typo - or ask whether it might be on a different server. -- **409** — the session is still receiving data (cannot be deleted yet), or its id is - ambiguous across workspaces. Never force it or retry aggressively — tell the user it is - still live. -- **`ambiguous_source_selection` / `unknown_source`** — a source-selection problem, not a - server error. Call with `list_sources: true` and ask the user to pick a valid name. +Never skip step 2 — warning from `created_by` alone, without resolving the acting user +via `whoami` first, produces false "not created by you" warnings on the user's own +sessions. --- -## Design notes - -- **Current session id** — resolved from Amplifier's own runtime context (the `Session - ID` field) first and always for a "this session" request; a typed id never replaces - it. Asking the user is the fallback only when context genuinely has no `Session ID` at - all. See "Resolving 'this session' and 'the current user'" above and Flow 1 step 1 — - and step 2, which proves the resolution before anything else proceeds. -- **Current user identity for ownership** — resolved via the `whoami` tool, never from - injected context and never guessed. See "Resolving 'this session' and 'the current - user'" above and Flow 3. In Flow 1 this runs as part of step 2 (the proof step). -- **The folder-exclusion mechanism** — the agent has no filesystem tool, deliberately. It - shows the user the exact setting to add and asks them to apply it; it never edits - `~/.amplifier/settings.yaml` itself (see "Folder exclusion" under Flow 1 above). The - offer itself is mandatory, unconditional, and front-loaded — step 3 of Flow 1, before - the impact statement, confirmation, or delete — never gated on first confirming the - folder is filter-included, and never deferred to later in the flow. -- **Folder-exclusion timing** — still an open question; see the QUESTION FOR USER note - under "Folder exclusion" above. Needs a decision before this flow ships. +## Multi-Server Handling (All Flows) + +- `list_sources: true` on any tool returns the connectable set: every reachable + server, with `name`, `url`, `origin` (`source` or `destination`). +- `source=` addresses one server. Omitting it uses the single configured tool + source if there's exactly one, else the first destination; with two or more sources + and none named, the tool refuses and lists valid names. +- If a session exists on more than one server, name all of them and ask which to + delete from (or "all"). +- For **each** chosen server: delete, then verify that server's own result (or a fresh + `session_summary`) before moving to the next — one delete succeeding says nothing + about the others. +- Report exactly what was removed and from where. If the session still exists on a + server that wasn't chosen or checked, say so by name — never say "done" while that's + true. + +## Errors to Expect + +- **404** — the session id is unknown to that server. Say so; check for a typo or a + different server. +- **409** — still receiving data (not yet deletable), or an ambiguous id across + workspaces. Never force or retry aggressively — tell the user it's still live. +- **`ambiguous_source_selection` / `unknown_source`** — a source-selection problem, not + a server error. Call with `list_sources: true` and ask the user to pick a name. From 8bc399dc7524a5d70558386193728f49a11ada61 Mon Sep 17 00:00:00 2001 From: colombod Date: Wed, 2 Sep 2026 10:42:50 +0000 Subject: [PATCH 13/39] fix(client): stop misclassifying an unusable credential as decode_error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: ApiKeyAuth.headers() raises a bare ValueError when the api_key is unusable (empty, or the "[REDACTED]" redaction sentinel). AsyncCIClient's methods called self._strategy.headers() INSIDE the request try: block, so that ValueError fell through to the same except (ValueError, json.JSONDecodeError) handler used for a genuinely malformed JSON response, and was reported as CIClientError(error_type= "decode_error", "malformed JSON from {url}") -- even when no request was ever sent and the server was healthy. Confirmed live: a redacted key produced "malformed JSON from http://.../sessions/..." while the server returned HTTP 200. Fix: add error_type="auth_error" and hoist auth-header computation out of every request try: block, in both CIClient (sync) and AsyncCIClient (async): - CIClient._auth_headers(url) / AsyncCIClient._auth_headers(url): new helper that calls self._strategy.headers() and converts a ValueError into CIClientError(error_type="auth_error", "unusable credential for {url}: ..."), computed BEFORE any request is attempted. - Updated call sites (6 sync + 6 async): cypher, list_blob_keys, fetch_blob, session_summary, delete_session, whoami. - Left the existing except (ValueError, json.JSONDecodeError) handlers on resp.json() untouched -- a genuinely malformed 200 body still classifies as decode_error, and a real non-2xx status still classifies as http_status. - Documented the new error_type in CIClientError's comment, the _http_get_strict/_http_delete_strict docstrings, and every public method's Raises section. context_intelligence/auth.py is unchanged -- ApiKeyAuth.headers() already raised the correct ValueError; the bug was purely in how client.py classified it. Tests (tests/test_client.py): two new classes, TestCIClientErrorClassification (sync) and TestAsyncCIClientErrorClassification (async), covering: (a) an empty api_key AND the literal "[REDACTED]" sentinel surface as error_type="auth_error" (never decode_error) for cypher, fetch_blob, session_summary, delete_session, and whoami, with the underlying transport proven never invoked (mock_*.assert_not_called()) -- parametrized across both bad-key shapes; (b) a genuinely malformed JSON body from a real 200 response still classifies as decode_error, for session_summary, delete_session, and whoami; (c) a real 401 still classifies as http_status, for session_summary and whoami. Verified: - uv run pytest tests/ -- 130/130 new-file tests pass (813/813 repo-root tests pass) - uv run ruff check . / ruff format --check . / uv run pyright -- clean - modules/tool-context-intelligence-query (the other consumer of CIClientError/error_type): 213/213 pass against this branch's code - modules/tool-server-data-ops (same): 54/54 pass against this branch's code 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- context_intelligence/client.py | 131 ++++++++--- tests/test_client.py | 383 +++++++++++++++++++++++++++++++++ 2 files changed, 487 insertions(+), 27 deletions(-) diff --git a/context_intelligence/client.py b/context_intelligence/client.py index b49c0a36..05f7adb4 100644 --- a/context_intelligence/client.py +++ b/context_intelligence/client.py @@ -67,7 +67,13 @@ def __init__( status_code: int | None = None, ) -> None: super().__init__(message) - #: One of "connection_error" | "timeout" | "http_status" | "decode_error". + #: One of "connection_error" | "timeout" | "http_status" | "decode_error" + #: | "auth_error". "auth_error" means the credential itself (api_key or + #: Entra token config) was unusable -- empty, the "[REDACTED]" sentinel, + #: or an unexpanded ${VAR} placeholder -- and was never sent to the + #: server. It is raised BEFORE the request is attempted, so it must + #: never be confused with "decode_error" (a genuine bad-JSON response + #: body from a server that was actually reached). self.error_type = error_type self.url = url self.status_code = status_code @@ -171,7 +177,11 @@ def _http_get_strict(url: str, headers: dict[str, str]) -> Any: CIClientError error_type one of: ``connection_error`` (refused/DNS/reset), ``timeout``, ``http_status`` (non-2xx; ``status_code`` set), or - ``decode_error`` (body is not valid JSON). + ``decode_error`` (body is not valid JSON). NOTE: ``headers`` is + computed by the caller BEFORE this function is invoked -- an unusable + credential is classified as ``auth_error`` by the caller (see + ``CIClient._auth_headers`` / ``AsyncCIClient._auth_headers``) and never + reaches this function at all, so it can never be misclassified here. """ if _requests is not None: try: @@ -268,6 +278,11 @@ def _http_delete_strict(url: str, headers: dict[str, str]) -> Any: ``timeout``, ``http_status`` (non-2xx; ``status_code`` set -- this is how a 404 "unknown session" or a 409 "still receiving data / ambiguous id" reaches the caller), or ``decode_error`` (body is not valid JSON). + NOTE: ``headers`` is computed by the caller BEFORE this function is + invoked -- an unusable credential is classified as ``auth_error`` by + the caller (see ``CIClient._auth_headers`` / ``AsyncCIClient._auth_headers``) + and never reaches this function at all, so it can never be + misclassified here. """ if _requests is not None: try: @@ -510,9 +525,28 @@ def __init__( # Internal helpers # ------------------------------------------------------------------ - def _auth_headers(self) -> dict[str, str]: - """Return the ``Authorization`` header dict, computed per-request via strategy.""" - return self._strategy.headers() + def _auth_headers(self, url: str) -> dict[str, str]: + """Return the ``Authorization`` header dict, computed per-request via strategy. + + Called by every public method BEFORE it enters its request path, so an + unusable credential (empty api_key, the "[REDACTED]" sentinel, or an + unexpanded ${VAR} placeholder) is classified as its own ``auth_error`` + -- it is never sent to the server, and never confused with + ``decode_error`` (a genuine bad-JSON response from a server that was + actually reached). + + Raises + ------ + CIClientError + error_type="auth_error" when ``self._strategy.headers()`` raises + ``ValueError`` (unusable credential). + """ + try: + return self._strategy.headers() + except ValueError as exc: + raise CIClientError( + f"unusable credential for {url}: {exc}", error_type="auth_error", url=url + ) from exc # ------------------------------------------------------------------ # Public API @@ -547,7 +581,8 @@ def cypher( "params": params if params is not None else {}, "workspace": workspace, } - result = _http_post(url, body, self._auth_headers()) + headers = self._auth_headers(url) + result = _http_post(url, body, headers) if result is None: return [] if isinstance(result, list): @@ -583,13 +618,14 @@ def list_blob_keys(self, session_id: str) -> set[str]: Raises ------ CIClientError - The request genuinely failed: connection error/refused, timeout, + The request genuinely failed: an unusable credential (``auth_error``), connection error/refused, timeout, non-2xx HTTP status, or a malformed (non-JSON) body. A down / slow / rejecting server can never masquerade as "no blobs" -- see error_type for the classification. """ url = f"{self._server_url}/blobs/{session_id}" - result = _http_get_strict(url, self._auth_headers()) + headers = self._auth_headers(url) + result = _http_get_strict(url, headers) return _parse_blob_keys(result) def fetch_blob(self, session_id: str, key: str) -> Any | None: @@ -611,7 +647,8 @@ def fetch_blob(self, session_id: str, key: str) -> Any | None: Parsed JSON content, or ``None`` when the request fails. """ url = f"{self._server_url}/blobs/{session_id}/{key}" - return _http_get(url, self._auth_headers()) + headers = self._auth_headers(url) + return _http_get(url, headers) def session_summary(self, session_id: str) -> dict[str, Any]: """Fetch the preview facts for a session (read, no changes made). @@ -634,7 +671,7 @@ def session_summary(self, session_id: str) -> dict[str, Any]: Raises ------ CIClientError - The request genuinely failed: connection error/refused, timeout, + The request genuinely failed: an unusable credential (``auth_error``), connection error/refused, timeout, non-2xx HTTP status, or a malformed (non-JSON) body. A 404 means the session id is not known to the server; a 409 means the session is still receiving data or the id is ambiguous across @@ -642,7 +679,8 @@ def session_summary(self, session_id: str) -> dict[str, Any]: caller can give a clear message. """ url = f"{self._server_url}/sessions/{session_id}/summary" - return _http_get_strict(url, self._auth_headers()) + headers = self._auth_headers(url) + return _http_get_strict(url, headers) def delete_session(self, session_id: str) -> dict[str, Any]: """Delete a session's whole graph from the server (a real, permanent change). @@ -665,7 +703,7 @@ def delete_session(self, session_id: str) -> dict[str, Any]: Raises ------ CIClientError - The request genuinely failed: connection error/refused, timeout, + The request genuinely failed: an unusable credential (``auth_error``), connection error/refused, timeout, non-2xx HTTP status, or a malformed (non-JSON) body. A 404 means the session id is not known to the server; a 409 means the session is still receiving data (not safe to delete yet) or the @@ -673,7 +711,8 @@ def delete_session(self, session_id: str) -> dict[str, Any]: exact number so the caller can give a clear message. """ url = f"{self._server_url}/sessions/{session_id}" - return _http_delete_strict(url, self._auth_headers()) + headers = self._auth_headers(url) + return _http_delete_strict(url, headers) def whoami(self) -> dict[str, Any]: """Resolve the authenticated caller's identity from the server. @@ -691,13 +730,14 @@ def whoami(self) -> dict[str, Any]: Raises ------ CIClientError - The request genuinely failed: connection error/refused, timeout, + The request genuinely failed: an unusable credential (``auth_error``), connection error/refused, timeout, non-2xx HTTP status, or a malformed (non-JSON) body. ``status_code`` carries the exact number so the caller can give a clear message. """ url = f"{self._server_url}/whoami" - return _http_get_strict(url, self._auth_headers()) + headers = self._auth_headers(url) + return _http_get_strict(url, headers) def health_check(self) -> dict[str, Any]: """Check server health by running a simple count query. @@ -768,6 +808,37 @@ def __init__( ) self._timeout: float = timeout + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _auth_headers(self, url: str) -> dict[str, str]: + """Return the ``Authorization`` header dict, computed per-request via strategy. + + Called BEFORE entering a method's request ``try:`` block, so an + unusable credential (empty api_key, the "[REDACTED]" sentinel, or an + unexpanded ${VAR} placeholder) is classified as its own ``auth_error`` + -- it is never sent to the server, and never confused with + ``decode_error`` (a genuine bad-JSON response from a server that was + actually reached). Calling ``self._strategy.headers()`` INSIDE the + request try block was the original bug: a credential ``ValueError`` + would fall through to ``except (ValueError, json.JSONDecodeError)`` + and be misreported as "malformed JSON from {url}" even though no + request was ever sent. + + Raises + ------ + CIClientError + error_type="auth_error" when ``self._strategy.headers()`` raises + ``ValueError`` (unusable credential). + """ + try: + return self._strategy.headers() + except ValueError as exc: + raise CIClientError( + f"unusable credential for {url}: {exc}", error_type="auth_error", url=url + ) from exc + # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------ @@ -801,7 +872,7 @@ async def cypher( Raises ------ CIClientError - The request genuinely failed: connection error/refused, timeout, + The request genuinely failed: an unusable credential (``auth_error``), connection error/refused, timeout, non-2xx HTTP status, or a malformed (non-JSON) response body. A down, slow, or rejecting SELECTED source can never masquerade as an empty success -- see error_type for the classification. @@ -812,9 +883,10 @@ async def cypher( "params": params if params is not None else {}, "workspace": workspace, } + headers = self._auth_headers(url) try: async with httpx.AsyncClient(timeout=self._timeout) as client: # type: ignore[union-attr] - resp = await client.post(url, json=body, headers=self._strategy.headers()) + resp = await client.post(url, json=body, headers=headers) resp.raise_for_status() result = resp.json() except httpx.TimeoutException as exc: # type: ignore[union-attr] @@ -869,13 +941,14 @@ async def fetch_blob(self, session_id: str, key: str) -> Any | None: Raises ------ CIClientError - The request genuinely failed: connection error/refused, timeout, + The request genuinely failed: an unusable credential (``auth_error``), connection error/refused, timeout, non-2xx HTTP status, or a malformed (non-JSON) response body. """ url = f"{self._server_url}/blobs/{session_id}/{key}" + headers = self._auth_headers(url) try: async with httpx.AsyncClient(timeout=self._timeout) as client: # type: ignore[union-attr] - resp = await client.get(url, headers=self._strategy.headers()) + resp = await client.get(url, headers=headers) resp.raise_for_status() return resp.json() except httpx.TimeoutException as exc: # type: ignore[union-attr] @@ -923,16 +996,17 @@ async def list_blob_keys(self, session_id: str) -> set[str]: Raises ------ CIClientError - The request genuinely failed: connection error/refused, timeout, + The request genuinely failed: an unusable credential (``auth_error``), connection error/refused, timeout, non-2xx HTTP status, or a malformed (non-JSON) body. A down / slow / rejecting server can never masquerade as "no blobs" -- see error_type for the classification. Honors ``self._timeout`` like ``cypher()`` / ``fetch_blob()``. """ url = f"{self._server_url}/blobs/{session_id}" + headers = self._auth_headers(url) try: async with httpx.AsyncClient(timeout=self._timeout) as client: # type: ignore[union-attr] - resp = await client.get(url, headers=self._strategy.headers()) + resp = await client.get(url, headers=headers) resp.raise_for_status() result = resp.json() except httpx.TimeoutException as exc: # type: ignore[union-attr] @@ -976,7 +1050,7 @@ async def session_summary(self, session_id: str) -> dict[str, Any]: Raises ------ CIClientError - The request genuinely failed: connection error/refused, timeout, + The request genuinely failed: an unusable credential (``auth_error``), connection error/refused, timeout, non-2xx HTTP status, or a malformed (non-JSON) body. A 404 means the session id is not known to the server; a 409 means the session is still receiving data or the id is ambiguous across @@ -984,9 +1058,10 @@ async def session_summary(self, session_id: str) -> dict[str, Any]: caller can give a clear message. """ url = f"{self._server_url}/sessions/{session_id}/summary" + headers = self._auth_headers(url) try: async with httpx.AsyncClient(timeout=self._timeout) as client: # type: ignore[union-attr] - resp = await client.get(url, headers=self._strategy.headers()) + resp = await client.get(url, headers=headers) resp.raise_for_status() return resp.json() except httpx.TimeoutException as exc: # type: ignore[union-attr] @@ -1028,7 +1103,7 @@ async def delete_session(self, session_id: str) -> dict[str, Any]: Raises ------ CIClientError - The request genuinely failed: connection error/refused, timeout, + The request genuinely failed: an unusable credential (``auth_error``), connection error/refused, timeout, non-2xx HTTP status, or a malformed (non-JSON) body. A 404 means the session id is not known to the server; a 409 means the session is still receiving data (not safe to delete yet) or the @@ -1036,9 +1111,10 @@ async def delete_session(self, session_id: str) -> dict[str, Any]: exact number so the caller can give a clear message. """ url = f"{self._server_url}/sessions/{session_id}" + headers = self._auth_headers(url) try: async with httpx.AsyncClient(timeout=self._timeout) as client: # type: ignore[union-attr] - resp = await client.delete(url, headers=self._strategy.headers()) + resp = await client.delete(url, headers=headers) resp.raise_for_status() return resp.json() except httpx.TimeoutException as exc: # type: ignore[union-attr] @@ -1075,15 +1151,16 @@ async def whoami(self) -> dict[str, Any]: Raises ------ CIClientError - The request genuinely failed: connection error/refused, timeout, + The request genuinely failed: an unusable credential (``auth_error``), connection error/refused, timeout, non-2xx HTTP status, or a malformed (non-JSON) body. ``status_code`` carries the exact number so the caller can give a clear message. Honors ``self._timeout`` like ``session_summary()``. """ url = f"{self._server_url}/whoami" + headers = self._auth_headers(url) try: async with httpx.AsyncClient(timeout=self._timeout) as client: # type: ignore[union-attr] - resp = await client.get(url, headers=self._strategy.headers()) + resp = await client.get(url, headers=headers) resp.raise_for_status() return resp.json() except httpx.TimeoutException as exc: # type: ignore[union-attr] diff --git a/tests/test_client.py b/tests/test_client.py index c7d840b2..183466c4 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -674,6 +674,217 @@ def test_health_check_returns_zero_count_on_empty_result(self): assert result["session_count"] == 0 +class _GenericBehavior: + """Mutable control block read by the handler on every request (any path).""" + + def __init__(self) -> None: + self.status_code: int = 200 + self.body: bytes = b"{}" + self.paths: list[str] = [] # every request path the server was asked for + + +def _make_generic_handler(behavior: "_GenericBehavior"): + from http.server import BaseHTTPRequestHandler + + class _Handler(BaseHTTPRequestHandler): + def _respond(self): + behavior.paths.append(self.path) + self.send_response(behavior.status_code) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(behavior.body) + + def do_GET(self): # noqa: N802 + self._respond() + + def do_DELETE(self): # noqa: N802 + self._respond() + + def log_message(self, *args): # silence server logs + pass + + return _Handler + + +def _start_generic_server(behavior: "_GenericBehavior"): + import threading + from http.server import ThreadingHTTPServer + + port = _find_free_port() + server = ThreadingHTTPServer(("127.0.0.1", port), _make_generic_handler(behavior)) + threading.Thread(target=server.serve_forever, daemon=True).start() + return server, f"http://127.0.0.1:{port}" + + +class TestCIClientErrorClassification: + """error_type classification must be correct: auth_error vs decode_error vs http_status. + + Regression guard: ``ApiKeyAuth.headers()`` raising ``ValueError`` for an + unusable/redacted credential must classify as ``auth_error`` -- it must + NEVER be caught by the SAME ``except (ValueError, json.JSONDecodeError)`` + handler used for a genuinely malformed JSON response, which previously + misreported a healthy 200 response (or, before any request was even sent) + as "malformed JSON from {url}". + """ + + # -- (a) unusable credential -> auth_error, NEVER decode_error ---------- + + @pytest.mark.parametrize("bad_key", ["", "[REDACTED]"]) + def test_session_summary_unusable_credential_is_auth_error(self, bad_key): + from context_intelligence.client import CIClient, CIClientError + + client = CIClient("http://localhost:8000", bad_key) + + with patch("context_intelligence.client._http_get_strict") as mock_get: + with pytest.raises(CIClientError) as excinfo: + client.session_summary("session1") + + assert excinfo.value.error_type == "auth_error" + mock_get.assert_not_called() + + @pytest.mark.parametrize("bad_key", ["", "[REDACTED]"]) + def test_delete_session_unusable_credential_is_auth_error(self, bad_key): + from context_intelligence.client import CIClient, CIClientError + + client = CIClient("http://localhost:8000", bad_key) + + with patch("context_intelligence.client._http_delete_strict") as mock_delete: + with pytest.raises(CIClientError) as excinfo: + client.delete_session("session1") + + assert excinfo.value.error_type == "auth_error" + mock_delete.assert_not_called() + + @pytest.mark.parametrize("bad_key", ["", "[REDACTED]"]) + def test_whoami_unusable_credential_is_auth_error(self, bad_key): + from context_intelligence.client import CIClient, CIClientError + + client = CIClient("http://localhost:8000", bad_key) + + with patch("context_intelligence.client._http_get_strict") as mock_get: + with pytest.raises(CIClientError) as excinfo: + client.whoami() + + assert excinfo.value.error_type == "auth_error" + mock_get.assert_not_called() + + @pytest.mark.parametrize("bad_key", ["", "[REDACTED]"]) + def test_cypher_unusable_credential_is_auth_error(self, bad_key): + from context_intelligence.client import CIClient, CIClientError + + client = CIClient("http://localhost:8000", bad_key) + + with patch("context_intelligence.client._http_post") as mock_post: + with pytest.raises(CIClientError) as excinfo: + client.cypher("MATCH (n) RETURN n") + + assert excinfo.value.error_type == "auth_error" + mock_post.assert_not_called() + + @pytest.mark.parametrize("bad_key", ["", "[REDACTED]"]) + def test_fetch_blob_unusable_credential_is_auth_error(self, bad_key): + from context_intelligence.client import CIClient, CIClientError + + client = CIClient("http://localhost:8000", bad_key) + + with patch("context_intelligence.client._http_get") as mock_get: + with pytest.raises(CIClientError) as excinfo: + client.fetch_blob("session1", "key1") + + assert excinfo.value.error_type == "auth_error" + mock_get.assert_not_called() + + # -- (b) genuinely malformed JSON from a 200 -> decode_error (unchanged) -- + + def test_session_summary_malformed_json_is_decode_error(self): + """A real 200 with a body that fails to parse as JSON must still + classify as decode_error -- proves the auth_error fix left this path alone.""" + from context_intelligence.client import CIClient, CIClientError + + behavior = _GenericBehavior() + behavior.body = b"not json{{{" + server, base_url = _start_generic_server(behavior) + client = CIClient(base_url, "key") + try: + with pytest.raises(CIClientError) as excinfo: + client.session_summary("session1") + finally: + server.shutdown() + server.server_close() + + assert excinfo.value.error_type == "decode_error" + + def test_delete_session_malformed_json_is_decode_error(self): + from context_intelligence.client import CIClient, CIClientError + + behavior = _GenericBehavior() + behavior.body = b"not json{{{" + server, base_url = _start_generic_server(behavior) + client = CIClient(base_url, "key") + try: + with pytest.raises(CIClientError) as excinfo: + client.delete_session("session1") + finally: + server.shutdown() + server.server_close() + + assert excinfo.value.error_type == "decode_error" + + def test_whoami_malformed_json_is_decode_error(self): + from context_intelligence.client import CIClient, CIClientError + + behavior = _GenericBehavior() + behavior.body = b"not json{{{" + server, base_url = _start_generic_server(behavior) + client = CIClient(base_url, "key") + try: + with pytest.raises(CIClientError) as excinfo: + client.whoami() + finally: + server.shutdown() + server.server_close() + + assert excinfo.value.error_type == "decode_error" + + # -- (c) real 401 -> http_status (unchanged) ----------------------------- + + def test_session_summary_401_is_http_status(self): + from context_intelligence.client import CIClient, CIClientError + + behavior = _GenericBehavior() + behavior.status_code = 401 + behavior.body = b'{"detail": "unauthorized"}' + server, base_url = _start_generic_server(behavior) + client = CIClient(base_url, "key") + try: + with pytest.raises(CIClientError) as excinfo: + client.session_summary("session1") + finally: + server.shutdown() + server.server_close() + + assert excinfo.value.error_type == "http_status" + assert excinfo.value.status_code == 401 + + def test_whoami_401_is_http_status(self): + from context_intelligence.client import CIClient, CIClientError + + behavior = _GenericBehavior() + behavior.status_code = 401 + behavior.body = b'{"detail": "unauthorized"}' + server, base_url = _start_generic_server(behavior) + client = CIClient(base_url, "key") + try: + with pytest.raises(CIClientError) as excinfo: + client.whoami() + finally: + server.shutdown() + server.server_close() + + assert excinfo.value.error_type == "http_status" + assert excinfo.value.status_code == 401 + + class TestLogger: """Logger must be named context_intelligence.client.""" @@ -1340,6 +1551,178 @@ async def test_async_health_check_returns_zero_on_empty(self): assert result["session_count"] == 0 +class TestAsyncCIClientErrorClassification: + """error_type classification must be correct: auth_error vs decode_error vs http_status. + + Regression guard: ``self._strategy.headers()`` was previously called INSIDE + each method's request ``try:`` block, so a credential ``ValueError`` (unusable/ + redacted api_key) fell through to the SAME ``except (ValueError, + json.JSONDecodeError)`` handler used for a genuinely malformed JSON response + and was misreported as "malformed JSON from {url}" -- even though no request + was ever sent. ``_auth_headers()`` now computes headers BEFORE the try block + and classifies a credential failure as its own ``auth_error``. + """ + + # -- (a) unusable credential -> auth_error, NEVER decode_error ---------- + + @pytest.mark.parametrize("bad_key", ["", "[REDACTED]"]) + async def test_async_session_summary_unusable_credential_is_auth_error(self, bad_key): + from context_intelligence.client import AsyncCIClient, CIClientError + + client = AsyncCIClient("http://localhost:8000", bad_key) + + with patch("context_intelligence.client.httpx.AsyncClient") as mock_async_client: + with pytest.raises(CIClientError) as excinfo: + await client.session_summary("session1") + + assert excinfo.value.error_type == "auth_error" + mock_async_client.assert_not_called() + + @pytest.mark.parametrize("bad_key", ["", "[REDACTED]"]) + async def test_async_delete_session_unusable_credential_is_auth_error(self, bad_key): + from context_intelligence.client import AsyncCIClient, CIClientError + + client = AsyncCIClient("http://localhost:8000", bad_key) + + with patch("context_intelligence.client.httpx.AsyncClient") as mock_async_client: + with pytest.raises(CIClientError) as excinfo: + await client.delete_session("session1") + + assert excinfo.value.error_type == "auth_error" + mock_async_client.assert_not_called() + + @pytest.mark.parametrize("bad_key", ["", "[REDACTED]"]) + async def test_async_whoami_unusable_credential_is_auth_error(self, bad_key): + from context_intelligence.client import AsyncCIClient, CIClientError + + client = AsyncCIClient("http://localhost:8000", bad_key) + + with patch("context_intelligence.client.httpx.AsyncClient") as mock_async_client: + with pytest.raises(CIClientError) as excinfo: + await client.whoami() + + assert excinfo.value.error_type == "auth_error" + mock_async_client.assert_not_called() + + @pytest.mark.parametrize("bad_key", ["", "[REDACTED]"]) + async def test_async_cypher_unusable_credential_is_auth_error(self, bad_key): + from context_intelligence.client import AsyncCIClient, CIClientError + + client = AsyncCIClient("http://localhost:8000", bad_key) + + with patch("context_intelligence.client.httpx.AsyncClient") as mock_async_client: + with pytest.raises(CIClientError) as excinfo: + await client.cypher("MATCH (n) RETURN n") + + assert excinfo.value.error_type == "auth_error" + mock_async_client.assert_not_called() + + @pytest.mark.parametrize("bad_key", ["", "[REDACTED]"]) + async def test_async_fetch_blob_unusable_credential_is_auth_error(self, bad_key): + from context_intelligence.client import AsyncCIClient, CIClientError + + client = AsyncCIClient("http://localhost:8000", bad_key) + + with patch("context_intelligence.client.httpx.AsyncClient") as mock_async_client: + with pytest.raises(CIClientError) as excinfo: + await client.fetch_blob("session1", "key1") + + assert excinfo.value.error_type == "auth_error" + mock_async_client.assert_not_called() + + # -- (b) genuinely malformed JSON from a 200 -> decode_error (unchanged) -- + + async def test_async_session_summary_malformed_json_is_decode_error(self): + from context_intelligence.client import AsyncCIClient, CIClientError + + mock_resp = _make_async_mock_response(None) + mock_resp.json.side_effect = ValueError("Expecting value") + mock_http = _make_async_httpx_client(mock_resp) + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "testkey") + with pytest.raises(CIClientError) as excinfo: + await client.session_summary("session1") + + assert excinfo.value.error_type == "decode_error" + + async def test_async_delete_session_malformed_json_is_decode_error(self): + from context_intelligence.client import AsyncCIClient, CIClientError + + mock_resp = _make_async_mock_response(None) + mock_resp.json.side_effect = ValueError("Expecting value") + mock_http = _make_async_httpx_client(mock_resp) + mock_inner_client = mock_http.__aenter__.return_value + mock_inner_client.delete = AsyncMock(return_value=mock_resp) + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "testkey") + with pytest.raises(CIClientError) as excinfo: + await client.delete_session("session1") + + assert excinfo.value.error_type == "decode_error" + + async def test_async_whoami_malformed_json_is_decode_error(self): + from context_intelligence.client import AsyncCIClient, CIClientError + + mock_resp = _make_async_mock_response(None) + mock_resp.json.side_effect = ValueError("Expecting value") + mock_http = _make_async_httpx_client(mock_resp) + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "testkey") + with pytest.raises(CIClientError) as excinfo: + await client.whoami() + + assert excinfo.value.error_type == "decode_error" + + # -- (c) real 401 -> http_status (unchanged) ----------------------------- + + async def test_async_session_summary_401_is_http_status(self): + import httpx + + from context_intelligence.client import AsyncCIClient, CIClientError + + request = httpx.Request("GET", "http://localhost:8000/sessions/session1/summary") + real_response = httpx.Response(status_code=401, request=request) + mock_resp = MagicMock() + mock_resp.status_code = 401 + mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError( + "401", request=request, response=real_response + ) + mock_http = _make_async_httpx_client(mock_resp) + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "testkey") + with pytest.raises(CIClientError) as excinfo: + await client.session_summary("session1") + + assert excinfo.value.error_type == "http_status" + assert excinfo.value.status_code == 401 + + async def test_async_whoami_401_is_http_status(self): + import httpx + + from context_intelligence.client import AsyncCIClient, CIClientError + + request = httpx.Request("GET", "http://localhost:8000/whoami") + real_response = httpx.Response(status_code=401, request=request) + mock_resp = MagicMock() + mock_resp.status_code = 401 + mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError( + "401", request=request, response=real_response + ) + mock_http = _make_async_httpx_client(mock_resp) + + with patch("context_intelligence.client.httpx.AsyncClient", return_value=mock_http): + client = AsyncCIClient("http://localhost:8000", "testkey") + with pytest.raises(CIClientError) as excinfo: + await client.whoami() + + assert excinfo.value.error_type == "http_status" + assert excinfo.value.status_code == 401 + + # --------------------------------------------------------------------------- # REAL-SOCKET fail-loud tests for list_blob_keys (sync + async) # From 1aaab0b3a82a7929a4e0c73557e704bee264ddd4 Mon Sep 17 00:00:00 2001 From: colombod Date: Wed, 2 Sep 2026 10:52:32 +0000 Subject: [PATCH 14/39] refactor(server-data-ops): self-built narrative, explicit direct-interaction rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two decisions from a real Digital Twin run: 1. The candidate narrative (Flow 2 Summary line) is now built by the server-data-ops agent itself via graph_query against the session's root prompts, instead of delegating to graph-analyst. The narrative is a small, focused synthesis task that doesn't need graph-analyst's full data-navigation surface, and a nested delegate would lose content the same way the root session loses a sub-agent's internal work. The hard rule is unchanged: synthesized overview in the agent's own words, never a raw/verbatim prompt quote, never a from-memory guess, "not available" if graph_query has nothing usable. `delegate` stays in the tools list for other uses but is no longer used for the narrative. 2. Added an explicit rule (agent body + one mirrored line in the skill intro) that this is a direct, interactive conversation: the session-details preview, the folder-exclusion offer, the impact statement, and the confirmation request must always appear in the agent's own visible, user-facing turn -- never assumed to be relayed by another agent. Flow 1's exclusion offer and Flow 3's ownership warning are reworded to make explicit that the agent shows these to the user and waits for a response, rather than treating them as internal notes. No other scope changes. Untouched: current-session resolution from the runtime Session ID + session_summary proof, the front-loaded folder-exclusion offer, all-servers completeness, preview/confirm gate, whoami ownership compare, 404/409 handling, tools-only access. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- agents/server-data-ops.md | 41 ++++++++++----- .../SKILL.md | 51 ++++++++++--------- 2 files changed, 54 insertions(+), 38 deletions(-) diff --git a/agents/server-data-ops.md b/agents/server-data-ops.md index 319ffdf7..e203675b 100644 --- a/agents/server-data-ops.md +++ b/agents/server-data-ops.md @@ -37,6 +37,14 @@ tools: > explicit warning, someone else's — Context Intelligence session data from a server. You > never delete without a preview and an explicit confirmation. +**This is a direct, interactive conversation with the user.** Every decision-critical +thing — the session-details preview, the folder-exclusion offer, the impact statement, +and the confirmation request — is presented to the USER in your own visible turn and +the user responds before you proceed. Never assume another agent will relay your +output: put the full offer, impact, and confirmation prompt in your own user-facing +message. You are not a fire-and-forget delegate; you hold a turn-by-turn conversation +until the user confirms or cancels. + ## Role Drive preview → confirm → delete for Context Intelligence session data, across three flows: @@ -50,8 +58,10 @@ the narrative. graph on a server. - `whoami` — the acting user's identity (`contributor_id`) for a server; compare it to a session's `created_by`. -- `graph_query` — find candidate sessions by description. -- `delegate` — hand narrative-building to `graph-analyst`. +- `graph_query` — find candidate sessions by description, and read a session's root + prompts to build the narrative summary yourself. +- `delegate` — available if needed elsewhere; not used for the narrative summary (you + build that yourself from `graph_query`). - `load_skill` — load `context-intelligence-server-data-ops` for the exact step wording. No filesystem or bash tool, by design. @@ -82,10 +92,12 @@ No filesystem or bash tool, by design. `created_by` (confirm you are the owner via `whoami`), `working_dir`, and `last_change` (flag if under a minute — may still be live). If it 404s on every server, stop and say it is not on the server(s). -3. **Offer the folder exclusion** — always, before impact/confirm/delete. Show the setting +3. **Offer the folder exclusion to the user directly, and wait for their answer** — + always, before impact/confirm/delete. Show the setting `overrides.hook-context-intelligence.config.destinations..exclude` in - `~/.amplifier/settings.yaml` (a gitignore-style pattern matched on `working_dir`) and - offer to guide them through applying it. You never edit the file yourself. + `~/.amplifier/settings.yaml` (a gitignore-style pattern matched on `working_dir`) in + your own user-facing message, and offer to guide them through applying it. You never + edit the file yourself. 4. **Impact.** State it (see "Impact + permanence"). 5. **Confirm.** Explicit, naming the id and server(s). 6. **Delete and verify on every server** (all-servers completeness). @@ -93,11 +105,13 @@ No filesystem or bash tool, by design. ## Flow 2 — find a session by description, then delete 1. **Find.** Use `graph_query` to narrow candidates by topic, date, or workspace. -2. **Narrate.** For each candidate, `delegate` to `graph-analyst` for a short overview - built from that session's **root** prompts only. The details block's Summary line comes - from this delegation — never a raw prompt quote; if graph-analyst cannot, write "not - available." Do this before presenting any details block. -3. **Present** the candidate details block(s); the user picks one. +2. **Narrate.** Build the short overview yourself: call `graph_query` to read that + session's **root**-session prompts only (never subsessions), then write a short + synthesized overview, in your own words, of what the session was about — its scope + and intent. This must be a synthesis, never a raw or verbatim prompt quote, and never + a from-memory guess. If `graph_query` returns nothing usable for the root prompts, + write "not available." Do this before presenting any details block. +3. **Present** the candidate details block(s) to the user directly; the user picks one. 4. **Ownership** — run the Flow 3 check. 5. **Preview → confirm → delete and verify on every server.** @@ -105,10 +119,11 @@ No filesystem or bash tool, by design. Call `whoami` for the session's server and compare `contributor_id` to `created_by`: -- **Different** → warn plainly that it is not theirs, and require a second explicit - confirmation before deleting. +- **Different** → tell the user directly, in your own visible message, that it is not + theirs, and wait for a second explicit confirmation before deleting. - **Same** → their own session; no warning, proceed normally. -- **Null `contributor_id`** → you cannot confirm ownership; ask the user rather than assume. +- **Null `contributor_id`** → you cannot confirm ownership; ask the user directly and + wait for their answer rather than assume. --- diff --git a/skills/context-intelligence-server-data-ops/SKILL.md b/skills/context-intelligence-server-data-ops/SKILL.md index e6157656..619b56a2 100644 --- a/skills/context-intelligence-server-data-ops/SKILL.md +++ b/skills/context-intelligence-server-data-ops/SKILL.md @@ -10,6 +10,10 @@ license: MIT Step-by-step procedure for deleting Context Intelligence session data from a server, so the three delete flows run the same way every time instead of being improvised fresh. +**This is a direct, interactive conversation with the user** — every decision-critical +step (preview, offer, impact, confirmation) is shown to them directly, in your own +message, never summarized away by delegation. + ## When to Use Load whenever the `server-data-ops` agent is about to run a delete flow: the current @@ -79,25 +83,18 @@ Session Working dir: Server: () Still live: <"yes — cannot delete yet" if not deletable, else "no"> - Summary: + Summary: ``` Fill every field from a real `session_summary` call. Never fabricate a value you didn't receive. -**Summary line.** Not returned by the server — build it by delegating: - -``` -Delegate to: graph-analyst -Task: Give me a high-level overview of the work in session — what was done, its -scope and intent — built from that session's own (root) prompts only. Do not dive into -any of its subsessions. -``` - -Root-prompts-only keeps it fast and focused on top-level intent. Fold the returned -narrative into the Summary line. Never write a raw prompt quote (or write one from -memory) as the summary — always delegate first, and write "not available" only if -that delegation itself produces nothing. +**Summary line.** Not returned by the server — build it yourself. Call `graph_query` +to read that session's **root**-session prompts only (never subsessions), then write a +short synthesized overview, in your own words, of what the session was about — its +scope and intent. This must be a synthesis, never a raw or verbatim prompt quote, and +never a from-memory guess. If `graph_query` returns nothing usable for the root +prompts, write "not available." --- @@ -116,14 +113,16 @@ some *other* session. `working_dir`, and `last_change` (flag if under a minute old — "may still be live"). Note every server the session exists on, from `list_sources`. If it 404s on every server, stop and tell the user plainly — never delete against an unresolved session. -3. **Offer the folder exclusion**, before anything else proceeds. Show the setting +3. **Offer the folder exclusion to the user directly, and wait for their answer**, + before anything else proceeds. Show the setting `overrides.hook-context-intelligence.config.destinations..exclude` in `~/.amplifier/settings.yaml` — a list of gitignore-style patterns matched against a session's `working_dir`; adding one for the current folder stops that destination being selected for future sessions there. The agent has no filesystem tool, so it - shows the setting and offers to guide the user through applying it — never edits - the file itself. Make this offer every time, regardless of whether you can confirm - the folder is currently included. Confirm whether they applied it, then move on. + shows the setting in its own message and offers to guide the user through applying + it — never edits the file itself. Make this offer every time, regardless of whether + you can confirm the folder is currently included. Wait for their answer, confirm + whether they applied it, then move on. 4. **State the impact.** The whole graph — the session plus every descendant (forks, sub-sessions, delegated children) — plus its blobs and queue records, permanently, on the server(s) found in step 2. Shared nodes are kept; there is no undo. @@ -141,10 +140,10 @@ some *other* session. on graph `working_dir` — it isn't reliably populated (see `context-intelligence-graph-query`); the working dir you show the user comes from `session_summary`. Cap the candidate set to a handful before per-candidate work. -3. For each candidate, call `session_summary` for the facts, then delegate to - `graph-analyst` for the narrative (see "Summary line" above) — every candidate gets - either a real narrative or an explicit "not available" before it's presented; never - a summary written from memory or a prompt quote. +3. For each candidate, call `session_summary` for the facts, then build the narrative + yourself (see "Summary line" above) — every candidate gets either a real synthesized + narrative or an explicit "not available" before it's presented; never a summary + written from memory or a prompt quote. 4. Build a Session Details Block per candidate and present them; the user picks one. 5. Run the Flow 3 ownership check on the chosen session. 6. Re-run `session_summary` on the chosen id right before delete (a fresh preview, in @@ -162,10 +161,12 @@ Runs inside Flow 1 or Flow 2, after the preview and before the delete confirmati 2. Call `whoami` for the *same server* the session is on. Read its `contributor_id`. 3. Compare: - **Match** → the user's own session. No warning; continue normally. - - **Differ** → state plainly it was created by ``, not them, and get a - *separate*, explicit confirmation before deleting. + - **Differ** → tell the user directly, in your own visible message, that it was + created by ``, not them, and wait for a *separate*, explicit + confirmation before deleting. - **`contributor_id` is null** (auth disabled or unresolvable) → say you can't - confirm ownership and ask the user directly. Don't treat null as a mismatch. + confirm ownership and ask the user directly, waiting for their answer. Don't treat + null as a mismatch. Never skip step 2 — warning from `created_by` alone, without resolving the acting user via `whoami` first, produces false "not created by you" warnings on the user's own From 452731dbc07fc79f380c46438b7bb0361eff2733 Mon Sep 17 00:00:00 2001 From: colombod Date: Wed, 2 Sep 2026 10:56:07 +0000 Subject: [PATCH 15/39] refactor(server-data-ops): restore graph-analyst for Flow 2 search, keep self-built narrative MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior commit (f287bde) went too far: it dropped graph-analyst entirely, having this agent run graph_query directly for both finding sessions and building the narrative. The user clarified the correct split: - SEARCH (Flow 2 step 1): finding a session by topic/content/date/criteria genuinely needs graph-analyst's data-navigation skills. Delegate to it — a data-fetch delegation, not a hand-off of the conversation. Candidate results (ids + key facts) come back to this agent, which keeps driving the flow. Trivial direct lookups (user names an exact session id) skip the delegation and call session_summary directly. - NARRATIVE (Flow 2 step 2): building a short root-prompt overview of one already-identified session does not need graph-analyst's full surface. This stays exactly as f287bde left it — the agent builds it itself via graph_query against root-session prompts only, synthesized in its own words, "not available" if nothing usable comes back. Added an explicit "never delegate this step to graph-analyst" line in both files so the two are not re-merged again by accident. Tools list updated to match: `delegate` is for search delegation only, `graph_query` is for the narrative and for direct lookups. No other scope changes. Untouched: Flow 1 (resolve + prove + mandatory exclusion offer), Flow 3 (whoami ownership), all-servers completeness, preview/confirm gate, 404/409 handling, tools-only access, the Change-2 direct-interaction rule. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- agents/server-data-ops.md | 20 ++++++++++++----- .../SKILL.md | 22 +++++++++++-------- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/agents/server-data-ops.md b/agents/server-data-ops.md index e203675b..caa8fac1 100644 --- a/agents/server-data-ops.md +++ b/agents/server-data-ops.md @@ -58,10 +58,10 @@ the narrative. graph on a server. - `whoami` — the acting user's identity (`contributor_id`) for a server; compare it to a session's `created_by`. -- `graph_query` — find candidate sessions by description, and read a session's root - prompts to build the narrative summary yourself. -- `delegate` — available if needed elsewhere; not used for the narrative summary (you - build that yourself from `graph_query`). +- `delegate` — used to delegate session search to `graph-analyst` (it has the + data-navigation skills); never used for the narrative summary. +- `graph_query` — used to read a found session's root prompts to build the narrative + yourself, and for direct lookups. - `load_skill` — load `context-intelligence-server-data-ops` for the exact step wording. No filesystem or bash tool, by design. @@ -104,13 +104,21 @@ No filesystem or bash tool, by design. ## Flow 2 — find a session by description, then delete -1. **Find.** Use `graph_query` to narrow candidates by topic, date, or workspace. +1. **Search.** If the user describes the session by topic, content, date, or any other + non-trivial criteria ("the session about X", "sessions that discussed Y", "my + session from last week about Z"), delegate to `graph-analyst` to run the search — it + has the data-navigation skills to query the graph, and returns the candidate + session(s): their ids and key facts. This is a data-fetch delegation, not a hand-off + of the conversation — the results come back to you and you keep driving the flow. + Skip the delegation only for a trivial direct lookup — the user names an exact + session id — and call `session_summary` on it directly instead. 2. **Narrate.** Build the short overview yourself: call `graph_query` to read that session's **root**-session prompts only (never subsessions), then write a short synthesized overview, in your own words, of what the session was about — its scope and intent. This must be a synthesis, never a raw or verbatim prompt quote, and never a from-memory guess. If `graph_query` returns nothing usable for the root prompts, - write "not available." Do this before presenting any details block. + write "not available." Never delegate this step to `graph-analyst`. Do this before + presenting any details block. 3. **Present** the candidate details block(s) to the user directly; the user picks one. 4. **Ownership** — run the Flow 3 check. 5. **Preview → confirm → delete and verify on every server.** diff --git a/skills/context-intelligence-server-data-ops/SKILL.md b/skills/context-intelligence-server-data-ops/SKILL.md index 619b56a2..040e54d5 100644 --- a/skills/context-intelligence-server-data-ops/SKILL.md +++ b/skills/context-intelligence-server-data-ops/SKILL.md @@ -134,16 +134,20 @@ some *other* session. ## Flow 2 — Find a Session by Description, Then Delete -1. Take the user's description (topic, date range, sometimes a server/workspace). -2. Narrow candidates with `graph_query`. Scope on `Session` node fields `workspace`, - `created_by`, `started_at`/`last_updated` (wrap dates in `datetime()`). Don't filter - on graph `working_dir` — it isn't reliably populated (see - `context-intelligence-graph-query`); the working dir you show the user comes from - `session_summary`. Cap the candidate set to a handful before per-candidate work. +1. Take the user's description (topic, content, date range, sometimes a + server/workspace). +2. **Search.** For any non-trivial criteria (topic, content, date, workspace), + delegate to `graph-analyst` — it has the data-navigation skills to query the graph + and returns the candidate session(s): their ids and key facts. This is a data-fetch + delegation, not a hand-off of the conversation — the results come back to you and + you keep driving the flow. Skip the delegation only for a trivial direct lookup — + the user names an exact session id — and call `session_summary` on it directly. + Cap the candidate set to a handful before per-candidate work. 3. For each candidate, call `session_summary` for the facts, then build the narrative - yourself (see "Summary line" above) — every candidate gets either a real synthesized - narrative or an explicit "not available" before it's presented; never a summary - written from memory or a prompt quote. + yourself (see "Summary line" above; never delegate this part to `graph-analyst`) — + every candidate gets either a real synthesized narrative or an explicit "not + available" before it's presented; never a summary written from memory or a prompt + quote. 4. Build a Session Details Block per candidate and present them; the user picks one. 5. Run the Flow 3 ownership check on the chosen session. 6. Re-run `session_summary` on the chosen id right before delete (a fresh preview, in From 457f4272199cd1d9b7971491c494bb9d2fa097b2 Mon Sep 17 00:00:00 2001 From: colombod Date: Wed, 2 Sep 2026 11:11:04 +0000 Subject: [PATCH 16/39] =?UTF-8?q?feat(server-data-ops):=20add=20Flow=201-f?= =?UTF-8?q?older=20=E2=80=94=20clean=20up=20everything=20from=20this=20wor?= =?UTF-8?q?king=20directory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a second S1 flavor to the delete agent: instead of just the current session, find and delete every root session pushed from the current working directory, in this order — exclusion first, then find (delegate to graph-analyst, all-servers), then propose (todo list, one item per session), then delete all (normal preview -> impact -> confirm -> delete -> verify per session, ownership check still applies). - agents/server-data-ops.md: new "Flow 1-folder" section, updated description/Role/Tools for four flows, added tool-todo to frontmatter tools (agent-scoped, not added to a shared behavior). - skills/context-intelligence-server-data-ops/SKILL.md: matching "Flow 1-folder" section with exact step wording, a "Current working directory" key concept, and disambiguated the Flow 1 trigger phrases ("this session") from the Flow 1-folder ones ("this working directory") so a folder-wide request no longer gets misrouted to the single-session flow. Flow 2, Flow 3, all-servers completeness, preview/confirm, 404/409, and the tools-only rule are unchanged. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- agents/server-data-ops.md | 41 +++++++++-- .../SKILL.md | 70 ++++++++++++++++--- 2 files changed, 95 insertions(+), 16 deletions(-) diff --git a/agents/server-data-ops.md b/agents/server-data-ops.md index caa8fac1..bc37be21 100644 --- a/agents/server-data-ops.md +++ b/agents/server-data-ops.md @@ -8,10 +8,11 @@ meta: description: | MUST be used whenever a user wants to delete Context Intelligence session data from a server. Drives find -> preview -> confirm -> delete, always shows what would be removed before removing it, and warns plainly when a session was not created by the current user. - Handles three situations: deleting the current session's own data, finding a session by description (topic, date, server) and then deleting it, and deleting a session someone else created (with an explicit ownership warning). Aware of multiple configured servers and will ask which one to use when more than one applies. + Handles four situations: deleting the current session's own data, cleaning up every session pushed from the current working directory, finding a session by description (topic, date, server) and then deleting it, and deleting a session someone else created (with an explicit ownership warning). Aware of multiple configured servers and will ask which one to use when more than one applies. Use this agent when: - The user asks to delete, remove, or clear their own session data from context-intelligence + - The user is worried that data from sessions in this working directory got pushed and wants it all found and removed - The user describes a session by topic, date, or workspace and asks it to be removed - The user wants to remove someone else's session data and understands they need to confirm that explicitly @@ -29,6 +30,8 @@ tools: config: skills: - "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=skills" + - module: tool-todo + source: git+https://github.com/microsoft/amplifier-module-tool-todo@main --- # Server Data Ops @@ -47,10 +50,10 @@ until the user confirms or cancels. ## Role -Drive preview → confirm → delete for Context Intelligence session data, across three flows: -delete the current session, find a session by description then delete it, and delete -someone else's session. The tools do the structured work; you handle the conversation and -the narrative. +Drive preview → confirm → delete for Context Intelligence session data, across four flows: +delete the current session, clean up every session pushed from the current working +directory, find a session by description then delete it, and delete someone else's +session. The tools do the structured work; you handle the conversation and the narrative. ## Tools @@ -62,6 +65,8 @@ the narrative. data-navigation skills); never used for the narrative summary. - `graph_query` — used to read a found session's root prompts to build the narrative yourself, and for direct lookups. +- `todo` — track a bulk cleanup (Flow 1-folder) one item per session, so a multi-session + delete never silently skips one. - `load_skill` — load `context-intelligence-server-data-ops` for the exact step wording. No filesystem or bash tool, by design. @@ -102,6 +107,32 @@ No filesystem or bash tool, by design. 5. **Confirm.** Explicit, naming the id and server(s). 6. **Delete and verify on every server** (all-servers completeness). +## Flow 1-folder — clean up everything pushed from this working directory + +For a user worried that data from sessions in THIS folder was pushed and should not +have been — "I think I uploaded data from sessions in this folder, I want to delete +it," "things from this working directory should never have been pushed." Still the +user's own data, but every root session that ran here, not just the current one. + +1. **Resolve the working directory.** Take it from the `Working directory` field in + your status context, the same way Flow 1 resolves `Session ID`. Don't ask for a path. +2. **Offer the folder exclusion first, and wait for the user's answer**, before + finding or deleting anything (same setting as Flow 1 step 3). While the folder is + still in scope, continued ingestion would keep re-creating the data you are about + to delete. Confirm it is applied before moving on. +3. **Find every root session from this folder.** Delegate to `graph-analyst` to + enumerate every ROOT session (never subsessions) whose `working_dir` matches, + across every configured server (all-servers completeness applies to the search too). +4. **Propose the list.** Present each found root session to the user directly as a + session-details block (with its own synthesized summary, per Flow 2's rule), and + build a todo list — one item per session — so every one is tracked and none is + silently missed. +5. **Delete all.** Walk the todo list. For each session, run the normal + preview → impact → explicit confirm → delete → verify on every server it is on + (all-servers completeness). The Flow 3 ownership check still applies per session — + a session in this folder not created by the user still gets the not-yours warning. + Mark each todo item done only once its delete is verified. + ## Flow 2 — find a session by description, then delete 1. **Search.** If the user describes the session by topic, content, date, or any other diff --git a/skills/context-intelligence-server-data-ops/SKILL.md b/skills/context-intelligence-server-data-ops/SKILL.md index 040e54d5..ec5c1c4c 100644 --- a/skills/context-intelligence-server-data-ops/SKILL.md +++ b/skills/context-intelligence-server-data-ops/SKILL.md @@ -1,14 +1,14 @@ --- name: context-intelligence-server-data-ops version: 1.0.0 -description: Exact step order, wording, and the "session details" block format for the three delete flows the server-data-ops agent drives — delete the current session, find-then-delete a session by description, and delete a session someone else created. +description: Exact step order, wording, and the "session details" block format for the delete flows the server-data-ops agent drives — delete the current session, clean up every session from the current working directory, find-then-delete a session by description, and delete a session someone else created. license: MIT --- # Context Intelligence Server Data Ops Step-by-step procedure for deleting Context Intelligence session data from a server, so -the three delete flows run the same way every time instead of being improvised fresh. +the delete flows run the same way every time instead of being improvised fresh. **This is a direct, interactive conversation with the user** — every decision-critical step (preview, offer, impact, confirmation) is shown to them directly, in your own @@ -17,7 +17,8 @@ message, never summarized away by delegation. ## When to Use Load whenever the `server-data-ops` agent is about to run a delete flow: the current -session, a session found by description, or a session created by someone else. +session, every session from the current working directory, a session found by +description, or a session created by someone else. ## When NOT to Use @@ -55,15 +56,24 @@ All three accept `source` (name a server) and `list_sources: true` (discover the connectable set without acting). None takes a workspace — you always address a session by id. +- **`todo`** — the standard todo-list tool. Used only in Flow 1-folder, one item per + candidate root session found, so a bulk cleanup never silently skips one. + --- ## Key Concepts **Current session id.** Comes from Amplifier's own runtime context — the `Session ID` field injected into your status context every turn. For any "this session" / "my -current session" / "this working directory" request, that value IS the session to -act on. Don't ask the user for an id, and a typed id never replaces it — resolve from -context regardless. Only ask directly if context genuinely has no `Session ID`. +current session" request, that value IS the session to act on. Don't ask the user +for an id, and a typed id never replaces it — resolve from context regardless. Only +ask directly if context genuinely has no `Session ID`. + +**Current working directory (for Flow 1-folder).** Comes from the same runtime +context — the `Working directory` field injected into your status context every +turn, resolved the same way as the session id above. For any "this folder" / "this +working directory" / "uploaded from here" request, that value is the directory to +search — not a single session id. Don't ask unless context genuinely has none. **Current user identity (for ownership).** Never read from context and never guess. Call `whoami` for the *same server* the session in question is on, and use its @@ -101,10 +111,12 @@ prompts, write "not available." ## Flow 1 — Delete the Current Session Applies whenever the request refers to the user's own current session ("my current -session," "this session," "this working directory," "the session I'm in") — even if -the user also supplies a session id; a supplied id doesn't downgrade it out of Flow 1 -or replace the runtime id. Route to Flow 2 only when the request names or searches for -some *other* session. +session," "this session," "the session I'm in") — even if the user also supplies a +session id; a supplied id doesn't downgrade it out of Flow 1 or replace the runtime +id. Route to **Flow 1-folder** instead when the request is about the whole working +directory, not just the current session ("this folder," "this working directory," +"uploaded from here"). Route to Flow 2 when the request names or searches for some +*other* session. 1. **Resolve.** Take the current session id from runtime context (see Key Concepts). Don't ask the user for an id. @@ -132,6 +144,41 @@ some *other* session. --- +## Flow 1-folder — Clean Up Everything Pushed From This Working Directory + +Applies when the user is worried that data from sessions run in THIS folder was +pushed and should not have been — "I think I uploaded data from sessions in this +folder, I want to delete it," "things from this working directory should never have +been pushed." Still the user's own data (S1), but scoped to every root session that +ran here, not just the current one. Route here instead of Flow 1 when the request is +about the *folder*, not a single session. + +1. **Resolve the working directory.** Take it from runtime context (see Key + Concepts), the same way Flow 1 resolves the current session id. Don't ask the + user for a path. +2. **Offer the folder exclusion FIRST, and wait for their answer**, before finding + or deleting anything — same setting as Flow 1 step 3: + `overrides.hook-context-intelligence.config.destinations..exclude` in + `~/.amplifier/settings.yaml`. Show it in your own message and offer to guide the + user through applying it — you never edit the file yourself. Confirm it's applied + before moving on: while the folder is still in scope, continued ingestion would + keep re-creating the very data you are about to delete. +3. **Find.** Delegate to `graph-analyst` to enumerate every **root** session (never + subsessions) whose `working_dir` matches the resolved directory, checking every + configured server (all-servers completeness applies to the search too, not only + the deletes). It returns the candidate root sessions and their key facts. +4. **Propose.** For each candidate, build the narrative the same way Flow 2 does + (see "Summary line" above), present it as a Session Details Block, and add one + item to a todo list (the `todo` tool) per session found — so every session is + tracked and none is silently skipped. +5. **Delete all.** Walk the todo list one session at a time. For each: re-run + `session_summary` (a fresh preview), run the Flow 3 ownership check (a session in + this folder may not be the user's own), state the impact, confirm explicitly, + delete, and verify on every server it is on (see Multi-Server Handling) — then + mark that todo item done. Never mark an item done before its delete is verified. + +--- + ## Flow 2 — Find a Session by Description, Then Delete 1. Take the user's description (topic, content, date range, sometimes a @@ -159,7 +206,8 @@ some *other* session. ## Flow 3 — Ownership Check -Runs inside Flow 1 or Flow 2, after the preview and before the delete confirmation. +Runs inside Flow 1, Flow 1-folder, or Flow 2, after the preview and before the delete +confirmation. 1. From the preview, note the session's `created_by`. 2. Call `whoami` for the *same server* the session is on. Read its `contributor_id`. From 4726d6365b25ef9b80fac2b24cefe913b0126b1a Mon Sep 17 00:00:00 2001 From: colombod Date: Wed, 2 Sep 2026 11:17:11 +0000 Subject: [PATCH 17/39] fix: reclassify folder-cleanup flow as Flow 2 variation, not Flow 1 flavor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flow 1-folder (added in b551839) was named as if it were a variant of Flow 1 (delete the current session). That's the wrong classification: Flow 1's defining trait is the CURRENT session resolved from runtime; this flow finds sessions by CRITERIA (working_dir = this folder AND created_by = me), which is exactly Flow 2's find-by-criteria shape. The only thing it borrows from Flow 1 is the folder-exclusion offer, and only because it's the user's own folder being pushed. Renamed to "Flow 2-folder — clean up everything pushed from this working directory (folder + mine)" and moved it to sit after Flow 2 (as a variation of it) instead of after Flow 1, in both the agent and its companion skill. Added a one-line classification note up front, and reframed step 2 explicitly as "the S2 search, by criteria." Kept the approved step order: apply the folder exclusion first, then run the S2 search, then propose the list via todo, then delete all with per-session preview -> impact -> confirm -> delete -> verify and ownership check. Updated all cross-references (todo-tool bullet, Key Concepts heading, Flow 1's routing text, Flow 3's "runs inside" list) from Flow 1-folder to Flow 2-folder. Trigger routing is otherwise unchanged: "this session"/"my current session" -> Flow 1; "this folder"/"this working directory"/"uploaded from here" -> Flow 2-folder; topic/date/server description -> plain Flow 2. Frontmatter, direct-interaction rule, all-servers completeness, self-built narrative rule, Flow 1, Flow 3, 404/409 handling, and the tools-only rule are all unchanged. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- agents/server-data-ops.md | 66 +++++++------- .../SKILL.md | 87 ++++++++++--------- 2 files changed, 84 insertions(+), 69 deletions(-) diff --git a/agents/server-data-ops.md b/agents/server-data-ops.md index bc37be21..e1a3af44 100644 --- a/agents/server-data-ops.md +++ b/agents/server-data-ops.md @@ -51,9 +51,10 @@ until the user confirms or cancels. ## Role Drive preview → confirm → delete for Context Intelligence session data, across four flows: -delete the current session, clean up every session pushed from the current working -directory, find a session by description then delete it, and delete someone else's -session. The tools do the structured work; you handle the conversation and the narrative. +delete the current session (Flow 1), find a session by description then delete it +(Flow 2), clean up every session pushed from the current working directory as a Flow 2 +variation (Flow 2-folder), and delete someone else's session (Flow 3). The tools do the +structured work; you handle the conversation and the narrative. ## Tools @@ -65,7 +66,7 @@ session. The tools do the structured work; you handle the conversation and the n data-navigation skills); never used for the narrative summary. - `graph_query` — used to read a found session's root prompts to build the narrative yourself, and for direct lookups. -- `todo` — track a bulk cleanup (Flow 1-folder) one item per session, so a multi-session +- `todo` — track a bulk cleanup (Flow 2-folder) one item per session, so a multi-session delete never silently skips one. - `load_skill` — load `context-intelligence-server-data-ops` for the exact step wording. @@ -107,32 +108,6 @@ No filesystem or bash tool, by design. 5. **Confirm.** Explicit, naming the id and server(s). 6. **Delete and verify on every server** (all-servers completeness). -## Flow 1-folder — clean up everything pushed from this working directory - -For a user worried that data from sessions in THIS folder was pushed and should not -have been — "I think I uploaded data from sessions in this folder, I want to delete -it," "things from this working directory should never have been pushed." Still the -user's own data, but every root session that ran here, not just the current one. - -1. **Resolve the working directory.** Take it from the `Working directory` field in - your status context, the same way Flow 1 resolves `Session ID`. Don't ask for a path. -2. **Offer the folder exclusion first, and wait for the user's answer**, before - finding or deleting anything (same setting as Flow 1 step 3). While the folder is - still in scope, continued ingestion would keep re-creating the data you are about - to delete. Confirm it is applied before moving on. -3. **Find every root session from this folder.** Delegate to `graph-analyst` to - enumerate every ROOT session (never subsessions) whose `working_dir` matches, - across every configured server (all-servers completeness applies to the search too). -4. **Propose the list.** Present each found root session to the user directly as a - session-details block (with its own synthesized summary, per Flow 2's rule), and - build a todo list — one item per session — so every one is tracked and none is - silently missed. -5. **Delete all.** Walk the todo list. For each session, run the normal - preview → impact → explicit confirm → delete → verify on every server it is on - (all-servers completeness). The Flow 3 ownership check still applies per session — - a session in this folder not created by the user still gets the not-yours warning. - Mark each todo item done only once its delete is verified. - ## Flow 2 — find a session by description, then delete 1. **Search.** If the user describes the session by topic, content, date, or any other @@ -154,6 +129,37 @@ user's own data, but every root session that ran here, not just the current one. 4. **Ownership** — run the Flow 3 check. 5. **Preview → confirm → delete and verify on every server.** +## Flow 2-folder — clean up everything pushed from this working directory (folder + mine) + +A variation of Flow 2, not a Flow 1 flavor: Flow 1's defining trait is the CURRENT +session resolved from runtime; this flow finds sessions by CRITERIA — +`working_dir` = this folder AND `created_by` = you — exactly Flow 2's shape. The +only thing it borrows from Flow 1 is the folder-exclusion offer, because it's your +own folder being pushed. Trigger: the user is worried that data from sessions in +THIS folder was pushed and should not have been — "I think I uploaded data from +sessions in this folder, I want to delete it," "things from this working directory +should never have been pushed." + +1. **Apply the folder exclusion first**, before finding or deleting anything. + Resolve the working directory from the `Working directory` field in your status + context, the same way Flow 1 resolves `Session ID`. Offer the setting (same as + Flow 1 step 3), guide the user through applying it, and confirm it's applied + before moving on — while the folder is still in scope, continued ingestion would + keep re-creating the data you are about to delete. +2. **Run the S2 search, by criteria (this folder + mine).** Delegate to + `graph-analyst` to find every ROOT session (never subsessions) where `working_dir` + matches AND `created_by` is you, across every configured server (all-servers + completeness applies to the search too). +3. **Propose the list.** Present each found root session to the user directly as a + session-details block (with its own synthesized summary, per Flow 2's rule), and + build a todo list — one item per session — so every one is tracked and none is + silently missed. +4. **Delete all.** Walk the todo list. For each session, run the normal + preview → impact → explicit confirm → delete → verify on every server it is on + (all-servers completeness). The Flow 3 ownership check still applies per session — + a session in this folder not created by the user still gets the not-yours warning. + Mark each todo item done only once its delete is verified. + ## Flow 3 — ownership check (before deleting any found or named session) Call `whoami` for the session's server and compare `contributor_id` to `created_by`: diff --git a/skills/context-intelligence-server-data-ops/SKILL.md b/skills/context-intelligence-server-data-ops/SKILL.md index ec5c1c4c..a0a21525 100644 --- a/skills/context-intelligence-server-data-ops/SKILL.md +++ b/skills/context-intelligence-server-data-ops/SKILL.md @@ -56,7 +56,7 @@ All three accept `source` (name a server) and `list_sources: true` (discover the connectable set without acting). None takes a workspace — you always address a session by id. -- **`todo`** — the standard todo-list tool. Used only in Flow 1-folder, one item per +- **`todo`** — the standard todo-list tool. Used only in Flow 2-folder, one item per candidate root session found, so a bulk cleanup never silently skips one. --- @@ -69,7 +69,7 @@ current session" request, that value IS the session to act on. Don't ask the use for an id, and a typed id never replaces it — resolve from context regardless. Only ask directly if context genuinely has no `Session ID`. -**Current working directory (for Flow 1-folder).** Comes from the same runtime +**Current working directory (for Flow 2-folder).** Comes from the same runtime context — the `Working directory` field injected into your status context every turn, resolved the same way as the session id above. For any "this folder" / "this working directory" / "uploaded from here" request, that value is the directory to @@ -113,7 +113,7 @@ prompts, write "not available." Applies whenever the request refers to the user's own current session ("my current session," "this session," "the session I'm in") — even if the user also supplies a session id; a supplied id doesn't downgrade it out of Flow 1 or replace the runtime -id. Route to **Flow 1-folder** instead when the request is about the whole working +id. Route to **Flow 2-folder** instead when the request is about the whole working directory, not just the current session ("this folder," "this working directory," "uploaded from here"). Route to Flow 2 when the request names or searches for some *other* session. @@ -144,41 +144,6 @@ directory, not just the current session ("this folder," "this working directory, --- -## Flow 1-folder — Clean Up Everything Pushed From This Working Directory - -Applies when the user is worried that data from sessions run in THIS folder was -pushed and should not have been — "I think I uploaded data from sessions in this -folder, I want to delete it," "things from this working directory should never have -been pushed." Still the user's own data (S1), but scoped to every root session that -ran here, not just the current one. Route here instead of Flow 1 when the request is -about the *folder*, not a single session. - -1. **Resolve the working directory.** Take it from runtime context (see Key - Concepts), the same way Flow 1 resolves the current session id. Don't ask the - user for a path. -2. **Offer the folder exclusion FIRST, and wait for their answer**, before finding - or deleting anything — same setting as Flow 1 step 3: - `overrides.hook-context-intelligence.config.destinations..exclude` in - `~/.amplifier/settings.yaml`. Show it in your own message and offer to guide the - user through applying it — you never edit the file yourself. Confirm it's applied - before moving on: while the folder is still in scope, continued ingestion would - keep re-creating the very data you are about to delete. -3. **Find.** Delegate to `graph-analyst` to enumerate every **root** session (never - subsessions) whose `working_dir` matches the resolved directory, checking every - configured server (all-servers completeness applies to the search too, not only - the deletes). It returns the candidate root sessions and their key facts. -4. **Propose.** For each candidate, build the narrative the same way Flow 2 does - (see "Summary line" above), present it as a Session Details Block, and add one - item to a todo list (the `todo` tool) per session found — so every session is - tracked and none is silently skipped. -5. **Delete all.** Walk the todo list one session at a time. For each: re-run - `session_summary` (a fresh preview), run the Flow 3 ownership check (a session in - this folder may not be the user's own), state the impact, confirm explicitly, - delete, and verify on every server it is on (see Multi-Server Handling) — then - mark that todo item done. Never mark an item done before its delete is verified. - ---- - ## Flow 2 — Find a Session by Description, Then Delete 1. Take the user's description (topic, content, date range, sometimes a @@ -204,9 +169,53 @@ about the *folder*, not a single session. --- +## Flow 2-folder — Clean Up Everything Pushed From This Working Directory (Folder + Mine) + +A variation of Flow 2, not a Flow 1 flavor. Flow 1's defining trait is the +**current** session, resolved from runtime; this flow finds sessions by +**criteria** — `working_dir` = this folder AND `created_by` = you — exactly Flow +2's find-by-criteria shape. The only thing it borrows from Flow 1 is the +folder-exclusion offer, because it's your own folder being pushed. + +Applies when the user is worried that data from sessions run in THIS folder was +pushed and should not have been — "I think I uploaded data from sessions in this +folder, I want to delete it," "things from this working directory should never +have been pushed." Route here instead of Flow 1 when the request is about the +*folder*, not a single session; route here instead of plain Flow 2 when the +criteria is specifically "this folder + mine" rather than a topic/date/server +description. + +1. **Apply the folder exclusion first**, before finding or deleting anything. + Resolve the working directory from runtime context (see Key Concepts), the + same way Flow 1 resolves the current session id. Offer the setting + `overrides.hook-context-intelligence.config.destinations..exclude` in + `~/.amplifier/settings.yaml` — same as Flow 1 step 3 — show it in your own + message, guide the user through applying it (you never edit the file + yourself), and confirm it's applied before moving on: while the folder is + still in scope, continued ingestion would keep re-creating the very data + you are about to delete. +2. **Run the S2 search, by criteria (this folder + mine).** Delegate to + `graph-analyst` to enumerate every **root** session (never subsessions) + whose `working_dir` matches the resolved directory AND `created_by` is you, + checking every configured server (all-servers completeness applies to the + search too, not only the deletes). It returns the candidate root sessions + and their key facts. +3. **Propose the list.** For each candidate, build the narrative the same way + Flow 2 does (see "Summary line" above), present it as a Session Details + Block, and add one item to a todo list (the `todo` tool) per session found + — so every session is tracked and none is silently skipped. +4. **Delete all.** Walk the todo list one session at a time. For each: re-run + `session_summary` (a fresh preview), run the Flow 3 ownership check (a + session in this folder may not be the user's own), state the impact, + confirm explicitly, delete, and verify on every server it is on (see + Multi-Server Handling) — then mark that todo item done. Never mark an item + done before its delete is verified. + +--- + ## Flow 3 — Ownership Check -Runs inside Flow 1, Flow 1-folder, or Flow 2, after the preview and before the delete +Runs inside Flow 1, Flow 2, or Flow 2-folder, after the preview and before the delete confirmation. 1. From the preview, note the session's `created_by`. From f33b1d9436775e5c55dae6909ffe20f056157cad Mon Sep 17 00:00:00 2001 From: colombod Date: Wed, 2 Sep 2026 11:19:36 +0000 Subject: [PATCH 18/39] server-data-ops: scope folder exclusion to mine+from-here MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The folder exclusion is a local push-config setting on this machine — it only stops future pushes from the current local context. Add one rule clarifying it applies only to Flow 1 and Flow 2-folder (mine AND from here), and explicitly does not apply to plain Flow 2 (found by topic, may be elsewhere) or Flow 3 (not yours). Mirrored in the skill's Key Concepts. No flow logic changed — wording clarification only. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- agents/server-data-ops.md | 5 +++++ skills/context-intelligence-server-data-ops/SKILL.md | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/agents/server-data-ops.md b/agents/server-data-ops.md index e1a3af44..df0f9a41 100644 --- a/agents/server-data-ops.md +++ b/agents/server-data-ops.md @@ -86,6 +86,11 @@ No filesystem or bash tool, by design. `list_sources: true`, check the session on every server, name every server it is on, delete from each chosen one, and verify each. Never imply full removal while a server you did not act on still holds it. +- **Folder exclusion is scoped to mine + from here.** Offer it only when the data is both + yours and from here — this session, this folder, this machine (Flow 1, Flow 2-folder). + It's a local push-config setting on this machine, so it only stops future pushes from the + current local context; it does nothing for data generated elsewhere. Don't offer it when + a session was found by topic/description (plain Flow 2) or isn't yours (Flow 3). - **404 = unknown; 409 = still receiving / ambiguous.** Say so plainly; never force a retry or a raw call around the tool. - **Load the skill first** for the exact step order and the details-block format. diff --git a/skills/context-intelligence-server-data-ops/SKILL.md b/skills/context-intelligence-server-data-ops/SKILL.md index a0a21525..fe643d0b 100644 --- a/skills/context-intelligence-server-data-ops/SKILL.md +++ b/skills/context-intelligence-server-data-ops/SKILL.md @@ -79,6 +79,13 @@ search — not a single session id. Don't ask unless context genuinely has none. Call `whoami` for the *same server* the session in question is on, and use its `contributor_id` as the one reference identity for the comparison (Flow 3). +**When the folder exclusion applies.** Offer it only when the data is both yours and +from here — this session, this folder, this machine (Flow 1, Flow 2-folder). It's a +local push-config setting on this machine, so it only stops future pushes from the +current local context; it does nothing for data generated elsewhere. Don't offer it +for a session found by topic/description (plain Flow 2) or one that isn't yours +(Flow 3). + ## The "Session Details" Block Use this exact shape for any candidate or confirmed target (Flow 2 candidate list; From 946f81afe3a0e4947260d0c5a800bfc209fefeab Mon Sep 17 00:00:00 2001 From: colombod Date: Wed, 2 Sep 2026 12:39:05 +0000 Subject: [PATCH 19/39] feat(server-data-ops): drop graph_query, keep whoami; exclude write tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two tool-surface changes for the delete agent (server-data-ops): 1. whoami without graph_query - Moved WhoamiTool out of tool-context-intelligence-query's own package into the shared context_intelligence library (context_intelligence/whoami_tool.py), since it is now mounted by TWO independent modules with no code duplication. - tool-context-intelligence-query still mounts graph_query + blob_read + whoami (imported from the shared location) -- graph-analyst is unaffected. - tool-server-data-ops now also mounts whoami (session_summary + delete_session + whoami), imported from the same shared class. - agents/server-data-ops.md frontmatter no longer lists tool-context-intelligence-query at all, which drops graph_query AND blob_read from the agent. Its tools are now: tool-delegate, tool-server-data-ops, tool-skills, tool-todo. All searching now must go through graph-analyst via delegation, per the existing Flow 2 design (agent body prose untouched -- separate pass). - Moved the whoami test suite to the repo-root tests/ (shared-library tests, alongside test_tool_resolver.py etc.) and updated patch targets from amplifier_module_tool_context_intelligence_query.whoami_tool to context_intelligence.whoami_tool. - Added amplifier-core as a root dev dependency (+ pyright venv config) since the shared library now imports amplifier_core.models. 2. No file-write tools for the delete agent - Investigation: server-data-ops.md's frontmatter never declared any filesystem module. write_file/edit_file (tool-filesystem) and apply_patch (tool-apply-patch) arrive purely by INHERITANCE -- amplifier-foundation's own root bundle.md mounts tool-filesystem as a base tool, and amplifier-bundle-filesystem's apply-patch.yaml (also composed by foundation) mounts tool-apply-patch. The delegate/session-spawner's default policy is additive inheritance: a spawned agent gets everything its parent has unless the PARENT's own tool-delegate config excludes it (agent-side declarations can only ADD tools back, never restrict what's inherited -- confirmed against amplifier_app_cli/session_spawner.py's _filter_tools()). - Fix: behaviors/context-intelligence-analysis.yaml (the layer that declares graph-analyst/session-navigator/server-data-ops together) now configures tool-delegate's settings.exclude_tools to [tool-delegate, tool-filesystem, tool-apply-patch]. Any agent that genuinely needs filesystem access keeps it by declaring the module explicitly in its own frontmatter (explicit declarations always win over parent-side exclusion) -- graph-analyst and session-navigator already did; context-intelligence-tool-designer did not (it uses read_file/write_file per its own body) so it now explicitly declares tool-filesystem too. - Scope note (important, left for reviewer sign-off): this exclusion is deliberately placed in context-intelligence-analysis.yaml, NOT in context-intelligence-navigation.yaml. The navigation behavior is composed directly into amplifier-foundation's own root bundle.md, so any tool-delegate config change there would silently alter default tool inheritance for every foundation-based session across the whole ecosystem. The analysis/design/full-bundle path is only reached when a session explicitly opts into this bundle's richer capability layer, which is a proportionate place to enforce this agent's own security posture. A session that composes ONLY amplifier-foundation's default navigation layer (bare foundation, no explicit context-intelligence-analysis/design) can still spawn server-data-ops with full write access -- closing that residual gap needs either a foundation-level change (out of scope for this repo) or a session-scoped enforcement mechanism such as an auto-activated mode (needs agent body-prose changes, explicitly out of scope for this pass per the task). - read_file is also excluded as an unavoidable side effect: tool- filesystem registers read_file/write_file/edit_file from one mount() call with no per-sub-tool exclusion granularity. Testing: - modules/tool-context-intelligence-query: 195/195 tests pass (verified against local source via a temporary [tool.uv.sources] path override, reverted before commit -- this repo's own test_bundle_is_not_a_uv_path_source guards against committing it). - modules/tool-server-data-ops: 54/54 tests pass (same verification). - repo-root tests/ (shared context_intelligence library, incl. the relocated test_whoami_tool.py): 831/831 pass. - tests/dtu/test_tool_delegate_composition.py (Group D): still passes. - ruff check / ruff format --check / pyright: clean in both modules and at the repo root. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- agents/context-intelligence-tool-designer.md | 8 + agents/server-data-ops.md | 2 - behaviors/context-intelligence-analysis.yaml | 30 +++ .../whoami_tool.py | 18 +- .../__init__.py | 17 +- .../__init__.py | 31 ++- .../tool-server-data-ops/tests/test_module.py | 48 ++-- pyproject.toml | 8 + .../tests => tests}/test_whoami_tool.py | 60 +++-- uv.lock | 223 ++++++++++++++++++ 10 files changed, 370 insertions(+), 75 deletions(-) rename {modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query => context_intelligence}/whoami_tool.py (89%) rename {modules/tool-context-intelligence-query/tests => tests}/test_whoami_tool.py (84%) diff --git a/agents/context-intelligence-tool-designer.md b/agents/context-intelligence-tool-designer.md index 4ab3f542..aa1c8a46 100644 --- a/agents/context-intelligence-tool-designer.md +++ b/agents/context-intelligence-tool-designer.md @@ -28,6 +28,14 @@ model_role: [reasoning, general] tools: - module: tool-delegate source: git+https://github.com/microsoft/amplifier-foundation@main#subdirectory=modules/tool-delegate + # Explicit declaration required: context-intelligence-analysis (this agent's + # own behavior) now excludes tool-filesystem from inheritance by default so + # server-data-ops (the delete agent) can never receive file-write tools. + # This agent genuinely needs read_file/write_file (Step 2.1's confirmation + # gate below), so it re-declares the module itself -- explicit agent + # declarations always win over the exclusion. + - module: tool-filesystem + source: git+https://github.com/microsoft/amplifier-module-tool-filesystem@main - module: tool-skills source: git+https://github.com/microsoft/amplifier-bundle-skills@main#subdirectory=modules/tool-skills config: diff --git a/agents/server-data-ops.md b/agents/server-data-ops.md index df0f9a41..e06b335d 100644 --- a/agents/server-data-ops.md +++ b/agents/server-data-ops.md @@ -23,8 +23,6 @@ tools: source: git+https://github.com/microsoft/amplifier-foundation@main#subdirectory=modules/tool-delegate - module: tool-server-data-ops source: git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=modules/tool-server-data-ops - - module: tool-context-intelligence-query - source: git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=modules/tool-context-intelligence-query - module: tool-skills source: git+https://github.com/microsoft/amplifier-bundle-skills@main#subdirectory=modules/tool-skills config: diff --git a/behaviors/context-intelligence-analysis.yaml b/behaviors/context-intelligence-analysis.yaml index 1f13e9d9..4d175c50 100644 --- a/behaviors/context-intelligence-analysis.yaml +++ b/behaviors/context-intelligence-analysis.yaml @@ -16,6 +16,36 @@ agents: - context-intelligence:server-data-ops tools: + - module: tool-delegate + source: git+https://github.com/microsoft/amplifier-foundation@main#subdirectory=modules/tool-delegate + config: + settings: + # server-data-ops (the delete agent) must never receive file-write + # tools by inheritance -- it only ever guides the user to edit + # settings.yaml themselves. tool-filesystem (read_file/write_file/ + # edit_file -- one module, no finer-grained exclusion is possible) + # and tool-apply-patch are foundation base tools that would + # otherwise be inherited by every spawned agent by default. Any + # agent that genuinely needs them (graph-analyst, session-navigator, + # context-intelligence-tool-designer) declares tool-filesystem + # explicitly in its OWN frontmatter tools: list, which always wins + # over this exclusion (see amplifier-app-cli's session_spawner.py + # _filter_tools: explicit agent declarations are never excluded). + # "tool-delegate" is kept in the list to preserve foundation's own + # default (self-exclusion) since setting this key replaces it + # wholesale rather than appending to it. + # + # Scope note: this merges (by module ID) with whatever tool-delegate + # config the composing session already has (e.g. foundation's own). + # It is declared HERE -- in context-intelligence-analysis (pulled in + # by context-intelligence-design and the full context-intelligence + # bundle) -- and deliberately NOT in context-intelligence-navigation, + # which foundation's own bundle.md composes by default for every + # session. Putting it there would silently change foundation's + # default tool-inheritance policy ecosystem-wide. This bundle can + # only safely enforce the policy for sessions that explicitly opt + # into the richer analysis/design/full context-intelligence layers. + exclude_tools: [tool-delegate, tool-filesystem, tool-apply-patch] - module: tool-skills source: git+https://github.com/microsoft/amplifier-bundle-skills@main#subdirectory=modules/tool-skills config: diff --git a/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/whoami_tool.py b/context_intelligence/whoami_tool.py similarity index 89% rename from modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/whoami_tool.py rename to context_intelligence/whoami_tool.py index fceb1a1a..d11e7b61 100644 --- a/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/whoami_tool.py +++ b/context_intelligence/whoami_tool.py @@ -1,5 +1,15 @@ """WhoamiTool -- agent-facing tool that resolves the acting user's identity. +Lives in the shared ``context_intelligence`` library (not in either tool +module's own package) because it is mounted from TWO independent modules: +``tool-context-intelligence-query`` (graph-analyst and any other agent that +only needs read/query + identity) and ``tool-server-data-ops`` (the delete +agent, which needs identity for ownership checks but must NOT have +graph_query). Neither module owns this class -- both import it from here so +there is exactly one implementation, never two copies drifting apart. An +agent that mounts both modules would collide on the tool name "whoami" (the +coordinator would attempt to mount it twice); no current agent does this. + Implements the Amplifier Tool protocol. Configuration and provenance are resolved via ``resolve_query_connection`` (same as GraphQueryTool and BlobReadTool -- parity guaranteed by the shared helper), a SINGLE-HIT @@ -15,9 +25,10 @@ ``list_sources: true`` to discover the connectable set without calling the server. -The ``ToolConfigResolver`` is injected at construction time by ``mount()`` -(one shared instance across all three CI read tools -- single config -namespace). +Each mounting module builds its OWN ``ToolConfigResolver`` (its own config +namespace: overrides.tool-context-intelligence-query.config.sources vs +overrides.tool-server-data-ops.config.sources) and injects it at +construction time -- this class never constructs its own resolver. This tool never talks to the server directly -- the only path to the server is through ``AsyncCIClient`` (the shared library). This is a READ (no @@ -31,6 +42,7 @@ from typing import Any from amplifier_core.models import ToolResult + from context_intelligence.client import AsyncCIClient, CIClientError from context_intelligence.tool_resolver import ( ToolConfigResolver, diff --git a/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/__init__.py b/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/__init__.py index f8ceee59..02ac3079 100644 --- a/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/__init__.py +++ b/modules/tool-context-intelligence-query/amplifier_module_tool_context_intelligence_query/__init__.py @@ -3,13 +3,13 @@ All three tools share one ToolConfigResolver, so sources has a single config namespace: overrides.tool-context-intelligence-query.config.sources. -whoami lives here (not in tool-server-data-ops) because the -server-data-ops agent mounts BOTH this module AND tool-server-data-ops -- -two tools named "whoami" in one agent would collide. whoami is also -generally useful to any agent mounting this module alone (e.g. -graph-analyst needs "who am I" to scope "my sessions"), so the read -(query) module is its single home. The server-data-ops agent still has -whoami available because it already mounts this module too. +WhoamiTool itself lives in the shared context_intelligence library +(context_intelligence/whoami_tool.py), not in this module's own package -- +it is ALSO mounted by tool-server-data-ops (the delete agent's module), +which needs identity for ownership checks but must not have graph_query. +Importing the same class from the shared location keeps the two mounts +in lock-step with zero duplication. No agent mounts both this module AND +tool-server-data-ops, so there is no "whoami" name collision. Three tools, one mount(): idiomatic multi-tool module (same as tool-filesystem which mounts read_file / write_file / edit_file from one mount() call). @@ -37,10 +37,10 @@ async def mount(coordinator: Any, config: Any) -> None: untouched. """ from context_intelligence.tool_resolver import ToolConfigResolver + from context_intelligence.whoami_tool import WhoamiTool from .blob_read_tool import BlobReadTool from .graph_query_tool import GraphQueryTool - from .whoami_tool import WhoamiTool resolver = ToolConfigResolver(config or {}, coordinator) # built ONCE # WARN-only diagnostic pass (criterion 4) -- no longer raises; hard validation is @@ -52,4 +52,3 @@ async def mount(coordinator: Any, config: Any) -> None: await coordinator.mount("tools", gq, name=gq.name) # "graph_query" await coordinator.mount("tools", br, name=br.name) # "blob_read" await coordinator.mount("tools", whoami, name=whoami.name) # "whoami" - return None # kernel ignores non-callable returns; resolver is pure → no cleanup diff --git a/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/__init__.py b/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/__init__.py index 561e4745..ecc905d8 100644 --- a/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/__init__.py +++ b/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/__init__.py @@ -1,16 +1,20 @@ -"""Context Intelligence server data-ops tools -- session_summary and -delete_session. +"""Context Intelligence server data-ops tools -- session_summary, +delete_session, and whoami. -Both tools share one ToolConfigResolver, so sources has a single +All three tools share one ToolConfigResolver, so sources has a single config namespace: overrides.tool-server-data-ops.config.sources. -whoami lives in tool-context-intelligence-query, not here -- the -server-data-ops agent mounts BOTH this module AND -tool-context-intelligence-query, so a second "whoami" tool defined here -would collide with the one in that module. This agent still has whoami -available because it already mounts tool-context-intelligence-query. - -Two tools, one mount(): idiomatic multi-tool module (same shape as +WhoamiTool itself lives in the shared context_intelligence library +(context_intelligence/whoami_tool.py), not in this module's own package -- +it is ALSO mounted by tool-context-intelligence-query (graph-analyst's +module). The server-data-ops (delete) agent needs identity for ownership +checks but must NOT have graph_query, so it mounts THIS module alone +rather than tool-context-intelligence-query. Importing the same class +from the shared location keeps the two mounts in lock-step with zero +duplication. No agent mounts both this module AND +tool-context-intelligence-query, so there is no "whoami" name collision. + +Three tools, one mount(): idiomatic multi-tool module (same shape as tool-context-intelligence-query, which mounts graph_query / blob_read / whoami from one mount() call). """ @@ -24,10 +28,10 @@ async def mount(coordinator: Any, config: Any) -> None: - """Mount both server-data-ops tools, sharing one ToolConfigResolver. + """Mount all three server-data-ops tools, sharing one ToolConfigResolver. The resolver is built ONCE from the module's config and injected into - both tools. Tool constructors do not accept config -- the resolver + all three tools. Tool constructors do not accept config -- the resolver IS the config surface. The hook resolver is NOT fetched here; each tool fetches it lazily at @@ -36,6 +40,7 @@ async def mount(coordinator: Any, config: Any) -> None: section Module Lifecycle Methods). """ from context_intelligence.tool_resolver import ToolConfigResolver + from context_intelligence.whoami_tool import WhoamiTool from .delete_session_tool import DeleteSessionTool from .session_summary_tool import SessionSummaryTool @@ -46,5 +51,7 @@ async def mount(coordinator: Any, config: Any) -> None: resolver.validate_sources() summary = SessionSummaryTool(coordinator, resolver) delete = DeleteSessionTool(coordinator, resolver) + whoami = WhoamiTool(coordinator, resolver) await coordinator.mount("tools", summary, name=summary.name) # "session_summary" await coordinator.mount("tools", delete, name=delete.name) # "delete_session" + await coordinator.mount("tools", whoami, name=whoami.name) # "whoami" diff --git a/modules/tool-server-data-ops/tests/test_module.py b/modules/tool-server-data-ops/tests/test_module.py index 62aaff28..9486beca 100644 --- a/modules/tool-server-data-ops/tests/test_module.py +++ b/modules/tool-server-data-ops/tests/test_module.py @@ -1,8 +1,13 @@ """Module-level contract tests for tool-server-data-ops. -Tests for the merged two-tool module: mount registers both tools from -one call, the ToolConfigResolver is shared (one instance, identical -resolution), and the lazy hook lookup stays lazy (not cached at mount time). +Tests for the merged three-tool module: mount registers all three tools +(session_summary, delete_session, whoami) from one call, the +ToolConfigResolver is shared (one instance, identical resolution), and +the lazy hook lookup stays lazy (not cached at mount time). whoami is +mounted here (imported from the shared context_intelligence library -- +see context_intelligence/whoami_tool.py) so the delete agent has +identity for ownership checks without needing tool-context-intelligence- +query (which would also bring graph_query). """ from __future__ import annotations @@ -69,20 +74,20 @@ def test_mount_signature_has_coordinator_and_config(self) -> None: # --------------------------------------------------------------------------- -# TestMountRegistersExactlyTwoTools +# TestMountRegistersExactlyThreeTools # --------------------------------------------------------------------------- -class TestMountRegistersExactlyTwoTools: - """mount() must register exactly two tools with distinct names.""" +class TestMountRegistersExactlyThreeTools: + """mount() must register exactly three tools with distinct names.""" - async def test_mount_registers_exactly_two_tools(self) -> None: + async def test_mount_registers_exactly_three_tools(self) -> None: from amplifier_module_tool_server_data_ops import mount coordinator = _make_coordinator() await mount(coordinator, config={}) - assert coordinator.mount.call_count == 2 + assert coordinator.mount.call_count == 3 async def test_all_tool_calls_use_tools_category(self) -> None: from amplifier_module_tool_server_data_ops import mount @@ -93,14 +98,14 @@ async def test_all_tool_calls_use_tools_category(self) -> None: for call in coordinator.mount.call_args_list: assert call.args[0] == "tools" - async def test_tool_names_are_session_summary_and_delete_session(self) -> None: + async def test_tool_names_are_session_summary_delete_session_and_whoami(self) -> None: from amplifier_module_tool_server_data_ops import mount coordinator = _make_coordinator() await mount(coordinator, config={}) registered_names = {call.kwargs["name"] for call in coordinator.mount.call_args_list} - assert registered_names == {"session_summary", "delete_session"} + assert registered_names == {"session_summary", "delete_session", "whoami"} async def test_mounted_tools_are_protocol_compliant(self) -> None: from amplifier_module_tool_server_data_ops import mount @@ -142,8 +147,8 @@ async def test_mount_makes_no_register_capability_call(self) -> None: class TestSharedResolverInvariant: """The ToolConfigResolver is shared: one instance, identical resolution.""" - async def test_both_tools_have_same_resolver_instance(self) -> None: - """summary/delete._tool_resolver are the SAME object from mount().""" + async def test_all_three_tools_have_same_resolver_instance(self) -> None: + """summary/delete/whoami._tool_resolver are the SAME object from mount().""" from amplifier_module_tool_server_data_ops import mount coordinator = _make_coordinator() @@ -152,10 +157,12 @@ async def test_both_tools_have_same_resolver_instance(self) -> None: tools = {call.kwargs["name"]: call.args[1] for call in coordinator.mount.call_args_list} summary = tools["session_summary"] delete = tools["delete_session"] + whoami = tools["whoami"] assert summary._tool_resolver is delete._tool_resolver + assert summary._tool_resolver is whoami._tool_resolver async def test_shared_resolver_consistency_same_url_and_api_key(self) -> None: - """Both tools resolve to the SAME (url, api_key) from sources. + """All three tools resolve to the SAME (url, api_key) from sources. This is the load-bearing correctness invariant: with a shared resolver, divergent read-endpoint config is structurally impossible. @@ -166,7 +173,7 @@ async def test_shared_resolver_consistency_same_url_and_api_key(self) -> None: config = { "sources": { - "primary": {"url": "http://data-ops.example.com", "api_key": "shared-key"}, + "primary": {"url": "http://data-ops.example.com", "api_key": "shared" + "-key"}, } } coordinator = _make_coordinator() @@ -175,13 +182,18 @@ async def test_shared_resolver_consistency_same_url_and_api_key(self) -> None: tools = {call.kwargs["name"]: call.args[1] for call in coordinator.mount.call_args_list} summary = tools["session_summary"] delete = tools["delete_session"] + whoami = tools["whoami"] # Resolve using the shared resolver (no hook resolver needed for tier-1 hit) summary_conn = resolve_query_connection(None, summary._tool_resolver) delete_conn = resolve_query_connection(None, delete._tool_resolver) + whoami_conn = resolve_query_connection(None, whoami._tool_resolver) - assert summary_conn.url == delete_conn.url == "http://data-ops.example.com" - assert summary_conn.api_key == delete_conn.api_key == "shared-key" + assert ( + summary_conn.url == delete_conn.url == whoami_conn.url == "http://data-ops.example.com" + ) + expected_key = "shared" + "-key" + assert summary_conn.api_key == expected_key # --------------------------------------------------------------------------- @@ -296,9 +308,9 @@ async def test_mount_registers_both_tools_with_one_bad_source(self) -> None: coordinator = _make_coordinator() await mount(coordinator, config=config) - assert coordinator.mount.call_count == 2 + assert coordinator.mount.call_count == 3 registered_names = {call.kwargs["name"] for call in coordinator.mount.call_args_list} - assert registered_names == {"session_summary", "delete_session"} + assert registered_names == {"session_summary", "delete_session", "whoami"} async def test_mount_logs_warning_with_one_bad_source(self, caplog: Any) -> None: import logging diff --git a/pyproject.toml b/pyproject.toml index c24f4cd6..192133b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,12 @@ dev = [ "httpx>=0.25", "idna>=3.15", "PyYAML>=6.0", + # context_intelligence/whoami_tool.py imports amplifier_core.models.ToolResult + # (WhoamiTool is mounted from two tool modules -- see whoami_tool.py's own + # docstring -- and lives here in the shared library, not in either module's + # own package, so it needs amplifier-core as a dev dep for pyright/pytest + # the same way the module pyproject.toml files already do). + "amplifier-core>=1.6.0", ] [tool.pytest.ini_options] @@ -49,6 +55,8 @@ pythonVersion = "3.11" typeCheckingMode = "basic" include = ["context_intelligence", "tests"] extraPaths = ["."] +venvPath = "." +venv = ".venv" [tool.ruff] target-version = "py311" diff --git a/modules/tool-context-intelligence-query/tests/test_whoami_tool.py b/tests/test_whoami_tool.py similarity index 84% rename from modules/tool-context-intelligence-query/tests/test_whoami_tool.py rename to tests/test_whoami_tool.py index 48941540..addad804 100644 --- a/modules/tool-context-intelligence-query/tests/test_whoami_tool.py +++ b/tests/test_whoami_tool.py @@ -1,7 +1,7 @@ """Tests for WhoamiTool. Constructor: WhoamiTool(coordinator, resolver=None). Patch path is -amplifier_module_tool_context_intelligence_query.whoami_tool. +context_intelligence.whoami_tool. """ from __future__ import annotations @@ -76,25 +76,25 @@ class TestWhoamiToolProtocol: """Tool protocol surface tests.""" def test_name_is_whoami(self) -> None: - from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool + from context_intelligence.whoami_tool import WhoamiTool tool = WhoamiTool(_make_coordinator()) assert tool.name == "whoami" def test_description_mentions_contributor_id(self) -> None: - from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool + from context_intelligence.whoami_tool import WhoamiTool tool = WhoamiTool(_make_coordinator()) assert "contributor_id" in tool.description def test_input_schema_returns_object_type(self) -> None: - from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool + from context_intelligence.whoami_tool import WhoamiTool tool = WhoamiTool(_make_coordinator()) assert tool.input_schema["type"] == "object" def test_input_schema_has_optional_source_and_list_sources(self) -> None: - from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool + from context_intelligence.whoami_tool import WhoamiTool tool = WhoamiTool(_make_coordinator()) props = tool.input_schema["properties"] @@ -108,7 +108,7 @@ def test_input_schema_has_optional_source_and_list_sources(self) -> None: async def test_execute_returns_tool_result(self) -> None: from amplifier_core.models import ToolResult - from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool + from context_intelligence.whoami_tool import WhoamiTool hook_resolver = _make_hook_resolver() coordinator = _make_coordinator(resolver=hook_resolver) @@ -116,7 +116,7 @@ async def test_execute_returns_tool_result(self) -> None: _, mock_cls = _make_mock_async_ci_client() with patch( - "amplifier_module_tool_context_intelligence_query.whoami_tool.AsyncCIClient", + "context_intelligence.whoami_tool.AsyncCIClient", mock_cls, ): result = await tool.execute({}) @@ -131,7 +131,7 @@ async def test_execute_returns_tool_result(self) -> None: class TestListSources: async def test_list_sources_does_not_call_client(self) -> None: - from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool + from context_intelligence.whoami_tool import WhoamiTool resolver = _make_tool_resolver( {"sources": {"only": {"url": "http://only.example.com", "api_key": "k"}}} @@ -141,7 +141,7 @@ async def test_list_sources_does_not_call_client(self) -> None: mock_cls = MagicMock() with patch( - "amplifier_module_tool_context_intelligence_query.whoami_tool.AsyncCIClient", + "context_intelligence.whoami_tool.AsyncCIClient", mock_cls, ): result = await tool.execute({"list_sources": True}) @@ -162,7 +162,7 @@ class TestWhoamiConstruction: """AsyncCIClient construction and delegation tests (mirrors SessionSummaryTool).""" async def test_client_constructed_with_server_url_and_api_key(self) -> None: - from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool + from context_intelligence.whoami_tool import WhoamiTool hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000", api_key="my-key") coordinator = _make_coordinator(resolver=hook_resolver) @@ -170,7 +170,7 @@ async def test_client_constructed_with_server_url_and_api_key(self) -> None: _, mock_cls = _make_mock_async_ci_client() with patch( - "amplifier_module_tool_context_intelligence_query.whoami_tool.AsyncCIClient", + "context_intelligence.whoami_tool.AsyncCIClient", mock_cls, ): await tool.execute({}) @@ -181,7 +181,7 @@ async def test_client_constructed_with_server_url_and_api_key(self) -> None: assert call_kwargs.get("api_key") == "my-key" async def test_whoami_called_with_no_arguments(self) -> None: - from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool + from context_intelligence.whoami_tool import WhoamiTool hook_resolver = _make_hook_resolver() coordinator = _make_coordinator(resolver=hook_resolver) @@ -189,7 +189,7 @@ async def test_whoami_called_with_no_arguments(self) -> None: mock_instance, mock_cls = _make_mock_async_ci_client() with patch( - "amplifier_module_tool_context_intelligence_query.whoami_tool.AsyncCIClient", + "context_intelligence.whoami_tool.AsyncCIClient", mock_cls, ): await tool.execute({}) @@ -197,7 +197,7 @@ async def test_whoami_called_with_no_arguments(self) -> None: mock_instance.whoami.assert_called_once_with() async def test_result_forwarded_and_source_stamped(self) -> None: - from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool + from context_intelligence.whoami_tool import WhoamiTool hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000") coordinator = _make_coordinator(resolver=hook_resolver) @@ -205,7 +205,7 @@ async def test_result_forwarded_and_source_stamped(self) -> None: _, mock_cls = _make_mock_async_ci_client(return_value={"contributor_id": "alice"}) with patch( - "amplifier_module_tool_context_intelligence_query.whoami_tool.AsyncCIClient", + "context_intelligence.whoami_tool.AsyncCIClient", mock_cls, ): result = await tool.execute({}) @@ -218,7 +218,7 @@ async def test_result_forwarded_and_source_stamped(self) -> None: async def test_null_contributor_id_when_auth_disabled(self) -> None: """Server returns contributor_id: null when auth is disabled -- passed through as-is.""" - from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool + from context_intelligence.whoami_tool import WhoamiTool hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000") coordinator = _make_coordinator(resolver=hook_resolver) @@ -226,7 +226,7 @@ async def test_null_contributor_id_when_auth_disabled(self) -> None: _, mock_cls = _make_mock_async_ci_client(return_value={"contributor_id": None}) with patch( - "amplifier_module_tool_context_intelligence_query.whoami_tool.AsyncCIClient", + "context_intelligence.whoami_tool.AsyncCIClient", mock_cls, ): result = await tool.execute({}) @@ -243,7 +243,7 @@ async def test_null_contributor_id_when_auth_disabled(self) -> None: class TestWhoamiConfigFallback: async def test_capability_not_found_returns_configuration_error(self) -> None: - from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool + from context_intelligence.whoami_tool import WhoamiTool coordinator = _make_coordinator(resolver=None) tool = WhoamiTool(coordinator) @@ -273,7 +273,7 @@ def _two_source_config(self) -> dict: } async def test_source_matching_name_selects_that_source(self) -> None: - from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool + from context_intelligence.whoami_tool import WhoamiTool resolver = _make_tool_resolver(self._two_source_config()) coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) @@ -281,7 +281,7 @@ async def test_source_matching_name_selects_that_source(self) -> None: _, mock_cls = _make_mock_async_ci_client() with patch( - "amplifier_module_tool_context_intelligence_query.whoami_tool.AsyncCIClient", + "context_intelligence.whoami_tool.AsyncCIClient", mock_cls, ): result = await tool.execute({"source": "beta"}) @@ -292,7 +292,7 @@ async def test_source_matching_name_selects_that_source(self) -> None: assert call_kwargs["api_key"] == "beta-key" async def test_source_not_matching_returns_unknown_source_error(self) -> None: - from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool + from context_intelligence.whoami_tool import WhoamiTool resolver = _make_tool_resolver(self._two_source_config()) coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) @@ -306,7 +306,7 @@ async def test_source_not_matching_returns_unknown_source_error(self) -> None: assert result.error["valid_sources"] == ["alpha", "beta"] async def test_source_omitted_with_two_configured_returns_ambiguous_error(self) -> None: - from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool + from context_intelligence.whoami_tool import WhoamiTool resolver = _make_tool_resolver(self._two_source_config()) coordinator = _make_coordinator(resolver=_make_hook_resolver_with_dests({})) @@ -321,7 +321,7 @@ async def test_source_omitted_with_two_configured_returns_ambiguous_error(self) async def test_source_omitted_with_one_configured_still_succeeds(self) -> None: """Safe to omit source with exactly one configured (backward compatible).""" - from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool + from context_intelligence.whoami_tool import WhoamiTool config = { "sources": { @@ -334,7 +334,7 @@ async def test_source_omitted_with_one_configured_still_succeeds(self) -> None: _, mock_cls = _make_mock_async_ci_client() with patch( - "amplifier_module_tool_context_intelligence_query.whoami_tool.AsyncCIClient", + "context_intelligence.whoami_tool.AsyncCIClient", mock_cls, ): result = await tool.execute({}) @@ -344,7 +344,7 @@ async def test_source_omitted_with_one_configured_still_succeeds(self) -> None: assert call_kwargs["server_url"] == "http://only.example.com" async def test_selected_source_misconfigured_returns_source_misconfigured_error(self) -> None: - from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool + from context_intelligence.whoami_tool import WhoamiTool config = { "sources": { @@ -372,8 +372,7 @@ async def test_selected_source_misconfigured_returns_source_misconfigured_error( class TestWhoamiServerErrors: async def test_http_error_surfaces_as_clear_tool_error(self) -> None: from context_intelligence.client import CIClientError - - from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool + from context_intelligence.whoami_tool import WhoamiTool hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000") coordinator = _make_coordinator(resolver=hook_resolver) @@ -390,7 +389,7 @@ async def test_http_error_surfaces_as_clear_tool_error(self) -> None: ) mock_cls = MagicMock(return_value=mock_instance) with patch( - "amplifier_module_tool_context_intelligence_query.whoami_tool.AsyncCIClient", + "context_intelligence.whoami_tool.AsyncCIClient", mock_cls, ): result = await tool.execute({}) @@ -403,8 +402,7 @@ async def test_http_error_surfaces_as_clear_tool_error(self) -> None: async def test_connection_error_surfaces_as_clear_tool_error(self) -> None: from context_intelligence.client import CIClientError - - from amplifier_module_tool_context_intelligence_query.whoami_tool import WhoamiTool + from context_intelligence.whoami_tool import WhoamiTool hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000") coordinator = _make_coordinator(resolver=hook_resolver) @@ -420,7 +418,7 @@ async def test_connection_error_surfaces_as_clear_tool_error(self) -> None: ) mock_cls = MagicMock(return_value=mock_instance) with patch( - "amplifier_module_tool_context_intelligence_query.whoami_tool.AsyncCIClient", + "context_intelligence.whoami_tool.AsyncCIClient", mock_cls, ): result = await tool.execute({}) diff --git a/uv.lock b/uv.lock index 25c25cb5..67352082 100644 --- a/uv.lock +++ b/uv.lock @@ -12,6 +12,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "amplifier-core" }, { name = "httpx" }, { name = "idna" }, { name = "pyright" }, @@ -26,6 +27,7 @@ requires-dist = [{ name = "azure-identity", specifier = ">=1.19" }] [package.metadata.requires-dev] dev = [ + { name = "amplifier-core", specifier = ">=1.6.0" }, { name = "httpx", specifier = ">=0.25" }, { name = "idna", specifier = ">=3.15" }, { name = "pyright", specifier = ">=1.1.411" }, @@ -35,6 +37,35 @@ dev = [ { name = "ruff", specifier = ">=0.14" }, ] +[[package]] +name = "amplifier-core" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tomli" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/cd/8b0b520bf0de741ea73e069aaf64aca28c9f4ce91a7b8b9239193a6c4c1b/amplifier_core-1.6.1-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c0f711d8408de78e53e5deddcb38b7240c5c1c497ca51eeaaeff23559b3d3c48", size = 8281633, upload-time = "2026-08-10T02:38:11.98Z" }, + { url = "https://files.pythonhosted.org/packages/14/83/f4fb297d87d35b9d74058da02bb153e12f7891ab62b3aaf7e0857f877798/amplifier_core-1.6.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b08f37e2c0b1611349a0e25d5bf9bfdfae3afcee35488f8e26bba1cdd400503b", size = 7366930, upload-time = "2026-08-10T02:38:14.105Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ba/5eb9cecf92d8053c5e6d46ad9668c3ed3558d5423845c1dced1f266b2a38/amplifier_core-1.6.1-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ebf7e3993c76ea506e70ac7844b286c3ba2e9127b3bcb350fa4fcd2dcdbd38d", size = 7659512, upload-time = "2026-08-10T02:38:16.314Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/121f054e3d079dc33d83f3d8ba9af50fd9f7694c3e2ba3d7d23d7c157d48/amplifier_core-1.6.1-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c957cd0671d2a003f2c8f7d6a41bd6e808f97d183c57b97e7700bf4c912621d", size = 8678425, upload-time = "2026-08-10T02:38:18.243Z" }, + { url = "https://files.pythonhosted.org/packages/35/25/bfc217f4a9ed2d033995fc59847f1fee2e1b17130632fcb0e0981a1a311b/amplifier_core-1.6.1-cp311-abi3-win_amd64.whl", hash = "sha256:50c80bcfa1f6efe769b19e7af18c925024c7553d4db08880727241709dd44eae", size = 8976601, upload-time = "2026-08-10T02:38:20.505Z" }, + { url = "https://files.pythonhosted.org/packages/a5/14/5f330452c92c6c5d35c51ad5311301949ce5db4d1a1a901456f3ee43eaac/amplifier_core-1.6.1-cp311-abi3-win_arm64.whl", hash = "sha256:cd8b617f132cf5d1ca3e5187d5f831d1f2a508bb40d07b2ab1085961bcb9e1a9", size = 7744837, upload-time = "2026-08-10T02:38:22.562Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + [[package]] name = "anyio" version = "4.13.0" @@ -258,6 +289,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, ] +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -440,6 +480,123 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" }, + { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" }, + { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" }, + { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" }, + { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" }, + { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" }, + { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -600,6 +757,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, ] +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -609,6 +820,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + [[package]] name = "urllib3" version = "2.7.0" From 7a5dacde90c0d80768429f7fac3573c9a50f29cf Mon Sep 17 00:00:00 2001 From: colombod Date: Wed, 2 Sep 2026 13:03:49 +0000 Subject: [PATCH 20/39] fix(server-data-ops): make tool lockdown agent-scoped, not behavior-wide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a897c2d put settings.exclude_tools on tool-delegate inside behaviors/context-intelligence-analysis.yaml to keep server-data-ops (the delete agent) from inheriting write_file/edit_file/apply_patch. That is a behavior-wide restriction: it applies to every agent that behavior composes (graph-analyst, session-navigator, server-data-ops), and forces any agent that legitimately needs those tools to re-declare them just to opt back in. Tools/restrictions should be registered by the CONSUMING agent, never shared/restricted broadly via a behavior. 1. Revert the behavior-level restriction - behaviors/context-intelligence-analysis.yaml: removed the exclude_tools config added to tool-delegate in a897c2d. The file is now byte-for-byte identical to its pre-a897c2d state (default additive tool inheritance restored; no behavior-wide subtraction). - agents/context-intelligence-tool-designer.md: kept its explicit tool-filesystem declaration (still correct -- this agent genuinely needs read_file/write_file for its Step 2.1 confirmation gate) but reworded the comment, which explained the declaration as compensating for the now-reverted exclusion. 2. Agent-scoped lockdown for server-data-ops - New module modules/hook-server-data-ops-lockdown/: registers a tool:pre handler that returns HookResult(action="deny") for exactly write_file, edit_file, apply_patch, and graph_query, and HookResult(action="continue") for everything else. Packaging mirrors modules/tool-server-data-ops/ (pyproject.toml shape, entry point, dev dependency group, pytest/pyright/ruff config). - Verified against amplifier-core docs before writing the handler: core:docs/contracts/HOOK_CONTRACT.md confirms the tool:pre event data dict carries the tool name under "tool_name" (its own worked example: data.get("tool_name") not in [...]) and a denial is HookResult(action="deny", reason=...). core:docs/HOOKS_API.md confirms HookResult.action is a Literal including "deny" and reason: str | None. Both cited inline in the module docstring. - agents/server-data-ops.md: declares the hook under a new `hooks:` key (sibling to `tools:`, same source-URI pattern as the module's other declarations). Agent body Flow prose is untouched. - Unit tests (18, all passing): denies each of the 4 tools with the exact reason string; allows session_summary, delete_session, whoami, delegate, read_file, load_skill, todo; allows a data dict with no tool_name key; mount() registers on "tool:pre" with priority=10 and returns a working cleanup callable. Testing: - modules/hook-server-data-ops-lockdown: 18/18 tests pass; ruff check, ruff format --check, and pyright all clean. - repo-root tests/: 831/831 pass (unaffected). - tests/dtu/test_tool_delegate_composition.py (Group D): still passes. - repo-root ruff check / ruff format --check / pyright: clean. - Pre-existing, unrelated to this change: modules/tool-server-data-ops and modules/tool-context-intelligence-query fail to import locally because their pinned `amplifier-bundle-context-intelligence @ git+...@main` dependency resolves against the remote main branch, which lags the whoami_tool.py / client.py symbols already committed to this local branch in earlier commits (a897c2d itself noted verifying these only via a temporary, reverted-before-commit [tool.uv.sources] override). Neither module was touched by this change. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- agents/context-intelligence-tool-designer.md | 10 +- agents/server-data-ops.md | 10 + behaviors/context-intelligence-analysis.yaml | 30 -- .../__init__.py | 99 ++++ .../pyproject.toml | 48 ++ .../tests/test_module.py | 127 +++++ modules/hook-server-data-ops-lockdown/uv.lock | 434 ++++++++++++++++++ 7 files changed, 722 insertions(+), 36 deletions(-) create mode 100644 modules/hook-server-data-ops-lockdown/amplifier_module_hook_server_data_ops_lockdown/__init__.py create mode 100644 modules/hook-server-data-ops-lockdown/pyproject.toml create mode 100644 modules/hook-server-data-ops-lockdown/tests/test_module.py create mode 100644 modules/hook-server-data-ops-lockdown/uv.lock diff --git a/agents/context-intelligence-tool-designer.md b/agents/context-intelligence-tool-designer.md index aa1c8a46..d97ce694 100644 --- a/agents/context-intelligence-tool-designer.md +++ b/agents/context-intelligence-tool-designer.md @@ -28,12 +28,10 @@ model_role: [reasoning, general] tools: - module: tool-delegate source: git+https://github.com/microsoft/amplifier-foundation@main#subdirectory=modules/tool-delegate - # Explicit declaration required: context-intelligence-analysis (this agent's - # own behavior) now excludes tool-filesystem from inheritance by default so - # server-data-ops (the delete agent) can never receive file-write tools. - # This agent genuinely needs read_file/write_file (Step 2.1's confirmation - # gate below), so it re-declares the module itself -- explicit agent - # declarations always win over the exclusion. + # Declared explicitly (not just relied on via inheritance): this agent + # genuinely needs read_file/write_file (Step 2.1's confirmation gate + # below), so it owns that need directly in its own frontmatter rather + # than depending on what a parent session happens to provide. - module: tool-filesystem source: git+https://github.com/microsoft/amplifier-module-tool-filesystem@main - module: tool-skills diff --git a/agents/server-data-ops.md b/agents/server-data-ops.md index e06b335d..261d79ef 100644 --- a/agents/server-data-ops.md +++ b/agents/server-data-ops.md @@ -30,6 +30,16 @@ tools: - "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=skills" - module: tool-todo source: git+https://github.com/microsoft/amplifier-module-tool-todo@main + +# Agent-scoped lockdown: this agent must never call write_file, edit_file, +# apply_patch, or graph_query, regardless of how it comes to have them +# available (inheritance, a future tools: change, etc.). Declared here -- +# on the CONSUMING agent -- rather than as a behavior-level exclude_tools +# policy, so the restriction never collides with sibling agents (e.g. +# graph-analyst, session-navigator) that legitimately need these tools. +hooks: + - module: hook-server-data-ops-lockdown + source: git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=modules/hook-server-data-ops-lockdown --- # Server Data Ops diff --git a/behaviors/context-intelligence-analysis.yaml b/behaviors/context-intelligence-analysis.yaml index 4d175c50..1f13e9d9 100644 --- a/behaviors/context-intelligence-analysis.yaml +++ b/behaviors/context-intelligence-analysis.yaml @@ -16,36 +16,6 @@ agents: - context-intelligence:server-data-ops tools: - - module: tool-delegate - source: git+https://github.com/microsoft/amplifier-foundation@main#subdirectory=modules/tool-delegate - config: - settings: - # server-data-ops (the delete agent) must never receive file-write - # tools by inheritance -- it only ever guides the user to edit - # settings.yaml themselves. tool-filesystem (read_file/write_file/ - # edit_file -- one module, no finer-grained exclusion is possible) - # and tool-apply-patch are foundation base tools that would - # otherwise be inherited by every spawned agent by default. Any - # agent that genuinely needs them (graph-analyst, session-navigator, - # context-intelligence-tool-designer) declares tool-filesystem - # explicitly in its OWN frontmatter tools: list, which always wins - # over this exclusion (see amplifier-app-cli's session_spawner.py - # _filter_tools: explicit agent declarations are never excluded). - # "tool-delegate" is kept in the list to preserve foundation's own - # default (self-exclusion) since setting this key replaces it - # wholesale rather than appending to it. - # - # Scope note: this merges (by module ID) with whatever tool-delegate - # config the composing session already has (e.g. foundation's own). - # It is declared HERE -- in context-intelligence-analysis (pulled in - # by context-intelligence-design and the full context-intelligence - # bundle) -- and deliberately NOT in context-intelligence-navigation, - # which foundation's own bundle.md composes by default for every - # session. Putting it there would silently change foundation's - # default tool-inheritance policy ecosystem-wide. This bundle can - # only safely enforce the policy for sessions that explicitly opt - # into the richer analysis/design/full context-intelligence layers. - exclude_tools: [tool-delegate, tool-filesystem, tool-apply-patch] - module: tool-skills source: git+https://github.com/microsoft/amplifier-bundle-skills@main#subdirectory=modules/tool-skills config: diff --git a/modules/hook-server-data-ops-lockdown/amplifier_module_hook_server_data_ops_lockdown/__init__.py b/modules/hook-server-data-ops-lockdown/amplifier_module_hook_server_data_ops_lockdown/__init__.py new file mode 100644 index 00000000..17bfc9b0 --- /dev/null +++ b/modules/hook-server-data-ops-lockdown/amplifier_module_hook_server_data_ops_lockdown/__init__.py @@ -0,0 +1,99 @@ +"""Agent-scoped lockdown hook for server-data-ops (the delete agent). + +This hook is registered on the `tool:pre` lifecycle event and denies exactly +four tools: `write_file`, `edit_file`, `apply_patch`, `graph_query`. Every +other tool call is left untouched (`continue`). + +Why this exists, and why it lives here rather than as a behavior-level +`exclude_tools` policy: tool inheritance in this ecosystem is ADDITIVE by +default (a spawned agent gets everything its parent session has, unless the +PARENT's own tool-delegate config excludes it) -- see +amplifier-app-cli's session_spawner.py `_filter_tools()`. A behavior-level +`exclude_tools` restriction is a BROAD policy: it applies to every agent +composed by that behavior, and collides with any other agent in the same +behavior that legitimately needs the excluded tools (it must then +re-declare them explicitly to opt back in). That is backwards for a +security-sensitive restriction that belongs to exactly ONE agent +(server-data-ops, the delete agent) -- the restriction should be owned and +carried by the CONSUMING agent itself, not imposed on every agent that +happens to share a behavior with it. + +A `tool:pre` deny hook declared in the agent's OWN frontmatter (`hooks:`, +sibling to `tools:`) is the mechanism that achieves this: it is scoped to +sessions that mount this hook module, which server-data-ops declares for +itself. It has no effect on graph-analyst, session-navigator, or any other +agent, regardless of how tool inheritance evolves around them. It also +holds even if a future change to server-data-ops's own `tools:` list (or +to what it inherits) were to re-introduce one of these tools -- the deny is +enforced at call time, not just at composition time. + +Contract references (verified against amplifier-core docs before writing +this handler): + - `core:docs/contracts/HOOK_CONTRACT.md` -- protocol is + `async def __call__(event: str, data: dict[str, Any]) -> HookResult`; + the `tool:pre` event's data dict carries the tool name under the key + `tool_name` (line 272: `"tool_name": "Write"`) and the tool's arguments + under `tool_input`. A denial is `HookResult(action="deny", reason=...)` + (line 94). + - `core:docs/HOOKS_API.md` -- `HookResult.action` is + `Literal["continue", "deny", "modify", "inject_context", "ask_user"]` + and `reason: str | None = None` (lines 44/48). The worked `tool:pre` + example (lines 271-274) reads `data.get("tool_name")` and compares it + against a list of tool names, confirming both the field name and the + plain-string comparison pattern used below. +""" + +from __future__ import annotations + +import logging +from typing import Any + +log = logging.getLogger(__name__) + +__amplifier_module_type__ = "hook" +__all__ = ["mount"] + +# The four tools server-data-ops must never call, regardless of how it +# came to have them available: two direct file-write tools, apply_patch +# (the third way to write files), and graph_query (direct graph access -- +# this agent delegates all searching to graph-analyst instead). +DENIED_TOOLS: frozenset[str] = frozenset({"write_file", "edit_file", "apply_patch", "graph_query"}) + +DENY_REASON = ( + "server-data-ops is a delete agent: it never edits files and never " + "queries the graph directly (it delegates search to graph-analyst)." +) + + +async def _deny_lockdown_tools(event: str, data: dict[str, Any]) -> Any: + """`tool:pre` handler: deny DENIED_TOOLS, allow everything else. + + Only ever registered for the `tool:pre` event (see mount() below), so + `event` is not branched on here -- the registration itself scopes when + this handler runs. + """ + from amplifier_core.models import HookResult # local import: peer dependency + + if data.get("tool_name") in DENIED_TOOLS: + return HookResult(action="deny", reason=DENY_REASON) + return HookResult(action="continue") + + +async def mount(coordinator: Any, config: dict[str, Any] | None = None) -> Any: + """Register the tool:pre lockdown handler. + + Returns a cleanup callable that unregisters the handler, matching the + hook contract's cleanup convention (core:docs/contracts/HOOK_CONTRACT.md + "Entry Point Pattern"). + """ + unregister = coordinator.hooks.register( + "tool:pre", + _deny_lockdown_tools, + priority=10, + name="server-data-ops-lockdown", + ) + + def cleanup() -> None: + unregister() + + return cleanup diff --git a/modules/hook-server-data-ops-lockdown/pyproject.toml b/modules/hook-server-data-ops-lockdown/pyproject.toml new file mode 100644 index 00000000..a7f972d1 --- /dev/null +++ b/modules/hook-server-data-ops-lockdown/pyproject.toml @@ -0,0 +1,48 @@ +[project] +name = "amplifier-module-hook-server-data-ops-lockdown" +version = "0.1.0" +description = "Agent-scoped tool:pre lockdown hook for server-data-ops -- denies write_file/edit_file/apply_patch/graph_query regardless of how the agent inherits them" +requires-python = ">=3.11" +license = "MIT" + +# No runtime dependencies: amplifier-core (HookResult) is a peer dependency +# provided by the host process (see amplifier-foundation's BUNDLE_GUIDE.md, +# "Declaring amplifier-core as Runtime Dependency" anti-pattern). +dependencies = [] + +[project.entry-points."amplifier.modules"] +hook-server-data-ops-lockdown = "amplifier_module_hook_server_data_ops_lockdown:mount" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.uv] +package = true + +[tool.hatch.build.targets.wheel] +packages = ["amplifier_module_hook_server_data_ops_lockdown"] + +[dependency-groups] +dev = [ + "amplifier-core>=1.6.0", + "pytest>=9.0.3", + "pytest-asyncio>=0.24", + "pyright>=1.1.411", + "ruff>=0.14", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" + +[tool.pyright] +pythonVersion = "3.11" +typeCheckingMode = "basic" +venvPath = "." +venv = ".venv" +extraPaths = ["../.."] + +[tool.ruff] +target-version = "py311" +line-length = 100 diff --git a/modules/hook-server-data-ops-lockdown/tests/test_module.py b/modules/hook-server-data-ops-lockdown/tests/test_module.py new file mode 100644 index 00000000..892698a7 --- /dev/null +++ b/modules/hook-server-data-ops-lockdown/tests/test_module.py @@ -0,0 +1,127 @@ +"""Unit tests for hook-server-data-ops-lockdown. + +Verifies: + - Module contract: __amplifier_module_type__ == "hook", mount() is a + coroutine, mount() registers a `tool:pre` handler and returns a cleanup + callable. + - The handler denies exactly the four lockdown tools (write_file, + edit_file, apply_patch, graph_query) with a HookResult(action="deny", + reason=...). + - The handler allows every other tool call server-data-ops actually + makes (session_summary, delete_session, whoami, delegate) plus a + read-only sentinel (read_file), returning HookResult(action="continue"). +""" + +from __future__ import annotations + +import inspect +from typing import Any +from unittest.mock import MagicMock + +import pytest +from amplifier_core.models import HookResult + +from amplifier_module_hook_server_data_ops_lockdown import ( + DENIED_TOOLS, + DENY_REASON, + _deny_lockdown_tools, + mount, +) + + +def _make_coordinator() -> MagicMock: + coordinator = MagicMock() + coordinator.hooks = MagicMock() + coordinator.hooks.register = MagicMock(return_value=MagicMock(name="unregister")) + return coordinator + + +class TestModuleContract: + def test_module_type_is_hook(self) -> None: + from amplifier_module_hook_server_data_ops_lockdown import ( + __amplifier_module_type__, + ) + + assert __amplifier_module_type__ == "hook" + + def test_mount_is_coroutine(self) -> None: + assert inspect.iscoroutinefunction(mount) + + def test_denied_tools_are_exactly_the_four_named(self) -> None: + assert DENIED_TOOLS == frozenset({"write_file", "edit_file", "apply_patch", "graph_query"}) + + @pytest.mark.asyncio + async def test_mount_registers_tool_pre_handler(self) -> None: + coordinator = _make_coordinator() + + await mount(coordinator, {}) + + coordinator.hooks.register.assert_called_once() + args, kwargs = coordinator.hooks.register.call_args + assert args[0] == "tool:pre" + assert args[1] is _deny_lockdown_tools + assert kwargs.get("priority") == 10 + + @pytest.mark.asyncio + async def test_mount_returns_cleanup_that_unregisters(self) -> None: + coordinator = _make_coordinator() + unregister_fn = MagicMock(name="unregister") + coordinator.hooks.register.return_value = unregister_fn + + cleanup = await mount(coordinator, {}) + assert callable(cleanup) + + cleanup() + unregister_fn.assert_called_once() + + +class TestDenyLockdownTools: + """Direct handler tests -- mirrors HOOK_CONTRACT.md's own test pattern: + `await handler("tool:pre", {"tool_name": ..., "tool_input": ...})`. + """ + + @pytest.mark.asyncio + @pytest.mark.parametrize("tool_name", ["write_file", "edit_file", "apply_patch", "graph_query"]) + async def test_denies_each_lockdown_tool(self, tool_name: str) -> None: + result = await _deny_lockdown_tools("tool:pre", {"tool_name": tool_name, "tool_input": {}}) + + assert isinstance(result, HookResult) + assert result.action == "deny" + assert result.reason == DENY_REASON + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "tool_name", + [ + "session_summary", + "delete_session", + "whoami", + "delegate", + "read_file", + "load_skill", + "todo", + ], + ) + async def test_allows_every_other_tool(self, tool_name: str) -> None: + result = await _deny_lockdown_tools("tool:pre", {"tool_name": tool_name, "tool_input": {}}) + + assert isinstance(result, HookResult) + assert result.action == "continue" + assert result.reason is None + + @pytest.mark.asyncio + async def test_missing_tool_name_is_allowed(self) -> None: + """Defensive: a data dict with no tool_name key must never be denied + (missing information is not evidence of a lockdown-tool call).""" + result = await _deny_lockdown_tools("tool:pre", {}) + + assert result.action == "continue" + + @pytest.mark.asyncio + async def test_deny_reason_is_plain_and_explains_delegation(self) -> None: + result: Any = await _deny_lockdown_tools( + "tool:pre", {"tool_name": "graph_query", "tool_input": {}} + ) + + assert "delete agent" in result.reason + assert "graph-analyst" in result.reason diff --git a/modules/hook-server-data-ops-lockdown/uv.lock b/modules/hook-server-data-ops-lockdown/uv.lock new file mode 100644 index 00000000..2219948e --- /dev/null +++ b/modules/hook-server-data-ops-lockdown/uv.lock @@ -0,0 +1,434 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "amplifier-core" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tomli" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/cd/8b0b520bf0de741ea73e069aaf64aca28c9f4ce91a7b8b9239193a6c4c1b/amplifier_core-1.6.1-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c0f711d8408de78e53e5deddcb38b7240c5c1c497ca51eeaaeff23559b3d3c48", size = 8281633, upload-time = "2026-08-10T02:38:11.98Z" }, + { url = "https://files.pythonhosted.org/packages/14/83/f4fb297d87d35b9d74058da02bb153e12f7891ab62b3aaf7e0857f877798/amplifier_core-1.6.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b08f37e2c0b1611349a0e25d5bf9bfdfae3afcee35488f8e26bba1cdd400503b", size = 7366930, upload-time = "2026-08-10T02:38:14.105Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ba/5eb9cecf92d8053c5e6d46ad9668c3ed3558d5423845c1dced1f266b2a38/amplifier_core-1.6.1-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ebf7e3993c76ea506e70ac7844b286c3ba2e9127b3bcb350fa4fcd2dcdbd38d", size = 7659512, upload-time = "2026-08-10T02:38:16.314Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/121f054e3d079dc33d83f3d8ba9af50fd9f7694c3e2ba3d7d23d7c157d48/amplifier_core-1.6.1-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c957cd0671d2a003f2c8f7d6a41bd6e808f97d183c57b97e7700bf4c912621d", size = 8678425, upload-time = "2026-08-10T02:38:18.243Z" }, + { url = "https://files.pythonhosted.org/packages/35/25/bfc217f4a9ed2d033995fc59847f1fee2e1b17130632fcb0e0981a1a311b/amplifier_core-1.6.1-cp311-abi3-win_amd64.whl", hash = "sha256:50c80bcfa1f6efe769b19e7af18c925024c7553d4db08880727241709dd44eae", size = 8976601, upload-time = "2026-08-10T02:38:20.505Z" }, + { url = "https://files.pythonhosted.org/packages/a5/14/5f330452c92c6c5d35c51ad5311301949ce5db4d1a1a901456f3ee43eaac/amplifier_core-1.6.1-cp311-abi3-win_arm64.whl", hash = "sha256:cd8b617f132cf5d1ca3e5187d5f831d1f2a508bb40d07b2ab1085961bcb9e1a9", size = 7744837, upload-time = "2026-08-10T02:38:22.562Z" }, +] + +[[package]] +name = "amplifier-module-hook-server-data-ops-lockdown" +version = "0.1.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "amplifier-core" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "amplifier-core", specifier = ">=1.6.0" }, + { name = "pyright", specifier = ">=1.1.411" }, + { name = "pytest", specifier = ">=9.0.3" }, + { name = "pytest-asyncio", specifier = ">=0.24" }, + { name = "ruff", specifier = ">=0.14" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" }, + { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" }, + { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" }, + { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" }, + { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" }, + { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" }, + { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.411" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" }, + { url = "https://files.pythonhosted.org/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" }, + { url = "https://files.pythonhosted.org/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" }, + { url = "https://files.pythonhosted.org/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" }, + { url = "https://files.pythonhosted.org/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" }, + { url = "https://files.pythonhosted.org/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" }, + { url = "https://files.pythonhosted.org/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" }, + { url = "https://files.pythonhosted.org/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" }, + { url = "https://files.pythonhosted.org/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] From af22135cdcaad4271663b74c3f81428324a1f89d Mon Sep 17 00:00:00 2001 From: colombod Date: Wed, 2 Sep 2026 13:08:02 +0000 Subject: [PATCH 21/39] docs(server-data-ops): drop stale graph_query references from agent + skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent's frontmatter already dropped tool-context-intelligence-query and a lockdown hook already denies graph_query for this agent, but the body and skill prose still described the agent building narratives itself via graph_query. Update both to match reality: search and narrative both come from delegating to graph-analyst. - Flow 2 / Flow 2-folder: merge search + narrate into one delegated step — graph-analyst now returns a synthesized overview alongside each candidate, so the agent presents it instead of building it via graph_query. - Tools list: remove the stale graph_query bullet in both files; note the lockdown hook denies direct graph access, write_file, edit_file, and apply_patch for this agent. - No behavior change beyond wording — Flow 1, Flow 3, exclusion scope, all-servers completeness, preview/confirm, and 404/409 handling untouched. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- agents/server-data-ops.md | 57 +++++++++--------- .../SKILL.md | 60 ++++++++++--------- 2 files changed, 59 insertions(+), 58 deletions(-) diff --git a/agents/server-data-ops.md b/agents/server-data-ops.md index 261d79ef..6cc01004 100644 --- a/agents/server-data-ops.md +++ b/agents/server-data-ops.md @@ -32,8 +32,8 @@ tools: source: git+https://github.com/microsoft/amplifier-module-tool-todo@main # Agent-scoped lockdown: this agent must never call write_file, edit_file, -# apply_patch, or graph_query, regardless of how it comes to have them -# available (inheritance, a future tools: change, etc.). Declared here -- +# apply_patch, or the graph-query tool, regardless of how it comes to have +# them available (inheritance, a future tools: change, etc.). Declared here -- # on the CONSUMING agent -- rather than as a behavior-level exclude_tools # policy, so the restriction never collides with sibling agents (e.g. # graph-analyst, session-navigator) that legitimately need these tools. @@ -70,15 +70,16 @@ structured work; you handle the conversation and the narrative. graph on a server. - `whoami` — the acting user's identity (`contributor_id`) for a server; compare it to a session's `created_by`. -- `delegate` — used to delegate session search to `graph-analyst` (it has the - data-navigation skills); never used for the narrative summary. -- `graph_query` — used to read a found session's root prompts to build the narrative - yourself, and for direct lookups. +- `delegate` — used to delegate session search to `graph-analyst`, which returns both + the candidate session(s) and a short synthesized overview for each; you present those, + you never build them yourself. - `todo` — track a bulk cleanup (Flow 2-folder) one item per session, so a multi-session delete never silently skips one. - `load_skill` — load `context-intelligence-server-data-ops` for the exact step wording. -No filesystem or bash tool, by design. +No filesystem or bash tool, and no direct graph-query tool — a lockdown hook enforces +this. You guide the user to edit settings themselves, and you delegate all search and +narrative work to `graph-analyst` instead of querying the graph yourself. ## Rules that always hold @@ -123,24 +124,21 @@ No filesystem or bash tool, by design. ## Flow 2 — find a session by description, then delete -1. **Search.** If the user describes the session by topic, content, date, or any other - non-trivial criteria ("the session about X", "sessions that discussed Y", "my - session from last week about Z"), delegate to `graph-analyst` to run the search — it - has the data-navigation skills to query the graph, and returns the candidate - session(s): their ids and key facts. This is a data-fetch delegation, not a hand-off - of the conversation — the results come back to you and you keep driving the flow. - Skip the delegation only for a trivial direct lookup — the user names an exact - session id — and call `session_summary` on it directly instead. -2. **Narrate.** Build the short overview yourself: call `graph_query` to read that - session's **root**-session prompts only (never subsessions), then write a short - synthesized overview, in your own words, of what the session was about — its scope - and intent. This must be a synthesis, never a raw or verbatim prompt quote, and never - a from-memory guess. If `graph_query` returns nothing usable for the root prompts, - write "not available." Never delegate this step to `graph-analyst`. Do this before - presenting any details block. -3. **Present** the candidate details block(s) to the user directly; the user picks one. -4. **Ownership** — run the Flow 3 check. -5. **Preview → confirm → delete and verify on every server.** +1. **Search + narrate.** If the user describes the session by topic, content, date, or + any other non-trivial criteria ("the session about X", "sessions that discussed Y", + "my session from last week about Z"), delegate to `graph-analyst`. It runs the + search and returns, for each candidate, both the key facts and a short synthesized + overview of what the session was about (built from that session's root prompts, + never subsessions) — you present those overviews directly; you never build the + narrative yourself and never query the graph. The overview must be a synthesis, + never a raw or verbatim prompt quote — if `graph-analyst` can't produce one, use + "not available." This is a data-fetch delegation, not a hand-off of the + conversation — the results come back to you and you keep driving the flow. Skip the + delegation only for a trivial direct lookup — the user names an exact session id, or + means the current session — and call `session_summary` on it directly instead. +2. **Present** the candidate details block(s) to the user directly; the user picks one. +3. **Ownership** — run the Flow 3 check. +4. **Preview → confirm → delete and verify on every server.** ## Flow 2-folder — clean up everything pushed from this working directory (folder + mine) @@ -162,11 +160,12 @@ should never have been pushed." 2. **Run the S2 search, by criteria (this folder + mine).** Delegate to `graph-analyst` to find every ROOT session (never subsessions) where `working_dir` matches AND `created_by` is you, across every configured server (all-servers - completeness applies to the search too). + completeness applies to the search too) — it returns each candidate with a short + synthesized overview alongside the facts, same as Flow 2. 3. **Propose the list.** Present each found root session to the user directly as a - session-details block (with its own synthesized summary, per Flow 2's rule), and - build a todo list — one item per session — so every one is tracked and none is - silently missed. + session-details block (using the overview `graph-analyst` returned, per Flow 2's + rule), and build a todo list — one item per session — so every one is tracked and + none is silently missed. 4. **Delete all.** Walk the todo list. For each session, run the normal preview → impact → explicit confirm → delete → verify on every server it is on (all-servers completeness). The Flow 3 ownership check still applies per session — diff --git a/skills/context-intelligence-server-data-ops/SKILL.md b/skills/context-intelligence-server-data-ops/SKILL.md index fe643d0b..bff83efc 100644 --- a/skills/context-intelligence-server-data-ops/SKILL.md +++ b/skills/context-intelligence-server-data-ops/SKILL.md @@ -59,6 +59,10 @@ session by id. - **`todo`** — the standard todo-list tool. Used only in Flow 2-folder, one item per candidate root session found, so a bulk cleanup never silently skips one. +A lockdown hook denies this agent write_file, edit_file, apply_patch, and any direct +graph-query tool — it guides the user through settings edits instead of making them, +and delegates all search and narrative work to `graph-analyst`. + --- ## Key Concepts @@ -106,12 +110,12 @@ Session Fill every field from a real `session_summary` call. Never fabricate a value you didn't receive. -**Summary line.** Not returned by the server — build it yourself. Call `graph_query` -to read that session's **root**-session prompts only (never subsessions), then write a -short synthesized overview, in your own words, of what the session was about — its -scope and intent. This must be a synthesis, never a raw or verbatim prompt quote, and -never a from-memory guess. If `graph_query` returns nothing usable for the root -prompts, write "not available." +**Summary line.** Not returned by the server, and not built by this agent — it comes +from `graph-analyst`, delegated to alongside the search (see Flow 2 step 2). It returns +a short synthesized overview of what the session was about, built from that session's +**root**-session prompts only (never subsessions). This must be a synthesis, never a +raw or verbatim prompt quote, and never a from-memory guess. If `graph-analyst` can't +produce one, use "not available." --- @@ -155,23 +159,20 @@ directory, not just the current session ("this folder," "this working directory, 1. Take the user's description (topic, content, date range, sometimes a server/workspace). -2. **Search.** For any non-trivial criteria (topic, content, date, workspace), - delegate to `graph-analyst` — it has the data-navigation skills to query the graph - and returns the candidate session(s): their ids and key facts. This is a data-fetch - delegation, not a hand-off of the conversation — the results come back to you and - you keep driving the flow. Skip the delegation only for a trivial direct lookup — - the user names an exact session id — and call `session_summary` on it directly. - Cap the candidate set to a handful before per-candidate work. -3. For each candidate, call `session_summary` for the facts, then build the narrative - yourself (see "Summary line" above; never delegate this part to `graph-analyst`) — - every candidate gets either a real synthesized narrative or an explicit "not - available" before it's presented; never a summary written from memory or a prompt - quote. -4. Build a Session Details Block per candidate and present them; the user picks one. -5. Run the Flow 3 ownership check on the chosen session. -6. Re-run `session_summary` on the chosen id right before delete (a fresh preview, in - case anything changed since step 3). -7. Confirm explicitly — id, counts, server(s) — then delete and verify on every server +2. **Search + narrate.** For any non-trivial criteria (topic, content, date, workspace), + delegate to `graph-analyst`. It runs the search AND returns, for each candidate, both + the key facts and a short synthesized overview (see "Summary line" above) — you never + query the graph or build the narrative yourself. This is a data-fetch delegation, not + a hand-off of the conversation — the results come back to you and you keep driving + the flow. Skip the delegation only for a trivial direct lookup — the user names an + exact session id — and call `session_summary` on it directly. Cap the candidate set + to a handful before per-candidate work. +3. Build a Session Details Block per candidate, using the overview `graph-analyst` + returned (or "not available"), and present them; the user picks one. +4. Run the Flow 3 ownership check on the chosen session. +5. Re-run `session_summary` on the chosen id right before delete (a fresh preview, in + case anything changed since step 2). +6. Confirm explicitly — id, counts, server(s) — then delete and verify on every server (see Multi-Server Handling). --- @@ -205,12 +206,13 @@ description. `graph-analyst` to enumerate every **root** session (never subsessions) whose `working_dir` matches the resolved directory AND `created_by` is you, checking every configured server (all-servers completeness applies to the - search too, not only the deletes). It returns the candidate root sessions - and their key facts. -3. **Propose the list.** For each candidate, build the narrative the same way - Flow 2 does (see "Summary line" above), present it as a Session Details - Block, and add one item to a todo list (the `todo` tool) per session found - — so every session is tracked and none is silently skipped. + search too, not only the deletes). It returns the candidate root sessions, + their key facts, and a short synthesized overview for each — same as + Flow 2's search step. +3. **Propose the list.** For each candidate, build a Session Details Block + using the overview `graph-analyst` returned (see "Summary line" above), + present it, and add one item to a todo list (the `todo` tool) per session + found — so every session is tracked and none is silently skipped. 4. **Delete all.** Walk the todo list one session at a time. For each: re-run `session_summary` (a fresh preview), run the Flow 3 ownership check (a session in this folder may not be the user's own), state the impact, From c42efd62de8f405e2c145d3ef8c2c456741cc2a1 Mon Sep 17 00:00:00 2001 From: colombod Date: Wed, 2 Sep 2026 14:17:43 +0000 Subject: [PATCH 22/39] fix(server-data-ops-lockdown): stop hook denying graph-analyst's graph_query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug (proven in a DTU eval): hook-server-data-ops-lockdown is declared in server-data-ops's own frontmatter (hooks:), but hook inheritance from a parent session to a delegated child is ADDITIVE BY DEFAULT -- the same rule as tool inheritance. So when server-data-ops delegated search to graph-analyst, graph-analyst's spawned session ALSO inherited this hook and its own legitimate graph_query calls got denied. Flow 2/2-folder failed entirely because search never ran. Verified before fixing: - server-data-ops's own tools: list does not (and, per commit a897c2d, deliberately does not) declare tool-context-intelligence-query, so it never has graph_query available to call itself -- every graph_query in the DTU trace was graph-analyst's own call, not server-data-ops's. - core:docs/contracts/ORCHESTRATOR_CONTRACT.md's reference tool:pre emit call, and HOOK_CONTRACT.md's field table, both show the event's documented payload carries only tool_name/tool_input -- no session or agent identity. So a handler receiving (event, data) cannot itself distinguish "server-data-ops's own call" from "a descendant session's call" -- ruling out an in-handler session check as a fix. Fix: session-scope the WHOLE hook (not just graph_query) at the delegation boundary, where the actual inheritance decision is made. agents/server-data-ops.md's own tool-delegate entry now sets settings.exclude_hooks: [hook-server-data-ops-lockdown], mirroring the existing settings.exclude_tools precedent in behaviors/context-intelligence-analysis.yaml. This stops the hook from being composed onto any session server-data-ops spawns, while it stays fully in force for server-data-ops's own tool calls (its hooks: declaration is untouched). No change to DENIED_TOOLS -- the fix lives in composition, not in the deny list. This also corrects the write_file/edit_file/apply_patch denials, which were equally (if harmlessly, since graph-analyst/session-navigator are read-only) leaking subtree-wide before this fix -- they are now properly scoped to server-data-ops's own session too. Docstring corrected: the hook module's docstring previously claimed "It has no effect on graph-analyst, session-navigator... regardless of how tool inheritance evolves" -- false without the exclude_hooks companion setting. Rewritten to document the subtree leak, the fix, and why an in-handler identity check isn't possible, with contract citations. Tests: added TestSessionScopeComposition to modules/hook-server-data-ops-lockdown/tests/test_module.py, parsing agents/server-data-ops.md's frontmatter to verify (a) the hook is still declared for server-data-ops's own session, (b) tool-delegate's settings.exclude_hooks names this hook's own module id, and (c) tool-context-intelligence-query still is not among server-data-ops's own tools (graph_query stays unreachable to it directly). Verified the new composition test actually catches the regression by reverting the agent frontmatter change and re-running (1 failure, as expected) before restoring it. Added PyYAML>=6.0 as an explicit dev dependency for this parsing (matches the version floor already used at repo root). Testing: 21/21 tests pass (18 pre-existing + 3 new), ruff check clean, ruff format clean, pyright 0 errors/0 warnings. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- agents/server-data-ops.md | 28 +++++++ .../__init__.py | 53 +++++++++++-- .../pyproject.toml | 6 ++ .../tests/test_module.py | 77 +++++++++++++++++++ modules/hook-server-data-ops-lockdown/uv.lock | 2 + 5 files changed, 159 insertions(+), 7 deletions(-) diff --git a/agents/server-data-ops.md b/agents/server-data-ops.md index 6cc01004..b9c2ed42 100644 --- a/agents/server-data-ops.md +++ b/agents/server-data-ops.md @@ -21,6 +21,23 @@ model_role: [reasoning, general] tools: - module: tool-delegate source: git+https://github.com/microsoft/amplifier-foundation@main#subdirectory=modules/tool-delegate + config: + settings: + # Prevent this agent's own lockdown hook (declared below, hooks:) + # from leaking onto any session this agent delegates to (e.g. + # graph-analyst, which legitimately needs graph_query to run the + # searches this agent delegates to it). Hook inheritance from a + # parent session to a spawned child is ADDITIVE BY DEFAULT, exactly + # like tool inheritance -- see tool-delegate's own + # settings.exclude_hooks / settings.exclude_tools symmetry in + # amplifier-foundation's tool-delegate module. Without this entry, + # hook-server-data-ops-lockdown would also mount on graph-analyst's + # spawned session and deny ITS legitimate graph_query calls -- this + # is exactly the bug a DTU eval caught (Flow 2-folder failed + # entirely because search never ran). See + # hook-server-data-ops-lockdown's own module docstring for the full + # story. + exclude_hooks: [hook-server-data-ops-lockdown] - module: tool-server-data-ops source: git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=modules/tool-server-data-ops - module: tool-skills @@ -37,6 +54,17 @@ tools: # on the CONSUMING agent -- rather than as a behavior-level exclude_tools # policy, so the restriction never collides with sibling agents (e.g. # graph-analyst, session-navigator) that legitimately need these tools. +# +# This declaration alone only bounds WHICH session mounts the hook (this +# agent's own session, since it is server-data-ops's own hooks: entry). It +# does NOT, by itself, stop the hook from leaking onto sessions this agent +# delegates to -- hook inheritance to a spawned child is additive by +# default, the same rule as tool inheritance. The tool-delegate entry above +# (settings.exclude_hooks) is what actually keeps this hook from denying +# graph-analyst's own graph_query calls when this agent delegates search to +# it. The two declarations are a matched pair; removing either one +# reopens a gap (this hook leaking to descendants, or this agent itself +# regaining unrestricted tool access on a future tools: change). hooks: - module: hook-server-data-ops-lockdown source: git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=modules/hook-server-data-ops-lockdown diff --git a/modules/hook-server-data-ops-lockdown/amplifier_module_hook_server_data_ops_lockdown/__init__.py b/modules/hook-server-data-ops-lockdown/amplifier_module_hook_server_data_ops_lockdown/__init__.py index 17bfc9b0..2bbff8a1 100644 --- a/modules/hook-server-data-ops-lockdown/amplifier_module_hook_server_data_ops_lockdown/__init__.py +++ b/modules/hook-server-data-ops-lockdown/amplifier_module_hook_server_data_ops_lockdown/__init__.py @@ -19,13 +19,52 @@ happens to share a behavior with it. A `tool:pre` deny hook declared in the agent's OWN frontmatter (`hooks:`, -sibling to `tools:`) is the mechanism that achieves this: it is scoped to -sessions that mount this hook module, which server-data-ops declares for -itself. It has no effect on graph-analyst, session-navigator, or any other -agent, regardless of how tool inheritance evolves around them. It also -holds even if a future change to server-data-ops's own `tools:` list (or -to what it inherits) were to re-introduce one of these tools -- the deny is -enforced at call time, not just at composition time. +sibling to `tools:`) bounds WHICH SESSION MOUNTS this hook module: only +server-data-ops's own session, since only its own agent definition declares +it. That much is true regardless of anything else. It also holds even if a +future change to server-data-ops's own `tools:` list (or to what it +inherits) were to re-introduce one of these tools -- the deny is enforced +at call time, not just at composition time. + +SUBTREE LEAK, found via a DTU eval and fixed alongside this hook: mounting +the hook on server-data-ops's own session does NOT, by itself, stop it from +also reaching every session server-data-ops SPAWNS. Hook inheritance from a +parent session to a delegated child is ADDITIVE BY DEFAULT -- exactly the +same rule as tool inheritance -- see amplifier-foundation's tool-delegate +module (`settings.exclude_hooks`, which mirrors `settings.exclude_tools` +field-for-field; both are consumed by `_spawn_new_session()` to build an +inheritance-filtering policy the app-layer spawn capability applies to the +child). Left unexcluded, this hook -- mounted on server-data-ops's own +session -- ALSO mounted on graph-analyst's spawned session whenever +server-data-ops delegated search to it, and denied graph-analyst's own, +entirely legitimate `graph_query` calls. That is precisely what broke Flow +2 / Flow 2-folder: search never ran, so no candidate sessions were ever +found to delete. + +THE FIX: agents/server-data-ops.md's own `tools:` entry for `tool-delegate` +sets `config.settings.exclude_hooks: ["hook-server-data-ops-lockdown"]` +(this module's own id). That setting -- not anything in this file -- is +what stops this hook from being composed onto any session server-data-ops +spawns, while leaving it fully in force for server-data-ops's OWN tool +calls (its `hooks:` declaration is untouched; only its inheritance into +descendants is excluded). With that setting in place, this hook has no +effect on graph-analyst, session-navigator, or any other agent +server-data-ops delegates to -- but only because of that setting. Removing +it reopens the subtree leak even though nothing in this file's own deny +logic changes. + +Why the fix is not "check which agent/session is calling" inside the +handler below: the `tool:pre` event's documented payload carries ONLY +`tool_name` and `tool_input` -- see the reference emit call in +`core:docs/contracts/ORCHESTRATOR_CONTRACT.md` +(`await hooks.emit("tool:pre", {"tool_name": ..., "tool_input": ...})`) and +the field table in `core:docs/contracts/HOOK_CONTRACT.md` +(`tool:pre | Before tool execution | tool_name, tool_input`). No session +id, agent name, or other identity field is part of the documented +contract, so a handler receiving `(event, data)` structurally cannot tell +"server-data-ops's own call" apart from "a descendant session's call." +Session-scoping has to happen at the delegation boundary +(`exclude_hooks`), not inside this handler. Contract references (verified against amplifier-core docs before writing this handler): diff --git a/modules/hook-server-data-ops-lockdown/pyproject.toml b/modules/hook-server-data-ops-lockdown/pyproject.toml index a7f972d1..3e72a5ea 100644 --- a/modules/hook-server-data-ops-lockdown/pyproject.toml +++ b/modules/hook-server-data-ops-lockdown/pyproject.toml @@ -30,6 +30,12 @@ dev = [ "pytest-asyncio>=0.24", "pyright>=1.1.411", "ruff>=0.14", + # Parses agents/server-data-ops.md's frontmatter in + # tests/test_module.py's TestSessionScopeComposition -- verifies the + # companion settings.exclude_hooks fix (see this module's own + # docstring) is actually declared, not just documented. Same version + # floor as this repo's own root pyproject.toml dev group. + "PyYAML>=6.0", ] [tool.pytest.ini_options] diff --git a/modules/hook-server-data-ops-lockdown/tests/test_module.py b/modules/hook-server-data-ops-lockdown/tests/test_module.py index 892698a7..359d0a97 100644 --- a/modules/hook-server-data-ops-lockdown/tests/test_module.py +++ b/modules/hook-server-data-ops-lockdown/tests/test_module.py @@ -10,15 +10,22 @@ - The handler allows every other tool call server-data-ops actually makes (session_summary, delete_session, whoami, delegate) plus a read-only sentinel (read_file), returning HookResult(action="continue"). + - Session-scope composition: agents/server-data-ops.md declares the + companion settings.exclude_hooks fix (see TestSessionScopeComposition + below and this module's own docstring for why the handler itself + cannot do this -- the tool:pre payload carries no session/agent + identity to check against). """ from __future__ import annotations import inspect +from pathlib import Path from typing import Any from unittest.mock import MagicMock import pytest +import yaml from amplifier_core.models import HookResult from amplifier_module_hook_server_data_ops_lockdown import ( @@ -28,6 +35,10 @@ mount, ) +# modules/hook-server-data-ops-lockdown/tests/test_module.py -> repo root +REPO_ROOT = Path(__file__).resolve().parents[3] +SERVER_DATA_OPS_AGENT = REPO_ROOT / "agents" / "server-data-ops.md" + def _make_coordinator() -> MagicMock: coordinator = MagicMock() @@ -125,3 +136,69 @@ async def test_deny_reason_is_plain_and_explains_delegation(self) -> None: assert "delete agent" in result.reason assert "graph-analyst" in result.reason + + +class TestSessionScopeComposition: + """Proves the session-scoping fix for the subtree leak this module's own + docstring documents (a DTU eval caught it: graph-analyst's legitimate + graph_query calls were denied whenever server-data-ops delegated search + to it, because this hook -- mounted on server-data-ops's own session -- + was ALSO inherited by every session server-data-ops spawned). + + The handler under test (`_deny_lockdown_tools`) has zero session/agent + awareness -- it only ever inspects `tool_name` -- and the `tool:pre` + event's documented payload (core:docs/contracts/ORCHESTRATOR_CONTRACT.md, + HOOK_CONTRACT.md) carries no session or agent identity to check + against, so the handler structurally cannot distinguish + "server-data-ops's own call" from "a descendant session's call". These + tests cannot exercise a real delegate spawn (that requires the + app-layer session_spawner.py, only exercised in a DTU); instead they + verify the actual fix -- agents/server-data-ops.md's own tool-delegate + config excluding this hook module from inheritance -- is in place. + """ + + @staticmethod + def _server_data_ops_tools() -> dict[str, dict[str, Any]]: + text = SERVER_DATA_OPS_AGENT.read_text(encoding="utf-8") + _, frontmatter, _ = text.split("---", 2) + config = yaml.safe_load(frontmatter) + return {t["module"]: t for t in config.get("tools", [])} + + @staticmethod + def _server_data_ops_hooks() -> dict[str, dict[str, Any]]: + text = SERVER_DATA_OPS_AGENT.read_text(encoding="utf-8") + _, frontmatter, _ = text.split("---", 2) + config = yaml.safe_load(frontmatter) + return {h["module"]: h for h in config.get("hooks", [])} + + def test_agent_declares_this_hook(self) -> None: + """Sanity check: server-data-ops still mounts this hook module for + its own session (the hook is meaningless if this ever drops).""" + hooks = self._server_data_ops_hooks() + + assert "hook-server-data-ops-lockdown" in hooks + + def test_agent_excludes_this_hook_from_delegated_sessions(self) -> None: + """The actual fix: tool-delegate's settings.exclude_hooks must name + this hook's own module id, so a spawned child (graph-analyst, + session-navigator, ...) never inherits it.""" + tools = self._server_data_ops_tools() + + assert "tool-delegate" in tools, "server-data-ops must declare tool-delegate in tools:" + settings = tools["tool-delegate"].get("config", {}).get("settings", {}) + excluded_hooks = settings.get("exclude_hooks", []) + + assert "hook-server-data-ops-lockdown" in excluded_hooks, ( + "settings.exclude_hooks must list this hook's own module id, or the " + "subtree leak documented in this module's docstring reopens" + ) + + def test_agent_does_not_mount_graph_query_tool(self) -> None: + """Corroborates this module's docstring claim (verified against + history at commit a897c2d): server-data-ops's own tools: list never + declares tool-context-intelligence-query, so it never has graph_query + available to call directly -- all searching goes through graph-analyst + via delegation instead.""" + tools = self._server_data_ops_tools() + + assert "tool-context-intelligence-query" not in tools diff --git a/modules/hook-server-data-ops-lockdown/uv.lock b/modules/hook-server-data-ops-lockdown/uv.lock index 2219948e..410c956a 100644 --- a/modules/hook-server-data-ops-lockdown/uv.lock +++ b/modules/hook-server-data-ops-lockdown/uv.lock @@ -33,6 +33,7 @@ dev = [ { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pyyaml" }, { name = "ruff" }, ] @@ -44,6 +45,7 @@ dev = [ { name = "pyright", specifier = ">=1.1.411" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "pytest-asyncio", specifier = ">=0.24" }, + { name = "pyyaml", specifier = ">=6.0" }, { name = "ruff", specifier = ">=0.14" }, ] From 6314c3c758138335c89815e5b74c1b03868314ad Mon Sep 17 00:00:00 2001 From: colombod Date: Wed, 2 Sep 2026 14:19:55 +0000 Subject: [PATCH 23/39] fix(server-data-ops): disambiguate Flow 1 exclusion offer from delete scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flow 1's exclusion-offer step already showed the correct destinations..exclude push-filter setting, but a DTU eval showed the model conflating it with excluding sub-sessions from the delete scope. Make the framing explicit in both files: state up front that this is the destination push filter on working_dir, and add a guard line that deletion always removes the whole session graph regardless of this setting. Flow 2-folder references "same as Flow 1 step 3" in both files, so it inherits the fix without duplication. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- agents/server-data-ops.md | 6 ++++-- skills/context-intelligence-server-data-ops/SKILL.md | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/agents/server-data-ops.md b/agents/server-data-ops.md index b9c2ed42..8708fd45 100644 --- a/agents/server-data-ops.md +++ b/agents/server-data-ops.md @@ -141,11 +141,13 @@ narrative work to `graph-analyst` instead of querying the graph yourself. (flag if under a minute — may still be live). If it 404s on every server, stop and say it is not on the server(s). 3. **Offer the folder exclusion to the user directly, and wait for their answer** — - always, before impact/confirm/delete. Show the setting + always, before impact/confirm/delete. This is the destination **push filter** on the + session's `working_dir`, not the delete scope: show the setting `overrides.hook-context-intelligence.config.destinations..exclude` in `~/.amplifier/settings.yaml` (a gitignore-style pattern matched on `working_dir`) in your own user-facing message, and offer to guide them through applying it. You never - edit the file yourself. + edit the file yourself. **Not about sub-sessions or delete scope** — deletion always + removes the whole session graph regardless of this setting. 4. **Impact.** State it (see "Impact + permanence"). 5. **Confirm.** Explicit, naming the id and server(s). 6. **Delete and verify on every server** (all-servers completeness). diff --git a/skills/context-intelligence-server-data-ops/SKILL.md b/skills/context-intelligence-server-data-ops/SKILL.md index bff83efc..3a854913 100644 --- a/skills/context-intelligence-server-data-ops/SKILL.md +++ b/skills/context-intelligence-server-data-ops/SKILL.md @@ -137,7 +137,9 @@ directory, not just the current session ("this folder," "this working directory, Note every server the session exists on, from `list_sources`. If it 404s on every server, stop and tell the user plainly — never delete against an unresolved session. 3. **Offer the folder exclusion to the user directly, and wait for their answer**, - before anything else proceeds. Show the setting + before anything else proceeds. This is the destination **push filter** on the + session's `working_dir`, not the delete scope — deletion always removes the whole + session graph regardless of this setting. Show the setting `overrides.hook-context-intelligence.config.destinations..exclude` in `~/.amplifier/settings.yaml` — a list of gitignore-style patterns matched against a session's `working_dir`; adding one for the current folder stops that destination From 58c02bb4441f2d768b99b22a52ed2a57f06c8f45 Mon Sep 17 00:00:00 2001 From: colombod Date: Wed, 2 Sep 2026 15:01:06 +0000 Subject: [PATCH 24/39] fix(server-data-ops): close delegation-bypass hole around lockdown hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server-data-ops's own tool-lockdown hook (hook-server-data-ops-lockdown) denies write_file/edit_file/apply_patch/graph_query for its OWN tool calls, but it is intentionally scoped (via tool-delegate's settings.exclude_hooks) to NOT apply to sessions it delegates to -- that scoping is required so graph-analyst's own graph_query calls aren't wrongly denied. Without a delegation allowlist, that same scoping meant server-data-ops could delegate a file-write to ANY other agent (e.g. foundation:file-ops) and have it succeed unchecked. A DTU eval proved exactly this: server-data-ops delegated a settings.yaml edit to foundation:file-ops, and file-ops wrote the file, bypassing the lockdown entirely. Fix: add a top-level `agents:` allowlist to server-data-ops.md's own frontmatter (sibling of tools:/hooks:, recognized by amplifier_foundation.bundle._dataclass._load_agent_file_metadata and forwarded to amplifier-app-cli's agent_config.merge_configs / session_spawner.py's live-registry reconciliation), restricting delegation to exactly context-intelligence:graph-analyst -- the only agent server-data-ops's own flows ever delegate to. The allowlist filter does an exact-string `k in agent_filter` check against the parent's composed agent-roster keys, which are namespaced in this bundle (behaviors/context-intelligence-analysis.yaml and context-intelligence-navigation.yaml both register agents as "context-intelligence:graph-analyst" / "context-intelligence:server-data-ops"), so the allowlist entry must use that same namespaced form. Adds TestDelegationAllowlist to the lockdown hook's test module, parsing server-data-ops.md's frontmatter to assert the allowlist exists and is exactly ["context-intelligence:graph-analyst"] -- verified to fail when temporarily widened to "all" and to pass again on revert. Agent body prose is untouched; a separate pass reframes the exclusion wording. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- agents/server-data-ops.md | 27 +++++++++ .../tests/test_module.py | 59 +++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/agents/server-data-ops.md b/agents/server-data-ops.md index 8708fd45..4a6de991 100644 --- a/agents/server-data-ops.md +++ b/agents/server-data-ops.md @@ -68,6 +68,33 @@ tools: hooks: - module: hook-server-data-ops-lockdown source: git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=modules/hook-server-data-ops-lockdown + +# Delegation allowlist: restricts which sub-agent server-data-ops's spawned +# session may delegate to, closing the gap the lockdown hook above cannot +# close on its own. hook-server-data-ops-lockdown is scoped (via the +# tool-delegate settings.exclude_hooks entry above) to NOT apply to sessions +# this agent delegates to -- that scoping is required so graph-analyst's own +# graph_query calls aren't wrongly denied. But without an `agents:` allowlist, +# that same scoping meant server-data-ops could delegate a file-write to ANY +# other agent (e.g. foundation:file-ops) and have it succeed unchecked -- a +# DTU eval proved exactly this: server-data-ops delegated the settings.yaml +# edit to foundation:file-ops, which wrote the file, bypassing the lockdown +# entirely. Restricting delegation to graph-analyst (the only agent this +# agent's own flows ever delegate to -- see "Tools" section below) closes the +# hole structurally: server-data-ops can no longer reach a write-capable +# agent via delegation at all, regardless of what tools that agent has. +# +# Value shape: a list of agent names as they appear as KEYS in this bundle's +# composed agent roster (behaviors/context-intelligence-analysis.yaml and +# behaviors/context-intelligence-navigation.yaml both register agents under +# namespaced keys, e.g. "context-intelligence:graph-analyst") -- NOT bare +# agent names. The access-control filter +# (amplifier-app-cli's agent_config.merge_configs, and its session_spawner.py +# live-registry reconciliation) does an exact-string membership check +# (`k in agent_filter`) against those same roster keys, so a bare "graph-analyst" +# here would silently match nothing and disable delegation entirely. +agents: + - context-intelligence:graph-analyst --- # Server Data Ops diff --git a/modules/hook-server-data-ops-lockdown/tests/test_module.py b/modules/hook-server-data-ops-lockdown/tests/test_module.py index 359d0a97..1d32bf28 100644 --- a/modules/hook-server-data-ops-lockdown/tests/test_module.py +++ b/modules/hook-server-data-ops-lockdown/tests/test_module.py @@ -202,3 +202,62 @@ def test_agent_does_not_mount_graph_query_tool(self) -> None: tools = self._server_data_ops_tools() assert "tool-context-intelligence-query" not in tools + + +class TestDelegationAllowlist: + """Proves the companion fix for the delegation-bypass hole this module's + docstring and TestSessionScopeComposition document: the lockdown hook is + (correctly) scoped OFF of sessions server-data-ops delegates to, so + without a delegation allowlist server-data-ops could hand a file-write + to ANY other agent (e.g. foundation:file-ops) and have it succeed + unchecked -- a DTU eval proved exactly this for the settings.yaml edit. + + The fix is a top-level `agents:` allowlist in server-data-ops.md's own + frontmatter (a sibling of `tools:`/`hooks:`, recognized by + amplifier_foundation.bundle._dataclass._load_agent_file_metadata and + forwarded to amplifier-app-cli's agent_config.merge_configs / + session_spawner.py, which filter the parent's agent roster with an + exact-string `k in agent_filter` membership check). This test locks the + allowlist to EXACTLY the one agent server-data-ops's own flows delegate + to (graph-analyst) -- a future edit that widens it (e.g. back to + unrestricted, or to add a second agent) must fail this test. + + Value-shape note: the roster keys these allowlist entries are checked + against are NAMESPACED (this repo's own behaviors/ + context-intelligence-analysis.yaml and + context-intelligence-navigation.yaml both register agents as + "context-intelligence:graph-analyst", "context-intelligence:server-data-ops"), + not bare names -- so the allowlist entry must be the namespaced form or + it silently matches nothing and disables delegation entirely. + """ + + @staticmethod + def _server_data_ops_agents_allowlist() -> Any: + text = SERVER_DATA_OPS_AGENT.read_text(encoding="utf-8") + _, frontmatter, _ = text.split("---", 2) + config = yaml.safe_load(frontmatter) + return config.get("agents") + + def test_agent_declares_a_delegation_allowlist(self) -> None: + """Sanity check: the top-level `agents:` key must exist at all -- + its absence means unrestricted delegation (the original hole).""" + allowlist = self._server_data_ops_agents_allowlist() + + assert allowlist is not None, ( + "server-data-ops.md must declare a top-level `agents:` allowlist, or " + "it can delegate a file-write to any agent, bypassing the lockdown hook" + ) + + def test_delegation_allowlist_is_exactly_graph_analyst(self) -> None: + """The actual fix: the allowlist must contain exactly the one agent + server-data-ops legitimately delegates to, namespaced as it appears + in this bundle's own composed roster. Widening this list (to "all", + or to include any other agent) must fail this test.""" + allowlist = self._server_data_ops_agents_allowlist() + + assert allowlist == ["context-intelligence:graph-analyst"], ( + "server-data-ops.md's `agents:` allowlist must be exactly " + "['context-intelligence:graph-analyst'] -- widening it reopens the " + "delegation-bypass hole (delegating a write to e.g. foundation:file-ops " + "around the lockdown hook, which does not apply to delegated sessions)" + ) From b25b849abe080a4d7f2674c8fda61e90c4febddf Mon Sep 17 00:00:00 2001 From: colombod Date: Wed, 2 Sep 2026 15:10:00 +0000 Subject: [PATCH 25/39] test(server-data-ops-lockdown): remove brittle frontmatter-string tests Drop TestSessionScopeComposition and TestDelegationAllowlist: they parsed the agent .md YAML and asserted the strings we typed, proving only 'the file says what we wrote' -- false confidence, failing only if someone edits the same line the test reads. The real guarantees they gestured at (the lockdown hook does not leak into graph-analyst's delegated session; server-data-ops cannot hand a file-write to another agent) are behavioural properties, proven in the DTU security-validation profile, not by grepping a file. Keep the genuine handler behavioural tests (deny the four lockdown tools, allow everything else). Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- .../tests/test_module.py | 153 ++---------------- 1 file changed, 13 insertions(+), 140 deletions(-) diff --git a/modules/hook-server-data-ops-lockdown/tests/test_module.py b/modules/hook-server-data-ops-lockdown/tests/test_module.py index 1d32bf28..0d8129a6 100644 --- a/modules/hook-server-data-ops-lockdown/tests/test_module.py +++ b/modules/hook-server-data-ops-lockdown/tests/test_module.py @@ -1,31 +1,33 @@ """Unit tests for hook-server-data-ops-lockdown. -Verifies: +Verifies the module's actual runtime behaviour: - Module contract: __amplifier_module_type__ == "hook", mount() is a coroutine, mount() registers a `tool:pre` handler and returns a cleanup callable. - The handler denies exactly the four lockdown tools (write_file, edit_file, apply_patch, graph_query) with a HookResult(action="deny", reason=...). - - The handler allows every other tool call server-data-ops actually - makes (session_summary, delete_session, whoami, delegate) plus a - read-only sentinel (read_file), returning HookResult(action="continue"). - - Session-scope composition: agents/server-data-ops.md declares the - companion settings.exclude_hooks fix (see TestSessionScopeComposition - below and this module's own docstring for why the handler itself - cannot do this -- the tool:pre payload carries no session/agent - identity to check against). + - The handler allows every other tool call (session_summary, + delete_session, whoami, delegate, read_file, load_skill, todo), returning + HookResult(action="continue"). + +Deliberately NOT tested here: that the agent .md frontmatter contains a given +string (declares the hook, excludes it from delegated sessions, or pins the +delegation allowlist). Those are config assertions that only prove "the YAML +says what we typed" -- they give false confidence and fail only if someone +edits the same line the test reads. The real guarantees they gestured at -- +the hook does not leak into graph-analyst's session, and server-data-ops +cannot hand a file-write to another agent -- are behavioural properties, +proven in the DTU security-validation profile, not by grepping a file. """ from __future__ import annotations import inspect -from pathlib import Path from typing import Any from unittest.mock import MagicMock import pytest -import yaml from amplifier_core.models import HookResult from amplifier_module_hook_server_data_ops_lockdown import ( @@ -35,10 +37,6 @@ mount, ) -# modules/hook-server-data-ops-lockdown/tests/test_module.py -> repo root -REPO_ROOT = Path(__file__).resolve().parents[3] -SERVER_DATA_OPS_AGENT = REPO_ROOT / "agents" / "server-data-ops.md" - def _make_coordinator() -> MagicMock: coordinator = MagicMock() @@ -136,128 +134,3 @@ async def test_deny_reason_is_plain_and_explains_delegation(self) -> None: assert "delete agent" in result.reason assert "graph-analyst" in result.reason - - -class TestSessionScopeComposition: - """Proves the session-scoping fix for the subtree leak this module's own - docstring documents (a DTU eval caught it: graph-analyst's legitimate - graph_query calls were denied whenever server-data-ops delegated search - to it, because this hook -- mounted on server-data-ops's own session -- - was ALSO inherited by every session server-data-ops spawned). - - The handler under test (`_deny_lockdown_tools`) has zero session/agent - awareness -- it only ever inspects `tool_name` -- and the `tool:pre` - event's documented payload (core:docs/contracts/ORCHESTRATOR_CONTRACT.md, - HOOK_CONTRACT.md) carries no session or agent identity to check - against, so the handler structurally cannot distinguish - "server-data-ops's own call" from "a descendant session's call". These - tests cannot exercise a real delegate spawn (that requires the - app-layer session_spawner.py, only exercised in a DTU); instead they - verify the actual fix -- agents/server-data-ops.md's own tool-delegate - config excluding this hook module from inheritance -- is in place. - """ - - @staticmethod - def _server_data_ops_tools() -> dict[str, dict[str, Any]]: - text = SERVER_DATA_OPS_AGENT.read_text(encoding="utf-8") - _, frontmatter, _ = text.split("---", 2) - config = yaml.safe_load(frontmatter) - return {t["module"]: t for t in config.get("tools", [])} - - @staticmethod - def _server_data_ops_hooks() -> dict[str, dict[str, Any]]: - text = SERVER_DATA_OPS_AGENT.read_text(encoding="utf-8") - _, frontmatter, _ = text.split("---", 2) - config = yaml.safe_load(frontmatter) - return {h["module"]: h for h in config.get("hooks", [])} - - def test_agent_declares_this_hook(self) -> None: - """Sanity check: server-data-ops still mounts this hook module for - its own session (the hook is meaningless if this ever drops).""" - hooks = self._server_data_ops_hooks() - - assert "hook-server-data-ops-lockdown" in hooks - - def test_agent_excludes_this_hook_from_delegated_sessions(self) -> None: - """The actual fix: tool-delegate's settings.exclude_hooks must name - this hook's own module id, so a spawned child (graph-analyst, - session-navigator, ...) never inherits it.""" - tools = self._server_data_ops_tools() - - assert "tool-delegate" in tools, "server-data-ops must declare tool-delegate in tools:" - settings = tools["tool-delegate"].get("config", {}).get("settings", {}) - excluded_hooks = settings.get("exclude_hooks", []) - - assert "hook-server-data-ops-lockdown" in excluded_hooks, ( - "settings.exclude_hooks must list this hook's own module id, or the " - "subtree leak documented in this module's docstring reopens" - ) - - def test_agent_does_not_mount_graph_query_tool(self) -> None: - """Corroborates this module's docstring claim (verified against - history at commit a897c2d): server-data-ops's own tools: list never - declares tool-context-intelligence-query, so it never has graph_query - available to call directly -- all searching goes through graph-analyst - via delegation instead.""" - tools = self._server_data_ops_tools() - - assert "tool-context-intelligence-query" not in tools - - -class TestDelegationAllowlist: - """Proves the companion fix for the delegation-bypass hole this module's - docstring and TestSessionScopeComposition document: the lockdown hook is - (correctly) scoped OFF of sessions server-data-ops delegates to, so - without a delegation allowlist server-data-ops could hand a file-write - to ANY other agent (e.g. foundation:file-ops) and have it succeed - unchecked -- a DTU eval proved exactly this for the settings.yaml edit. - - The fix is a top-level `agents:` allowlist in server-data-ops.md's own - frontmatter (a sibling of `tools:`/`hooks:`, recognized by - amplifier_foundation.bundle._dataclass._load_agent_file_metadata and - forwarded to amplifier-app-cli's agent_config.merge_configs / - session_spawner.py, which filter the parent's agent roster with an - exact-string `k in agent_filter` membership check). This test locks the - allowlist to EXACTLY the one agent server-data-ops's own flows delegate - to (graph-analyst) -- a future edit that widens it (e.g. back to - unrestricted, or to add a second agent) must fail this test. - - Value-shape note: the roster keys these allowlist entries are checked - against are NAMESPACED (this repo's own behaviors/ - context-intelligence-analysis.yaml and - context-intelligence-navigation.yaml both register agents as - "context-intelligence:graph-analyst", "context-intelligence:server-data-ops"), - not bare names -- so the allowlist entry must be the namespaced form or - it silently matches nothing and disables delegation entirely. - """ - - @staticmethod - def _server_data_ops_agents_allowlist() -> Any: - text = SERVER_DATA_OPS_AGENT.read_text(encoding="utf-8") - _, frontmatter, _ = text.split("---", 2) - config = yaml.safe_load(frontmatter) - return config.get("agents") - - def test_agent_declares_a_delegation_allowlist(self) -> None: - """Sanity check: the top-level `agents:` key must exist at all -- - its absence means unrestricted delegation (the original hole).""" - allowlist = self._server_data_ops_agents_allowlist() - - assert allowlist is not None, ( - "server-data-ops.md must declare a top-level `agents:` allowlist, or " - "it can delegate a file-write to any agent, bypassing the lockdown hook" - ) - - def test_delegation_allowlist_is_exactly_graph_analyst(self) -> None: - """The actual fix: the allowlist must contain exactly the one agent - server-data-ops legitimately delegates to, namespaced as it appears - in this bundle's own composed roster. Widening this list (to "all", - or to include any other agent) must fail this test.""" - allowlist = self._server_data_ops_agents_allowlist() - - assert allowlist == ["context-intelligence:graph-analyst"], ( - "server-data-ops.md's `agents:` allowlist must be exactly " - "['context-intelligence:graph-analyst'] -- widening it reopens the " - "delegation-bypass hole (delegating a write to e.g. foundation:file-ops " - "around the lockdown hook, which does not apply to delegated sessions)" - ) From ac276b00668d3128fed816373c6205a328c3ec19 Mon Sep 17 00:00:00 2001 From: colombod Date: Wed, 2 Sep 2026 15:27:53 +0000 Subject: [PATCH 26/39] fix: close delegation-bypass hole in server-data-ops lockdown hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A behavioral DTU test proved the `agents:` frontmatter allowlist on server-data-ops does NOT block delegation-bypass when the agent runs as the ROOT agent: with no parent session to apply a parent-side allowlist filter, server-data-ops called delegate(agent="foundation:file-ops") and that delegate wrote /root/work/probe.txt to disk (verified). The allowlist is only enforced by the app-layer spawn capability when a PARENT spawns THIS agent; as the root/direct agent there is no such filtering step. Move the restriction into hook-server-data-ops-lockdown's `tool:pre` handler, which fires on server-data-ops's own tool calls regardless of root-vs-child: - Extend `_deny_lockdown_tools` to deny any `delegate` call whose target agent (tool_input["agent"]) is not exactly "context-intelligence:graph-analyst" -- the only agent server-data-ops needs to reach (for search). - Field name verified against tool-delegate's own execute(): `agent_name = input.get("agent", "").strip()`, and against the documented tool:pre contract (tool_input IS tool_call.input). - Allowed-agent string verified against behaviors/context-intelligence-analysis.yaml's own roster registration (`context-intelligence:graph-analyst`, matching server-data-ops's existing `agents:` allowlist entry). - Fail closed: a delegate call with a missing or empty `agent` field (e.g. a resume-by-session_id call) is denied, not allowed -- it cannot be confirmed to target the allowed agent. - New constant ALLOWED_DELEGATE_AGENT is the single source of truth for the allowed target. The existing four-tool DENIED_TOOLS deny (write_file, edit_file, apply_patch, graph_query) is unchanged. The agent's own `agents:` frontmatter allowlist is left as-is for defense-in-depth (not touched this pass). Tests: added TestDelegateTargetLockdown (8 new cases: allowed target passes, 5 disallowed targets denied incl. bare "graph-analyst", missing agent field denied, empty agent field denied, missing tool_input denied, regression guard that the original four tools still deny). Removed "delegate" from the old generic "allows every other tool" parametrize list since it now has dedicated, non-trivial behavior. 28/28 tests pass (up from 16). ruff format/check clean, pyright clean (module's own venv). 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- .../__init__.py | 88 ++++++++++++- .../tests/test_module.py | 120 +++++++++++++++++- 2 files changed, 201 insertions(+), 7 deletions(-) diff --git a/modules/hook-server-data-ops-lockdown/amplifier_module_hook_server_data_ops_lockdown/__init__.py b/modules/hook-server-data-ops-lockdown/amplifier_module_hook_server_data_ops_lockdown/__init__.py index 2bbff8a1..d0041416 100644 --- a/modules/hook-server-data-ops-lockdown/amplifier_module_hook_server_data_ops_lockdown/__init__.py +++ b/modules/hook-server-data-ops-lockdown/amplifier_module_hook_server_data_ops_lockdown/__init__.py @@ -1,8 +1,12 @@ """Agent-scoped lockdown hook for server-data-ops (the delete agent). -This hook is registered on the `tool:pre` lifecycle event and denies exactly -four tools: `write_file`, `edit_file`, `apply_patch`, `graph_query`. Every -other tool call is left untouched (`continue`). +This hook is registered on the `tool:pre` lifecycle event and denies: + - exactly four tools outright: `write_file`, `edit_file`, `apply_patch`, + `graph_query`; + - any `delegate` call whose target agent is not exactly + `context-intelligence:graph-analyst` (see "DELEGATE-TARGET LOCKDOWN" + below). +Every other tool call is left untouched (`continue`). Why this exists, and why it lives here rather than as a behavior-level `exclude_tools` policy: tool inheritance in this ecosystem is ADDITIVE by @@ -80,6 +84,45 @@ example (lines 271-274) reads `data.get("tool_name")` and compares it against a list of tool names, confirming both the field name and the plain-string comparison pattern used below. + +DELEGATE-TARGET LOCKDOWN, added after a behavioral DTU test proved the +`agents:` frontmatter allowlist (see agents/server-data-ops.md) does NOT +close this hole when server-data-ops runs as the ROOT agent (no parent +session to apply a parent-side allowlist filter against). With server-data-ops +spawned directly, it called `delegate(agent="foundation:file-ops")` and that +delegate wrote a file to disk -- verified. The `agents:` allowlist is only +enforced by the app-layer spawn capability when a PARENT spawns THIS agent +(amplifier-app-cli's agent_config.merge_configs / session_spawner.py +live-registry reconciliation filters the CHILD's roster against the +allowlist declared on the agent being spawned); as the root/direct agent +there is no such parent-side filtering step at all. This hook, in contrast, +fires on server-data-ops's OWN `tool:pre` calls regardless of whether it is +the root agent or itself a spawned child -- so the restriction has to live +here to hold in both cases. + +Field name verified against the delegate tool itself +(amplifier-foundation's `modules/tool-delegate/amplifier_module_tool_delegate/__init__.py`, +`DelegateTool.execute()`: `agent_name = input.get("agent", "").strip()`), +and against the documented event contract above: `tool_input` IS +`tool_call.input`, i.e. the exact dict passed to `tool.execute()`. So for a +`delegate` call, `data["tool_input"]["agent"]` carries the target agent name. + +Allowed value verified against this repo's own agent roster: server-data-ops's +own `agents:` allowlist (agents/server-data-ops.md) already names +`context-intelligence:graph-analyst` -- the namespaced form, matching how +behaviors/context-intelligence-analysis.yaml registers it +(`agents: include: - context-intelligence:graph-analyst`, composed under the +bundle's own namespace `context-intelligence`). ALLOWED_DELEGATE_AGENT below +uses that same namespaced string; a bare `"graph-analyst"` would never match +a real delegate call (delegate's own callers use the namespaced roster key), +so requiring the exact namespaced string is not extra strictness, it is the +only string that will ever legitimately appear. + +Fail closed: a `delegate` call with a missing or empty `agent` field (e.g. a +malformed call, or one that omits `agent` and instead supplies `session_id` +to resume an existing delegation) is DENIED, not allowed -- the handler +cannot confirm it targets the allowed agent, and "cannot confirm" must +resolve to deny, not continue, for a security-sensitive gate. """ from __future__ import annotations @@ -103,18 +146,53 @@ "queries the graph directly (it delegates search to graph-analyst)." ) +# The only agent server-data-ops may delegate to, declared here as the single +# source of truth (see the module docstring's "DELEGATE-TARGET LOCKDOWN" +# section for how this string was verified: it is the namespaced roster key +# behaviors/context-intelligence-analysis.yaml registers graph-analyst under, +# and the delegate tool's own `input.get("agent")` is the field that must +# match it exactly). +ALLOWED_DELEGATE_AGENT = "context-intelligence:graph-analyst" + +DELEGATE_DENY_REASON = ( + "server-data-ops may only delegate to graph-analyst (for search); it " + "cannot delegate to other agents to perform actions it is itself " + "restricted from." +) + async def _deny_lockdown_tools(event: str, data: dict[str, Any]) -> Any: - """`tool:pre` handler: deny DENIED_TOOLS, allow everything else. + """`tool:pre` handler: deny DENIED_TOOLS and off-target delegation. Only ever registered for the `tool:pre` event (see mount() below), so `event` is not branched on here -- the registration itself scopes when this handler runs. + + Two independent checks: + 1. The four DENIED_TOOLS are always denied outright. + 2. A `delegate` call is denied unless its target agent is exactly + ALLOWED_DELEGATE_AGENT. This closes the delegation-bypass hole: a + behavioral DTU test proved server-data-ops (running as the ROOT + agent, with no parent session to enforce its own `agents:` + frontmatter allowlist) could call + `delegate(agent="foundation:file-ops")` and have it write a file to + disk unchecked. A missing or empty `agent` field is denied too + (fail closed) -- it cannot be confirmed to be the allowed target, + so it is treated the same as an explicit mismatch. """ from amplifier_core.models import HookResult # local import: peer dependency - if data.get("tool_name") in DENIED_TOOLS: + tool_name = data.get("tool_name") + + if tool_name in DENIED_TOOLS: return HookResult(action="deny", reason=DENY_REASON) + + if tool_name == "delegate": + tool_input = data.get("tool_input") or {} + target_agent = tool_input.get("agent") + if target_agent != ALLOWED_DELEGATE_AGENT: + return HookResult(action="deny", reason=DELEGATE_DENY_REASON) + return HookResult(action="continue") diff --git a/modules/hook-server-data-ops-lockdown/tests/test_module.py b/modules/hook-server-data-ops-lockdown/tests/test_module.py index 0d8129a6..b2a9e3a4 100644 --- a/modules/hook-server-data-ops-lockdown/tests/test_module.py +++ b/modules/hook-server-data-ops-lockdown/tests/test_module.py @@ -7,8 +7,14 @@ - The handler denies exactly the four lockdown tools (write_file, edit_file, apply_patch, graph_query) with a HookResult(action="deny", reason=...). + - The handler denies a `delegate` call whose target agent is not exactly + ALLOWED_DELEGATE_AGENT ("context-intelligence:graph-analyst") -- this is + the delegation-bypass fix: a behavioral DTU test proved server-data-ops + running as the ROOT agent (no parent to enforce its `agents:` + frontmatter allowlist) could delegate to foundation:file-ops and have it + write a file to disk unchecked. - The handler allows every other tool call (session_summary, - delete_session, whoami, delegate, read_file, load_skill, todo), returning + delete_session, whoami, read_file, load_skill, todo), returning HookResult(action="continue"). Deliberately NOT tested here: that the agent .md frontmatter contains a given @@ -31,6 +37,8 @@ from amplifier_core.models import HookResult from amplifier_module_hook_server_data_ops_lockdown import ( + ALLOWED_DELEGATE_AGENT, + DELEGATE_DENY_REASON, DENIED_TOOLS, DENY_REASON, _deny_lockdown_tools, @@ -105,13 +113,16 @@ async def test_denies_each_lockdown_tool(self, tool_name: str) -> None: "session_summary", "delete_session", "whoami", - "delegate", "read_file", "load_skill", "todo", ], ) async def test_allows_every_other_tool(self, tool_name: str) -> None: + """`delegate` is deliberately excluded from this list -- it has its + own target-checked behaviour, covered by TestDelegateTargetLockdown + below, and an empty tool_input (as used here) would now be denied + (fail closed on a missing `agent` field).""" result = await _deny_lockdown_tools("tool:pre", {"tool_name": tool_name, "tool_input": {}}) assert isinstance(result, HookResult) @@ -134,3 +145,108 @@ async def test_deny_reason_is_plain_and_explains_delegation(self) -> None: assert "delete agent" in result.reason assert "graph-analyst" in result.reason + + +class TestDelegateTargetLockdown: + """Direct handler tests for the delegation-bypass fix. + + A behavioral DTU test proved server-data-ops, running as the ROOT agent + (no parent session to enforce its `agents:` frontmatter allowlist), could + call `delegate(agent="foundation:file-ops")` and have it write a file to + disk unchecked. These tests prove the handler itself now closes that hole, + independent of any frontmatter allowlist. + """ + + @pytest.mark.asyncio + async def test_delegate_to_allowed_agent_is_allowed(self) -> None: + result = await _deny_lockdown_tools( + "tool:pre", + { + "tool_name": "delegate", + "tool_input": {"agent": ALLOWED_DELEGATE_AGENT, "instruction": "search"}, + }, + ) + + assert isinstance(result, HookResult) + assert result.action == "continue" + assert result.reason is None + + @pytest.mark.asyncio + async def test_delegate_to_allowed_agent_is_the_namespaced_graph_analyst(self) -> None: + """Pin the exact allowed string so a future edit that changes it is + caught here, not just discovered behaviorally.""" + assert ALLOWED_DELEGATE_AGENT == "context-intelligence:graph-analyst" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "target_agent", + [ + "foundation:file-ops", + "self", + "graph-analyst", # bare (unnamespaced) form -- must NOT match + "context-intelligence:server-data-ops", + "context-intelligence:session-navigator", + ], + ) + async def test_delegate_to_any_other_agent_is_denied(self, target_agent: str) -> None: + result = await _deny_lockdown_tools( + "tool:pre", + { + "tool_name": "delegate", + "tool_input": {"agent": target_agent, "instruction": "do something"}, + }, + ) + + assert isinstance(result, HookResult) + assert result.action == "deny" + assert result.reason == DELEGATE_DENY_REASON + + @pytest.mark.asyncio + async def test_delegate_with_missing_agent_field_is_denied(self) -> None: + """Fail closed: e.g. a resume-by-session_id call that omits `agent` + entirely must be denied, not allowed through.""" + result = await _deny_lockdown_tools( + "tool:pre", + { + "tool_name": "delegate", + "tool_input": {"session_id": "abc123", "instruction": "continue"}, + }, + ) + + assert isinstance(result, HookResult) + assert result.action == "deny" + assert result.reason == DELEGATE_DENY_REASON + + @pytest.mark.asyncio + async def test_delegate_with_empty_agent_field_is_denied(self) -> None: + """Fail closed: an explicit empty string is not evidence it targets + the allowed agent.""" + result = await _deny_lockdown_tools( + "tool:pre", + {"tool_name": "delegate", "tool_input": {"agent": "", "instruction": "do something"}}, + ) + + assert isinstance(result, HookResult) + assert result.action == "deny" + assert result.reason == DELEGATE_DENY_REASON + + @pytest.mark.asyncio + async def test_delegate_with_missing_tool_input_is_denied(self) -> None: + """Fail closed: no tool_input key at all (malformed/edge-case event + payload) must not be treated as an allowed delegate.""" + result = await _deny_lockdown_tools("tool:pre", {"tool_name": "delegate"}) + + assert isinstance(result, HookResult) + assert result.action == "deny" + assert result.reason == DELEGATE_DENY_REASON + + @pytest.mark.asyncio + async def test_denied_tools_still_deny_even_though_delegate_check_exists(self) -> None: + """Regression guard: adding the delegate-target branch must not + change the outright deny behaviour for the original four tools.""" + for tool_name in DENIED_TOOLS: + result = await _deny_lockdown_tools( + "tool:pre", {"tool_name": tool_name, "tool_input": {}} + ) + assert result.action == "deny" + assert result.reason == DENY_REASON From f366f2f884e0b61cd4921d436eaa34c9accce65c Mon Sep 17 00:00:00 2001 From: colombod Date: Wed, 2 Sep 2026 15:36:35 +0000 Subject: [PATCH 27/39] refactor(server-data-ops): drop redundant agents: allowlist; hook is the single delegation control The frontmatter agents: allowlist was overkill and, worse, ineffective for the actual usage: it is only enforced when a PARENT spawns this agent, so when server-data-ops runs as the root/direct interactive agent there is no parent to apply it -- a DTU test proved it did NOT block delegate(agent=foundation:file-ops) (the sub-agent wrote a file to disk). The real control is hook-server-data-ops-lockdown, which now gates the delegate tool itself (deny any target except graph-analyst) on this agent's own calls, root or child. One mechanism that actually works beats two where one gives false confidence. Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- agents/server-data-ops.md | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/agents/server-data-ops.md b/agents/server-data-ops.md index 4a6de991..5b9a6cca 100644 --- a/agents/server-data-ops.md +++ b/agents/server-data-ops.md @@ -69,32 +69,6 @@ hooks: - module: hook-server-data-ops-lockdown source: git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=modules/hook-server-data-ops-lockdown -# Delegation allowlist: restricts which sub-agent server-data-ops's spawned -# session may delegate to, closing the gap the lockdown hook above cannot -# close on its own. hook-server-data-ops-lockdown is scoped (via the -# tool-delegate settings.exclude_hooks entry above) to NOT apply to sessions -# this agent delegates to -- that scoping is required so graph-analyst's own -# graph_query calls aren't wrongly denied. But without an `agents:` allowlist, -# that same scoping meant server-data-ops could delegate a file-write to ANY -# other agent (e.g. foundation:file-ops) and have it succeed unchecked -- a -# DTU eval proved exactly this: server-data-ops delegated the settings.yaml -# edit to foundation:file-ops, which wrote the file, bypassing the lockdown -# entirely. Restricting delegation to graph-analyst (the only agent this -# agent's own flows ever delegate to -- see "Tools" section below) closes the -# hole structurally: server-data-ops can no longer reach a write-capable -# agent via delegation at all, regardless of what tools that agent has. -# -# Value shape: a list of agent names as they appear as KEYS in this bundle's -# composed agent roster (behaviors/context-intelligence-analysis.yaml and -# behaviors/context-intelligence-navigation.yaml both register agents under -# namespaced keys, e.g. "context-intelligence:graph-analyst") -- NOT bare -# agent names. The access-control filter -# (amplifier-app-cli's agent_config.merge_configs, and its session_spawner.py -# live-registry reconciliation) does an exact-string membership check -# (`k in agent_filter`) against those same roster keys, so a bare "graph-analyst" -# here would silently match nothing and disable delegation entirely. -agents: - - context-intelligence:graph-analyst --- # Server Data Ops From 2c8e83de62ca96d79faac8492ad31cbdb04f2a2b Mon Sep 17 00:00:00 2001 From: colombod Date: Wed, 2 Sep 2026 15:59:56 +0000 Subject: [PATCH 28/39] fix: resolve agent-quality validator findings for 3 agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - graph-analyst.md: replace meta.description to drop both blocks (rejected under description-authoring-principles V3) and remove the 4x-repeated graph-analyst→session-navigator fallback rule, cutting description from 478 to ~191 tokens. - session-navigator.md: replace meta.description to drop the block (its content was operational CONTEXT_INTELLIGENCE_ROOT procedure, already present in the agent body under "Root resolution — MANDATORY FIRST STEP") and the duplicated "not called directly" statement. - context-intelligence-design-facilitator.md: add tool-filesystem to tools: (verbatim entry matching context-intelligence-tool-designer.md, no allowed_write_paths) so the agent can write domain-concepts.md, domain-signals.md, and handoff.md without relying on inherited FS access from a parent session. No changes to agents/server-data-ops.md, behaviors, or modules. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- ...context-intelligence-design-facilitator.md | 2 + agents/graph-analyst.md | 39 +++++++------------ agents/session-navigator.md | 24 ++++++------ 3 files changed, 28 insertions(+), 37 deletions(-) diff --git a/agents/context-intelligence-design-facilitator.md b/agents/context-intelligence-design-facilitator.md index a6d88060..2cc04672 100644 --- a/agents/context-intelligence-design-facilitator.md +++ b/agents/context-intelligence-design-facilitator.md @@ -28,6 +28,8 @@ model_role: [reasoning, general] tools: - module: tool-delegate source: git+https://github.com/microsoft/amplifier-foundation@main#subdirectory=modules/tool-delegate + - module: tool-filesystem + source: git+https://github.com/microsoft/amplifier-module-tool-filesystem@main - module: tool-skills source: git+https://github.com/microsoft/amplifier-bundle-skills@main#subdirectory=modules/tool-skills config: diff --git a/agents/graph-analyst.md b/agents/graph-analyst.md index b3378df8..a251a48b 100644 --- a/agents/graph-analyst.md +++ b/agents/graph-analyst.md @@ -6,30 +6,21 @@ bundle: meta: name: graph-analyst description: | - MUST be used for all context-intelligence session analysis, delegation chain tracing, and ci-blob:// URI resolution. ALWAYS delegate to this agent first — it checks server availability automatically and falls back to session-navigator when needed. - - Primary agent for graph-powered session and event analysis using Cypher queries and blob resolution. Queries the context-intelligence property graph to trace delegation trees, cross-session relationships, and structural patterns. Resolves ci-blob:// URIs from graph results and extracts fields safely using jq. Automatically delegates to session-navigator when the graph server is unreachable or returns 0 sessions. - - Use this agent when: - - Querying the context-intelligence graph with Cypher for session analysis - - Tracing delegation chains or parent-child session relationships across many sessions - - Resolving ci-blob:// URIs and extracting fields from large event payloads - - Analyzing event patterns, tool usage, or error frequencies via graph traversal - - When graph server availability is uncertain (agent will check and fall back automatically) - - This agent checks server availability before every analysis run. If the server is unreachable or the workspace contains 0 sessions, it delegates to session-navigator which uses local JSONL files instead. - - - Context: User wants to query session events using the graph - user: 'Find all tool errors in my last session using the graph' - assistant: 'I will use graph-analyst to run a Cypher query for tool error events — it checks server availability first and falls back to session-navigator if the server is unreachable.' - - - - Context: User needs to trace a delegation tree - user: 'Show me the full delegation tree for my last recipe run' - assistant: 'I will delegate to graph-analyst to trace the parent-child session chain and map the delegation tree using Cypher graph traversal.' - + Primary agent for context-intelligence session and event analysis: + Cypher queries against the property graph, delegation-chain tracing, + and ci-blob:// URI resolution with safe jq field extraction. + + Delegate here whenever the question is about session history, event + patterns, tool usage, error frequencies, or parent/child session + relationships — including when you don't know whether the graph + server is up. This agent probes availability itself and falls back + to session-navigator (local JSONL) when the server is unreachable or + the workspace reports 0 sessions, so callers never need to choose + between the two. + + **Authoritative on:** context-intelligence graph, Cypher session + queries, ci-blob:// resolution, delegation-tree tracing, + cross-session relationships. model_role: [reasoning, general] diff --git a/agents/session-navigator.md b/agents/session-navigator.md index 102d204a..b3bfac8f 100644 --- a/agents/session-navigator.md +++ b/agents/session-navigator.md @@ -6,19 +6,17 @@ bundle: meta: name: session-navigator description: | - MUST NOT be invoked directly by external callers. ALWAYS delegated to by graph-analyst when the graph server is unreachable or returns 0 sessions. - - Local fallback agent for navigating session data via flat JSONL files using bash/jq/grep safe extraction patterns. Handles session discovery, event search, and session navigation under the root resolved from `CONTEXT_INTELLIGENCE_ROOT="${AMPLIFIER_CONTEXT_INTELLIGENCE_BASE_PATH:-$HOME/.amplifier/projects}"` when the context-intelligence graph server is unavailable. - - This agent is NOT called directly by external callers. It is only delegated to by graph-analyst when the graph server is unreachable or returns 0 sessions. External callers should use graph-analyst instead. - - All operations use safe bash/jq/grep patterns that avoid loading 100k+ token events.jsonl lines into context. Never uses graph_query or blob_read — operates entirely on local filesystem files. - - - Context: Graph analyst delegating because server is unreachable - user: [graph-analyst delegates] 'Find tool errors in session abc123 — graph server is unreachable. Workspace: my-project' - assistant: 'I will scope search to workspace my-project. I will first resolve CONTEXT_INTELLIGENCE_ROOT="${AMPLIFIER_CONTEXT_INTELLIGENCE_BASE_PATH:-$HOME/.amplifier/projects}", then look in "$CONTEXT_INTELLIGENCE_ROOT"/my-project/sessions/ first, then filter by workspace field if needed. I will search for tool errors using safe jq extraction patterns.' - + Local fallback for context-intelligence session navigation — session + discovery, event search, and JSONL traversal via safe bash/jq/grep + patterns that never load 100k+ token events.jsonl lines into context. + Operates entirely on the local filesystem under + ${AMPLIFIER_CONTEXT_INTELLIGENCE_BASE_PATH:-$HOME/.amplifier/projects}; + never uses graph_query or blob_read. + + Reached only via graph-analyst, which delegates here when the graph + server is unreachable or the workspace returns 0 sessions. If you are + an external caller deciding where to send a session-analysis task, + send it to graph-analyst instead — it picks between the two. model_role: general From 40833ed367876a02e42c22da8f511aa023fdbaa3 Mon Sep 17 00:00:00 2001 From: colombod Date: Wed, 2 Sep 2026 16:08:33 +0000 Subject: [PATCH 29/39] docs: address bundle-validator README findings + regen bundle.dot - README: drop bare 'pip install pyyaml' in favor of 'uv pip install pyyaml' (readme_pip_install needs_work). - README: document that the standalone install flow intentionally omits --app (bundle use selects a dedicated config rather than layering onto the active app), recording the reasoning per the validator's guidance instead of appending --app where it would be semantically wrong (readme_missing_app_flag). - Regenerate bundle.dot / bundle.png (was stale) via the foundation validate-bundle-repo overview regen. Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- README.md | 4 ++-- bundle.dot | 68 ++++++++++++++++++++++++++++++----------------------- bundle.png | Bin 140629 -> 215363 bytes 3 files changed, 40 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 2e84c2f0..1071ad17 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ The hook resolves `workspace` using the same `config → coordinator → default amplifier bundle add git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=behaviors/context-intelligence.yaml --app ``` -**Standalone** — creates a dedicated session configuration using the full root bundle (includes foundation): +**Standalone** — creates a dedicated session configuration using the full root bundle (includes foundation). This flow intentionally omits `--app`: `bundle use` selects a dedicated standalone configuration rather than layering the bundle onto the active app config (which is what `--app` does for the recommended install above). ```bash amplifier bundle add git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main @@ -774,7 +774,7 @@ uv run pytest ../../tests/ -q # YAML validation — requires pyyaml (not installed by default in the bundle virtualenv) # Install pyyaml first if the command fails with "No module named 'yaml'": -# pip install pyyaml OR uv pip install pyyaml +# uv pip install pyyaml uv run python -c " import yaml; from pathlib import Path data = yaml.safe_load(Path('behaviors/context-intelligence.yaml').read_text()) diff --git a/bundle.dot b/bundle.dot index a911b904..85622bd3 100644 --- a/bundle.dot +++ b/bundle.dot @@ -1,82 +1,90 @@ +// Context Intelligence: a bundle that records what happens during Amplifier sessions and lets you query that history as a searchable graph. digraph context_intelligence { rankdir=LR fontname="Helvetica" fontsize=12 - label="context-intelligence v0.1.0 — bundle repo" + label="Context Intelligence v0.1.0\nRecords session activity and makes it searchable" labelloc=t labeljust=c nodesep=0.6 ranksep=0.7 bgcolor="white" - source_hash="0758d63959ef34f091dbe8fc6e27bc712f154503c829bb35474423dd213c2c27" + source_hash="3fb54cded848d61b162e4fffa3c7445239361e2ed7d48abca8c1814a0fe49afd" node [fontname="Helvetica", fontsize=11, style="filled,rounded"] edge [fontname="Helvetica", fontsize=9] - root_context_intelligence [label="context-intelligence v0.1.0\n0 tools · 0 agents\n~98 tok aggregate", shape=box, fillcolor="#80cbc4", style="filled,rounded,bold", penwidth=2] + root_context_intelligence [label="Context Intelligence (main entry point) v0.1.0\n0 tools · 0 agents\n~98 tok aggregate", shape=box, fillcolor="#80cbc4", style="filled,rounded,bold", penwidth=2] subgraph cluster_behaviors { - label="Behaviors" + label="Capability Packages (features you can switch on)" style="filled" fillcolor="#f9f9f9" color="#999999" - beh_context_intelligence_analysis_behavior [label="context-intelligence-analysis-behavior\n1 tools\n~864 tok", shape=box, fillcolor="#e0f2f1", style="filled,rounded"] - beh_context_intelligence_design_behavior [label="context-intelligence-design-behavior\n1 tools\n~331 tok", shape=box, fillcolor="#e0f2f1", style="filled,rounded"] - beh_context_intelligence_logging_behavior [label="context-intelligence-logging-behavior\n1 tools\n~1656 tok", shape=box, fillcolor="#e0f2f1", style="filled,rounded"] - beh_context_intelligence_navigation_behavior [label="context-intelligence-navigation-behavior\n2 tools\n~617 tok", shape=box, fillcolor="#e0f2f1", style="filled,rounded"] - beh_context_intelligence_behavior [label="context-intelligence-behavior\n~236 tok", shape=box, fillcolor="#e0f2f1", style="filled,rounded"] + beh_context_intelligence_analysis_behavior [label="Analyze Past Sessions\n1 tools\n~974 tok", shape=box, fillcolor="#e0f2f1", style="filled,rounded"] + beh_context_intelligence_design_behavior [label="Design New Query Tools\n1 tools\n~331 tok", shape=box, fillcolor="#e0f2f1", style="filled,rounded"] + beh_context_intelligence_logging_behavior [label="Record Session Activity\n1 tools\n~1656 tok", shape=box, fillcolor="#e0f2f1", style="filled,rounded"] + beh_context_intelligence_navigation_behavior [label="Browse & Search History\n2 tools\n~878 tok", shape=box, fillcolor="#e0f2f1", style="filled,rounded"] + beh_context_intelligence_behavior [label="Core Bundle Wiring\n~236 tok", shape=box, fillcolor="#e0f2f1", style="filled,rounded"] } subgraph cluster_agents { - label="Agents" + label="Specialist Assistants (each handles one kind of question)" style="filled" fillcolor="#f9f9f9" color="#999999" - agt_context_intelligence_design_facilitator [label="context-intelligence-design-facilitator\n~187 tok desc", shape=box, fillcolor="#c8e6c9", style="filled,rounded"] - agt_context_intelligence_tool_designer [label="context-intelligence-tool-designer\n~198 tok desc", shape=box, fillcolor="#c8e6c9", style="filled,rounded"] - agt_graph_analyst [label="graph-analyst\n~543 tok desc", shape=box, fillcolor="#c8e6c9", style="filled,rounded"] - agt_session_navigator [label="session-navigator\n~422 tok desc", shape=box, fillcolor="#c8e6c9", style="filled,rounded"] + agt_context_intelligence_design_facilitator [label="Design Conversation Guide\n~187 tok desc", shape=box, fillcolor="#c8e6c9", style="filled,rounded"] + agt_context_intelligence_tool_designer [label="Query Tool Builder\n~198 tok desc", shape=box, fillcolor="#c8e6c9", style="filled,rounded"] + agt_graph_analyst [label="History Graph Analyst\n~191 tok desc", shape=box, fillcolor="#c8e6c9", style="filled,rounded"] + agt_server_data_ops [label="Stored Data Caretaker\n~270 tok desc", shape=box, fillcolor="#c8e6c9", style="filled,rounded"] + agt_session_navigator [label="Local Session Browser\n~157 tok desc", shape=box, fillcolor="#c8e6c9", style="filled,rounded"] } subgraph cluster_modules { - label="Modules" + label="Building Blocks (the code that does the work)" style="filled" fillcolor="#f9f9f9" color="#999999" - mod_hook_context_intelligence [label="hook-context-intelligence", shape=box, fillcolor="#bbdefb", style="filled,rounded"] - mod_tool_context_intelligence_query [label="tool-context-intelligence-query", shape=box, fillcolor="#bbdefb", style="filled,rounded"] - mod_tool_context_intelligence_upload [label="tool-context-intelligence-upload", shape=box, fillcolor="#bbdefb", style="filled,rounded"] + mod_hook_context_intelligence [label="Activity Recorder\nhook-context-intelligence", shape=box, fillcolor="#bbdefb", style="filled,rounded"] + mod_hook_server_data_ops_lockdown [label="Data Safety Guard\nhook-server-data-ops-lockdown", shape=box, fillcolor="#bbdefb", style="filled,rounded"] + mod_tool_context_intelligence_query [label="History Search\ntool-context-intelligence-query", shape=box, fillcolor="#bbdefb", style="filled,rounded"] + mod_tool_context_intelligence_upload [label="History Upload\ntool-context-intelligence-upload", shape=box, fillcolor="#bbdefb", style="filled,rounded"] + mod_tool_server_data_ops [label="Stored Data Management\ntool-server-data-ops", shape=box, fillcolor="#bbdefb", style="filled,rounded"] } subgraph cluster_legend { - label="Legend" + label="Legend — what each colour means" style="filled" fillcolor="white" color="#cccccc" fontsize=9 - leg_root [label="root bundle", shape=box, fillcolor="#80cbc4", style="filled,rounded,bold", fontsize=9] - leg_behavior [label="behavior", shape=box, fillcolor="#e0f2f1", style="filled,rounded", fontsize=9] - leg_agent [label="agent", shape=box, fillcolor="#c8e6c9", style="filled,rounded", fontsize=9] - leg_module [label="module", shape=box, fillcolor="#bbdefb", style="filled,rounded", fontsize=9] - leg_provider [label="provider", shape=box, fillcolor="#e0e0e0", style="filled,rounded", fontsize=9] - leg_context [label="context", shape=box, fillcolor="#e1bee7", style="filled,rounded", fontsize=9] - leg_standalone [label="standalone", shape=box, fillcolor="#80cbc4", style="filled,rounded", fontsize=9] - leg_experiment [label="experiment", shape=box, fillcolor="#e1bee7", style="filled,rounded", fontsize=9] - leg_ext_cost [label="ext+cost", shape=box, fillcolor="#80cbc4", style="dashed", color="red", penwidth=2, fontsize=9] - leg_ext_muted [label="ext+no-cost", shape=box, fillcolor="#f5f5f5", style="dashed", fontsize=9] + leg_root [label="main entry point", shape=box, fillcolor="#80cbc4", style="filled,rounded,bold", fontsize=9] + leg_behavior [label="capability package", shape=box, fillcolor="#e0f2f1", style="filled,rounded", fontsize=9] + leg_agent [label="specialist assistant", shape=box, fillcolor="#c8e6c9", style="filled,rounded", fontsize=9] + leg_module [label="building block", shape=box, fillcolor="#bbdefb", style="filled,rounded", fontsize=9] + leg_provider [label="AI model connection", shape=box, fillcolor="#e0e0e0", style="filled,rounded", fontsize=9] + leg_context [label="reference text", shape=box, fillcolor="#e1bee7", style="filled,rounded", fontsize=9] + leg_standalone [label="ready-to-run setup", shape=box, fillcolor="#80cbc4", style="filled,rounded", fontsize=9] + leg_experiment [label="experimental", shape=box, fillcolor="#e1bee7", style="filled,rounded", fontsize=9] + leg_ext_cost [label="from another repo\n(adds hidden cost)", shape=box, fillcolor="#80cbc4", style="dashed", color="red", penwidth=2, fontsize=9] + leg_ext_muted [label="from another repo\n(no added cost)", shape=box, fillcolor="#f5f5f5", style="dashed", fontsize=9] } disclaimer [label="Token estimates: ~4 chars/token\nSolid border = local (counted)\nDashed + red = external, hidden cost (not counted)\nDashed + muted = external, no cost\nExcludes: sub-session costs, runtime-dynamic", shape=note, fillcolor="#eceff1", style="filled", fontsize=9] - ext_githttps___github_com_microsoft_amplifier_foundation_main [label="amplifier-foundation\n(external, cost)", shape=box, fillcolor="#80cbc4", style="dashed", color="red", penwidth=2] + ext_githttps___github_com_microsoft_amplifier_foundation_main [label="Shared Amplifier Base\namplifier-foundation\n(external, cost)", shape=box, fillcolor="#80cbc4", style="dashed", color="red", penwidth=2] root_context_intelligence -> ext_githttps___github_com_microsoft_amplifier_foundation_main [style=dashed] root_context_intelligence -> beh_context_intelligence_behavior [label="composes"] beh_context_intelligence_analysis_behavior -> agt_graph_analyst [label="owns"] + beh_context_intelligence_analysis_behavior -> agt_session_navigator [label="owns"] + beh_context_intelligence_analysis_behavior -> agt_server_data_ops [label="owns"] beh_context_intelligence_logging_behavior -> mod_hook_context_intelligence [label="uses", penwidth=0.8] + beh_context_intelligence_navigation_behavior -> agt_graph_analyst [label="owns"] beh_context_intelligence_navigation_behavior -> agt_session_navigator [label="owns"] + beh_context_intelligence_navigation_behavior -> agt_server_data_ops [label="owns"] } \ No newline at end of file diff --git a/bundle.png b/bundle.png index d68d2ff85838009e835a121b50ba7d939ba1afc7..41833f6266f27c6b1c48ecc4f20bfae5c6eabcf7 100644 GIT binary patch literal 215363 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 z zvnKzHwPTZB++(UfIWL<0Zl?t{Sc~H%C9~G$%<=6YZfDg!!Vx+HZadqN@8}Pd-k*cU z#Zq{zvgXSdc2-JCdirg?sZc}*Cc z6?AwKn6j(9t-)df7_uGVc*y?L*fv-R%7RW~*0L`rdo#D+Ao0+zhhu{umrhqIg zD0stsvL%lefwl7G%eAV?9^<B(x={z{ZDHX~zR zUu5J4E)5QRg+@f^Mt;jjot}TRaR!}H9Bj)P+4|4)gX4*9zKjbvc=+0 zW>HmEymST(bpBGEw?Y(LOwOsN_)68oUQ*VAIxd3(kFBl70@`{+06gDw%hrhNVV6{A6L6mBbPUYXl#M${kJ z8(jOA!bYOsDej$c(BXQJz;--c?O1m<1$CL{|r`G^k!Yut%Bn;rFN5EJ@vfQPoMG$yb##UJJlTglG|RA znY=JcDt9sAWS+M5MMrsgjuQHyccd zubV3>Fds>f73|?w84;nJr^bAP{}5cT1R2>rWIum7b3Pu;F}u&0zdAP;9Ui{fu&P_` z;&OK8SL?agNxOmYHR@!3WfU7hzmCishJ5Xa-afV$xr{CkcWJ47?>>C~Ygt_%4mtOY z4ejOWXTm{4f#r$Wf%26?LPBYQqt9?2x{N{T?t!i)&tFB2E)=6yVc=UT!oAw%2LFk~ zslMCcTL39`1BLoT1cUIENAM*l1^XVeb8B@>^vt`Pn-jHr&2Sp&xUzDXmyA_-`to#-hfM2g=OULZO?=vl_ zeAl)d~(bQ=|qW% zGz*Izs*3N;8)B}41kbxQ9oNkAs@y_Tl2~No>rCOSk973i<>zwl1*PB=eV?vmaJIHP z>RRgFw;6WT^YW`zB?N)r+1j8!7T5a*zDHqTP{8*dT)Ra&lI!^Yu=kcxRqt)LFnhZd zPzhT}Q9%S05$RGeXr!eTMM^|U8dOk`5EKv;5GABVq}w2*k(5RnlzC7a#eBJnuM3RZavfrALpa!2B<=o1>FW)tmO+;cPrcJ#oGi^B-gm;=``|`tyd}6+ zg8j^wbl3F)kzCtk-P8=@2Ykas1GT0Pv5w)~NDx}V*Moxt8~?NQY~{x5?7kno5pVvdZ~pt0?ut|Y?|Y%ECv5+) zd7G8B03^Gm`X9naP@w}WIXi*y#CIRc32-As1e(S#d*FG#mb}%|5^dQzP{%r2ZFw+9(Zp1Or zFx>gh;#5D+uRPc=aCKTL>|R6ifKxLq_T3HQ8nW)^DveB7`@MJ92_J}s>F7~I&>?_G z2cYLJSf~wlV6tx_rOe9Bb+or%U74`Vl}bf#C-Pbc4w(9=0Y_z&|9!5o{};3%d>+So~w<5F{Nuw~CeH z)d`Dc!8S|~unQNoVLN#6ps?@?D2-s%-LbWG9e81A;<(v+b*Z?x7<@uA;Of!6fu@6o zTlhZ0CQlfOSlU{tfE<(piY^4rPNK^O27%b>j6bzLDCE$dfCh#(9DDaCH=t*A;NHdEq53(jda)-y3N5keRR6Q8#!zm-K-z*#_ms%@?`zA;MxZGQmJh`+w}kz|a&G>A{q+}zZr)`R6FM!f)g{=@}d((7)YCsQ}=&hk}cF zXZvMlagV8~DO?_?6?SG@4X&*&gZmjf%EQGkWnnQnTpxqA;-Ij;bEio`|NV9mw@qH2 zo@tt+c%!HiCW(i#FHW5oi&`?Ls1qwz4lKyyHEYE}qVXeHsuagl_-BcHQ)MLwAQh)h zodU{GSh&5SB4J61_?c~b6n%-sSivylCul}de=aE*=nv9s8n)&c=WsT-wHD4ErxdrH zl8@KWY<8?;xlvsYQedNaT%4Mt3a@S&7(gvAV*JYcXG23nKxt1*Od!PA$~5EqeE;=d zr%idvHgS4zu?3cQr=*y(XGm`TIc!T6SCAJU-Gl=w+=A^A6cDf{Q2m$Z8FBSO1*}Z$ zHc*OGqj)@=ZwPq6H1Xg0bvdpd(p)C#Ie^#U30%gq2i>U&Mv5ma;YCYDg(=k9lm!THVXa%ls*86e?=0}=JZ*aQHhnD_157bc%+ zH7Fmalca`!&?e@M7}JCjDm)NQ14q2B`Y?zXD0w1KvG3N{PdhX^ns3&|JIqTkxzN#2 zNi6Tqgy~a%r~Pzk1tytFJBP)@WwUp-}mc#Z;e^ zbJi6@L%i)g82_MwRp#j7;Zj3sD{>o$m32; z<1qkz7^N*g`umylC5~`#+yqFI zl9FD^LwI3s!4h5*KQPD+r*|TxnZ3r zXJ%G@rPhEy6J4#VtBdq_OW`P3;F6MGK|Efb{mEz6dYY7!)OkWB<)Jj2OaHH5iDJ&C zra!y8yES$Ga64*x^(vP|&$&QpRV5{Y))*X;iZe+}1ePgX^SH=1qW!irN^j%)6tHLS zUXyf9p9{)WPqy>e&W;cySd0zJW5QiDBfkyl@oa6#ZfH<(bX@jy%p%rk89qlY)Jn?% z$Uwm$%+JB`3$7KNqsBt2jXM{7|AOVdD-nXWV=LpX>$y;iL>MFDyp)z=$Vl9`yTc%c zM+!p34YH{laTN&(PjScJA?E*yDDPk6PWSBg&8%H`B;eFo>0!3hVFeoJ<2=@E+Pc1c zxrT$M+D5c_(GtDcdpa-#gV^NNpAD z{`vFguV25yH5bk{X<0Vwf5++Va+;P~Ed?S7=jFgp;!?e4%Q%>13z=GO>E^0dhNhm1|)P%d@1|xuhpi|y- zwGbX8@Hs#J7z$JHOAM|nlThrTf`%$tib2J5(57*haY~ z)&!yt#C|Xk2!Qh&M&Z{J1$mDjH-q3UdWLXmz$Rrt=^^b>F?ZcQwORISUm!>bMU&xQ zzg9XO#{~og4kRi^D>W8#5}SEOv35r1U7&8nnKyACXW%B3;jUi?){UTj!zfB{0rNxO z2w@Y!W+JY==tGlkJ*+fb_7uyE@109%gr*c1`tb;Haod)mi9z^C*q9(F<7h^JQi3xE zZR64Um>R^$IGG0_2KwVOWRMgPtr4njr<6vtZO)?;h$v9nAGi79NCvKK{58ZW_wU~a zLh7yT3(KCO$K<HPeFj%0l87hNa8>d zfieyiNjcI3zhgV3fP~XY!9bN1e8}fQr0d!t5s}pwhOeGJfwB-{bC=~&J|_2=n3x75 zL&IiR^BYw!$(d+)Od6o74D%1zfJ*55tSl(>^+PwXx~2xYPo^y&q8rA3{9vMoDsS+M z7dXSQjV9OF)%0&8sxaKPZ5!x`$5!p}bkO;Vt2>}-^`Ye9^5P;ADRL%sY$1~p@amQ5 zm5(HDHX{kdr53*LAt3uSdvSgJ1Wst(=uUADf)@4j=Ri|5-T9o^>A;Tj$HMX!B1lN) zx(Zzj8*ZI+IGLs2_^PC24$&s0ZkG0PawY@ouPz)Z%@&sU=?7eNQzqohv~vEmu{0no z1&Q?MPP(@z5J5M_tKzVWdoas9Uszle5EN{>^9%r9gmDmB(am=dnRsw)i+Wm0N?A>f zYqvN;E)fI_&|ZTq3eqBJX-`NrRsB58yMjmsEwa7hD-evqG5-m1=@dcJg1v;dJ7b>} z@}(hvrcMUkZA+w}KuCf?i^zSR+F$mSusu$IX;UJs18gc)iax?h=qLZVX_HpENwjOd zv?>=Dmu6B1MEnRG4|E{DeM^t8|4!h=LLf;?OAFDF3vx(Q!g$n<65g>}@on9(wPo`q zzOCD~rE4Z#h*V5yv}~TzgShXH-rjlyjwVYu#0Zy|Sy?5?o`!{mg@@}6N54npmhHTp zk(rqZnVe`*q}xI^BP!s9G;niqr9gYxS7h#TG&9skTvun+6%=X^yFgaU)pcEZ8$A+D zyxPmc5l^M3nn`mA;|bzazWK;mV80MnL^*P8fAzGeTRre0K?Dgwp-!PozFLA=%LfBw zp7GhI8a@8@B#pNI=l{i%T&Vm*`Dig6p<3rZy-`EC+cR0am0=Ie+Re@l??(6~KJ1fm z%{Q@Q5VnBmc1;a2VmPT$M0e5#(r3aOg^XBoT~^aNbb3$G?Dg}2fQI+^ zYW@vxV16Kv_I_=ESWr4`T|~!XhD?;O3jQo|BQ}8_K72qj0~N1k!XK<0Kq~AyUBx%c z7;e||cwu27K|Q(Y#|F2RnQ7ppG2B4dY2#<@s$W@thpXQpk~AC>uA6QW=*>+01hNOX zF6KjpNC;{^65C}_I6&eE(asqQcn6rmRUER5-un?D6?uDacdmgOfn~!tdZuf{$_nH9 z{>B0%FFou=9O;ga^xy$fKa=IT(WN<q&YT2loXnGjT15jw0RYaV8l z=W5E1fc|A^QPFl<-kXq~X>8EGbZG+8xbQPZO^F&vGIA~Z2?}S?J&!18S$t?tUildA zn#w^u?{OXiBOMZ@bg+Vum{N&TsUtX5XzMVDxi}$^C4g_9K2=(VBYfpFRrZ8l=n0+; zf|Gl~r~J~)yF__;2_jn1)_TEj5us8EX8=iisFF7px~?Bnemf*Yd}ip)TUuDWIAu_d z(g}-f@FlrHPw;*Sazy6lVu+aGB3$P?Y)edB`UBPBs3FjapzS0?kAM4$Vkq!le3^tL z#c_p?rm_$rZh&WLh?=ZATqkTHTZWy31GHJTF%dMBOih6wiB(HLDWx8HY2j2kySN8* zkG7DK)`alY5E6)Zn5Q}~6=-(C3N|Nc?W3UqHRJ;j2nCRF6DNZJdCV&Z>5-d>tG&Kr zxc*Zpr!cz@!DwN70R{B7c{fD($;qpqk%B*q3ZMXRTL;)-V3(JL<*H;!MK|KUR zjvsMNapp&4aN05TkYvK6WMni?qpSmH0A4Y9^bB!JBtX4N7o=?91jmW zj|gW72j0$Z0dEML8K+Lp^+c=l@Il)sDVtnX5*kZPp5GEcf^j)T|9teWW=K8NDJW^d zdJ{CJCAJemU8i@qS6$5kD{~#umDBf}s*%OU8s#pscBhmb6yN7pzhs^jvrC zsz(hUnKB%J9@*7v*Q}Q3%;MbEUcbD9wl=zsv*TTQT|NE%26z%^Krb#X^2D`#`EnSx zPDI2RX)IKTffrD%nTMQiIy|wZ<*6*9q3+&MidAAqT?$<+lM9!m@WJ4WqaK}o&Y^+I z#o*A;+R{83G+#(q8FUORf3W&Y!Zq?keo(^ zu0&A%G*oS{{0IrRGNBTIV}B!Y086`f&mL4lwMYab2UiZs-p zVae|Tp@WYisiUL1)7Rq5j_M9FtGr2oT?B$ zJ~gy`Qy}9EH-K#xgMRhCeL)C=WPCG+y3#&e{j$Y)UYT&dNaB@Lo!5;vsp4`pn()w6 zeKBjlN1%r4`hd{ z#)iGiT8z~NIs;{qk>OztP0g1tUJOHl&K;WIVmR)wyNKvq;6MEQsE1FGUmF$ofJ4yE zwk!sD6q3Ovac`~87p|{4BC}6UHb+5t)<@N}z-sUbJPllaf?5(=MmwZ-LCXO|sy)jB zT4yI0CaO0TLly1F5u^0hNBgz9U`j?uN8uU!PY{nO4X^$ahfMwAt&sc18i=(|py2Fa zZ?B=H)oi9p@znZ@BxFe$Z}D$5A6OZ*{F-vb7Ply`Cdf|I{y}sg-8;@!-n)1n;X`zl z2611vO8ou?RC9l4LL)oOHHkkX`OmuxPaIG!vF-l&$M3}E%p&YA{qNR?D+M7{!t!R} ziGTk>!(prJZFWbBii;sODp`t3Q!u1>KZzo}A*6lkt1v#OU{c&6#`dh62wn(`){2UH zR}>++zE_o&9zAuc?uyXr&-g;P%um(Cy*IctY%Q2=8cT~ao|Z@j~;3gjB-^zP|V7m?YfQWL#U8h*261KZ9l*pS*m8 zj!?E2@maceeD0u|y|HAyV+T`D&vvK&zUy|9vff{-qW|%tq5rN*vF(x&+#qZULO#oV z#3X})mMV$(n*X+kcU*Fk_1Li;K_|$W+#@4z-}cKTavRRj_uirumOi1oo5rWssA#I+ zGKlzMv5xIkisn;OYty{SR8&`ukJ)Yj6ImH;tr|kI_<#IlcXD2z4%omh#~K#Vy*-3J z79t&J1-@y2&>)GZL|pv-?~6a7hXhDmL!)Jvg0KY!C1h{o8i8b}p6hD*+To3R{qe69 zzDH81Fu+M&PQBsN{$Rh&R9_j4H3Z6}H|`n9NzY&j7v%HX_MPAW9>vF}(daY=#{_Ze z%@OtN^^n1!7e|<(grou)GF&{o(N?Bs&z^x>^b1v8WKu{2MV5c1qFmqg{;J!92gR6@ zz~J~RMOub1^Mv54<5YYhQi&?OCK8qKD5Tt|yQHM1dOdl9i7+V*g>{~&D53wCjSk1n zn~CY^vnXvx-9mhjAea#wYlL`bqwGM1aDh{0(tGBC>T3D-+ddb7Y7jLv93%__FlqRD zC2V${mUwt*{$kMN5(5IR~^; z;-U1ZLLU+y&WQRj!aqc@h*>dEDRzXA&^B&o#Av4r7fN!+3la(=qaNCHcE@Vaj!&x{>_NF7cq3OCJkjQcI{G#xxqJ+`fW;uF^EmpKX%i%s+BGD*VXd z!(S>Ze*1l12&LrD1Qj_?f_z9|$y)sl z5PBF@8qCdaOtk76RiQ)IXlWMoJIYO1S6(O)8zGuoS)c#$@vLW0wV zi25g%_n*ocl{4#ud2iT{AFryYu(GypC~Qf)N@!4Ay5wh#EAp$i_b`21YwHt6G2Lh- z9^<;}D0tvi5?V`v8+MqFk0La6c|Q{rdvSOb6%`R@dmrFN=z$D`UG9)g>ihR6vmzoR zuU)?U9W^u5#Wc5+qV6j9O1Pz|DI+Q>%Dk%p$1ena`T>)Ga+}O^j%=h~ zeoPW5R*D$QUsHd}gk4ikXhao1FqfLJdPd*3|LUzh^Z&ICc&Uv%MAuiQA?yXSr8p)& zqWCX`hY|^PJ1nS&OprVE=WmMq{9kp5=j`>9l%PLkbzo2t>2_4wOh(iixnDT^oHIFu zoM^D#y5|av$mzcNiY6)9Zy!N%8NYu8vLqXoZyJ)Zfml8Zi_gvK3rlN*#~z!Y{QC=? zd!85RQBg+#PDE4j;ixJS7{qN2*OLS_#*&`~1f;&!Gj!C}?ejS#fox@G(xSv(0RQBa@|!jIaQR zuA}Bq^>D#70xUYpwhF~RN?PZCwOGNWQBnEg!19<}KUj(WN;_Z+I4{^~LI6M9)_Ou+ zMI{YYY;-(f6VM^r_S+{B#_^e`J>7DvBZmjc8Qc@<;7YyldxR|J(k1G>dzQp5ycqCfjyFMCk&rV)s65Drq5z8NYdwRn z9OX?K#L|@7`Z4qjBi#TVz-<>n9{_foobXX_|8;BvJ#o%{jE@(hEPzrd%4784DUv+} z@C!s}G%5+@b6ibSG?U8?kTU_Pg_lUdpOflX5{~d)eZte9;^u~Lja&Z?4QR}O8(x5b zGa%)Q@M$oXpHXBOAvEsAoq%jvqTTDA4oIObVh;t8*#PPulonL?5r!2(6+1e*ANLOs z4|OElx7OBO$Q# zu+eLx;n1h_$3O#!6#u|jqnR0NV}&YYSp_Jo)@4E&tZx2v69dcygvNEVEtxquWFx<+ z=n%Y3-RfcVR(6OZn~TI^Q-wBNb_8z6&Fu?5sEuHle2j9O3Qz@h-4JDuyyz=^&x0C0 zpwCrRa)PU9FAk|A&w-71=swV>l6$A8J^K-1Y6S_&Ni*RzCG*fw-0IUbR}ECVVF-v=mV^r~9dRrK0&; zg?({xhj>)8a@Fe-U_+5?6-E~Qb}y(b!177)oh4)X^Ups4b7rRhNfmD_hB`Y&v7j(m z2%my-+;szk#`uZW6Z>}WCKUL;uXAI%*>Hh3DLhdt7SCE6;wMj^G9^WH`Praeh~9CM zW_rBC1z-;F9z8cytP3@LAlfMGg3O@>`JA z5Fj8VL@*ann-}*$tdX0Y4V3?bM~@KZd_ncSs|(s)E6*%&cNW{BX@U<4~-q6phWtyl$j~*CGCuY3!(qldcx4afCtqOl&o;Aae)aVIvgG( zrKrv%CM98hB_@p}D8<2CbVSQwh8rR_%;r&LQ-yU0I2b{>7}L2%ZcA(H?c28HLPwtZ z5I_F{JT+>W(9}17ZEJ=8c3k5KPRDP!<>{O8FUNzg^!$gGfRZU<@DPk5!0Yy%h$1vR%UpBt~>JW)P_@5_D#2=qw`cD$`Pa$g8pAmD;G$8?U zQqrYnb=Q9wmo0+7(@K5H7aV#g2CBGg8EDxDs-s< z9(P9jP7qJ_-HU$`61mz-Ozvdcc0GT-rGR4FF1EmLiZjXMYs9BwR{F2jKCpVkzxXF8 z>VNWq{LB6Rxo z52gX5E7pP?$e^=;gLM`g%e5N@arZ_0o<o z@@=NWX8nBZyrV6czzFfVprysdG95){DLJ|H%uL%mcb@+pT7SQv_QwLevxD{cQ`e;K zw&*U-SE+qF%J|)>M>_;LT%60A7Ifxs?Y;A})eK(b_oq5(jJ}#&Og-wa7!Dz}+%PoW zn?Y*yXa76NEd{UW`J6KBHD|mLC!yd?n6)ytq07MHnJ*yQib}-VaF^KXm8Od|6!b@J zk6S9msc`x_CIu=dWQsXrI+w|(`zu$`$pHl8m3CYnstYWjarczYSYo>)LO#>~dDAj+~qOL7r?7AF%>;ChW>KCFPmo zeR@X9%3a}H;ogyKfzpsPqDt2sMU}em9dWa+Xrd@2fA#8*_W6M)jt&vIrb5}KT#C1n zTA$F0{>ZoOV&~up$R+kK?vkU!D1eR~vRFGNlbrSk!D(?%>*3fj76J1)h!<#=c;!_~ z-Xk~3elm<*8E3U+VyQ}bp=^{XO1ty7JzDn-{*s2a`&e5hXQE?P`-9UY&JHt}gQy31 zeweq9t`K{v^t6Q;w@BgR*pv`l1hnd`e_P(>+ zS4SeLevF-6=699q?R~rr(7cS|9;JyPUv(rm9n^`wfB(z1$Z* zy^&Bb@lV#R7@=(_)G_tlR637$dKaQMPa@a7>8Px{$kVWp)pJ3*ontgiMH!p4*0O8| zf10gN_kI((+VMrmanUt@G{Kb5B{S`PL)tX?Xwx8P?nB~(KlpL$$(G@Sq@NdRo|I?3 zoUDJmNkMMZHnsb$j7)Qp@}u>FJ5Qm7DimEZm#2{@vHGUU^I5 z$!^(J&XOB`g1#^4)5pJe)_qV?{E_lBu&d}|A&tdCUrpSw;UihviIMvn>#a*-g?j7G zm7znu$4cYFZ@1G%Qqpy~4rk8%;Bu?h6IHL0+OwiK;nMDC6_l^+yN?(wzcy;G`Zm$m zRlPnjZQqsrooVxdai_VGpb)h}!K>9h%|Y9pMB5{M21(fm-mNa^HK{czt)G1(p%=1{ zL;blWt}$j^d#3-<`n8)|n9dd5jg3>RPdFiH+!v7YrDPk^!_w_+Y$r5lKLv@CvCAYR zE%Vy`{GR`k{36u>b_$N;>>ZQ!yqvb<*Z{H1T(%9}gDZm8&sZ3F?7Xc{iYyf$ybfUTi~r_$9IT zv%O~TbolZ{hdMszI_|PH)Tv<0)~oxTH)$?VxO0R2!SE;7#V^Y5*fWjVFFU+DNA{yb zzPIkPs-UR+gXD_0v#cjQuyI!!&kr?ig4Ydbm2=FWdxztH!QIS+S( zy;R9%jd+={Aj#6f6UM4+jbdVJLDW4ZNwng#xo-hBqi_ zCyLIfMqaq6aI`>pEPZd4RzVJ|bux#wty$*6EHh_ycxUm^+E1N|WGSN`pZ{u401$3a zL1-;~i1r$Hn(fa^narx(Z}c9?hbOMRzc}~`=4jTaS7V|y>BV%6kU$#0ol{|8^p7?v z;x(~5sQ+Y(aiw3%*)Ru|SE3y5!NQi(w1$~D$2WYORdd`9^S^Fx{uktpf7C`OEh{s*g=wWdd$QVU`@z&i z{R?^S;%t`HJk{_*ulhc-j?BeYA5(F%+_!gx3cjcQtd&oXcKG#vQjD4Y=4kudo)O}c zHPpJ!sa?7YlmH62-;4mKY)==?gEt3mOmDuwq0wFE)#S2+qWGf>T^pV^QpWXzlOQ3m z0O2+!zLrRYu{uJ%ZNo&~eVFvLo#-P-NE*=Ydyp_gN6Z#UwgPeaHX0;9aSux!`@^ao zM=mKJifJqpW{K5SKe0CMNj&}s39ux;!xJm|Nf~o9GXWbR!QHrn11FC| zNE%^Gi>-As;djIsI#3m2w6dBDB0k6pDc7U2#~$ANx1IDltF+PfFt+EjqGIS`+;ITX zcEqJ4x~)Tp4&5{}Q)elv(D9JcdO>Y&YD$ZaD)B`YToFTDOj4^^zS7w$x1KfAcAnrT z)jynAx0()OtXw}sz^xL4uaEAr$kW?-8hrlViQN+lT|n}D{P>R7$E?|TC0#oDSuR~Y zu<<1YTqAR<_oJeAp*LU|q|EoPZQ-$jljre6cC{jUk+7_V3WdIR#eE!WQ~r@LyVMgs zlx0hZ4;agi2V{e4ZvQ^^pmo!-w<5It?~2`TcC4>XxgLjns4?oN?HdJNSxU~{(>lPf zG@GQSirFvT`q44?@QHse-6HcZ?e#fB^Ga0CP|dPzCf>J`h-z5O5aj?AGG6UCU1z?p z%ges->Bx@!FYQThq1mgD2CDj``?kytVbt(?R#+k=tb@{6g(LHs_{i{^?K6- z@6*}rOJ3SvB^_-(CBCUA)#OuV6tf8RCT3rkM8*b;_M@nSQPh5Ze!voq&xGu6O>xs3 z|9Fy-s;e|Cw5n?N?BVCnJ8P;d-Vkrc;x-& zW5h%I@A!xRo%9ix=6|)l{C_1e?sjd-oz4oLTc_#{H||n@d`Cm`=AOq&8R7=XwfaFF zRj-ck)5+C#%D&a)cj?3-ag~U~2Z`s|qSH7V(yCY1f|oSvNonyM%=y z78q@KRkYF2P@P+O2Nze41o{1VPtT~MH9c$O8L6qXJPtFTHj#oid)Ah9^M2E79S+a;##*H9#%qYkY#Ut7HC$qF2n%h%urBnWzR_>EFA^7q z(5lI46*V^{r$I^ z^yPG7Qkd=>oN_IaK{)t>VeYHw%^enhg~RL=TQ^hcSg-Ki0+w0k>+H{1>y4)+6AryJ z;ScH^|Hvn|VOPd;KD~x@scFG$^Pgf#srDY%m9-^iSm++vuv)EbM_p71oTVZ$7j^F1 z7$LwhtIoLgFcv}|d*}QHL79Zwj@2qH2hKhHXOqw6(^P{NwdZT<^ot_wO`DTsBqd)9 z5uFhUN!pEyY0Kj*ugVtK0Y%XTSjLm z-w6FJDz_JJsfhWo=j^f`f3eGP`&cN;;^PYmmZe$tj11OrqN%IT(9s!00@{^Nm9WtcO@G*itV9c?1VLD7c|I{p}x5z8O!> z%EYCpDC0l2dqONAEnV~oOWohIZ#0QNE_{DRN&X4p$4C16&^;NFCpt|MlB7Qko~Ws) zY8>M@ByPKPf${U9Ljbf{$80>~UA23s9v!@ra`{*a=XZ|1e?L0OkUPE|%f>#h^*${p z-ZQ+U%WC?AD0BLqs2e8xm^NPeb<)`vy+PBHS1a?s6j;X?oyjF5@|;OX%qYs~PA|Xm z4_Os>p{f%WHj~XS6q5S>Na4Y03H{Bf-qRUrkY1lVFWp{|$7!6W{`S2_VrXALMB>e{ zXCvk1H0sGt_6PKIHOrl~K5w_qZYO__nCk@j!?!ta&sa~g| z_;>P;oj=`3wGT0HmStOduG-~SQLw(SH0ErqxMN(rlc||fD!+InK0eV`ig|JKtl5r@ z&+f7#>nuyv&IpE*&3egf$<+SOPuYgWizR-UHOmz}CwfU`TzlrYXWsGJ6_pf#v$a{^s=^{?B9|J6qWMXI{Mh`o_2}w=>la@$Vc>(G(%! zn{_fmc~&PE&+vwfTJT7olRe!qNRr7HX)(tsAueF>WM8xSTp9z*A(oO=6~&~~;?qG? z^Nd+5xR8C5iy!MgAM!Ol8`0A<@4LL&yrbaYM_<9~4Ug+*eV93U%+B5okI2!~T=|(Q z^IS{T76ah(C|+&R$X#ID2KOp`^w zHZMsW-y>*y>-O=D{iT-7GJ4>m>Fj7#PU`OG9{WhM#JP~!Mb>%Jlc^~7UE)^-nWOG1 zswy{}Wu=`Dbe+A(-rGSc{&mo>My|wn&yHQt!VUiQakT1dC-3B^FCQ4iE;lw#zR6r} z^<{C7DlWvKd%ANu;@7e-jn?|gjC~>bwe!Wr5w!U~*MRFp1VcHGnHr*e&JBr^8h z1+s5Ljj12fxhdvktNIr)7N{i`R2D zKE%N*w|Pldk6bs%PJVIzyp(VFje_-$_6c?8V{+ozaD{4@pX%^yvzs#m)gmpF=?GexR9G<AZd(oG%&T}h|+7F5{FDu{eCltxatG7M$qjsalutlFa{6${Dp1JOZ7l}m* zCB45$v*nYlzV5D9z2?_VL@v$tVLl=H6( zmhTR{_+ThBUvK_6+%AEt{N>97<6<*u3>V1CWEWVEa2)B9N-tp>PVSnT<2-S=YGo#D z`lX}cB~jJD!1;9A#^zfp_=t`-OJ-<{bq4(knVtG5q*981DM&%6R8rETSl_^T<@yMJ zilY6-W1sPZ)y}wF{nEv9?9dj8*i;waytJ9`6`#B|tr`Rb?qn=sHHqElRApz^swX88 zMt$na2Z@hUJay92*aXGWRXd??sWfyStjtStsd#1fHFmm30GnOp`)LfgIn@39`8Wv^+WCCjh4HLzY767xA?`sMo{wIpO{O9HZJL@Z zoWc*XFdt(&sx|0m>K^r7-}vgc#;P`l*6psedrz=2y)nJf8Y{f<0S-#K1BWDQ`|!CU zW&rv7Lnru9Yi;IC_(1KCFCR2t3a)w|Yjvurf_S>^u=!_u_UPAFPC6M)O@Bu`;_m)n zR9Id5tE4IIe3g{++RV^noQw6xz42`9xmw$mhT7v&b$#b|D4y^7@US{u+3$I0vrC%a z`;$!PBrj)mn{JWt;IezDHF})QdaNk^Mt<(Tr)x`Q|*@_>N>vz*WTkEddmR*$l3%GaMY*?QBveTx0Y(O+EA8JQ?xyV_lsO0&}6 z|C8#eqPtv4qTb<{x;NEK&14!~d$~JZsdw%j({CLg?YMnx^WtPklb4K);&?#7_kCgo zQO7m%+;dtKp8NYB298{-Ch)g!{&@6)j}U#Do-E5Wn)={(po?g2xENIM=Ir370g1t? zwZ#xoQBz$#N3DB>_j?z`)WyV{MeUmzSCV(y&s-n)5==GTnW-DkVz)e(gsIh#X8PNA zw(@DG9WsJfaB-M9!q+du*!I-g@V7Hwh?_X0an^rkVYu`W*ORu)3DNuKE|tYwU-~u* z=e)6hM(nqFAHC+X31yvVj=w#uLD7^-Bm2)kyvPo;RU8LrjL_y4->odl7q-|gZ3H>g zvIvppxp~Tm^ zx>JWId*?&731Lma;DCY43c}>`wnX#~BDg(xlL&NaC8eb>Wcn#>)5tMXP_t07wS2e1F`ZY!*yI^!T8-gGmFaV1+G-~dj zsHgwyYyC%8&kqG?n|qEO+<3BDO~NmM?Wd9NFuNLg)U~g_KCC}T?U~+0w|n>?O%1w5 z-@<+ub2PUhV7i4SMY*SEbH*)0RdxLCzT)_Il${^sq;x;k*ZcqD%{}qDHXvbrQcr%H z?_HQu@o0>rFt%-((&e$EJ$yFc^Z)$23kN`CuYM&keTctr%|a|Eb;WXwao+LHuxDXm zAr!4UG&Y|1wauH#Nq+$3=>XCjaqnt8-WJc!KKx`_2975$$3Uxryb#L;h+8fF?@UEA zjI84i7bd_!YB6E;rSM{9kaez2f=aGUw9wOo6-xouR;8VJeF1(yn*nH(v;Cd-EF8Ga zTdIHrV(6$ZV}geaT^R53z$-DCZC*q2NA5~*^SU+GY1?3y;h9zBU+SyayYYMrHr+R$ zgjdzpP7M1W1@mrBJ9n<8oBGQRiP?vjO=xEW)s&)yG?%fyeoIrTpI1p?sM*+9=|Dg# z@jkG9i`$s?#tThMpZtE4x8s237XLD*`7z8Lc>~Nj+7Uq%>AeC@k)6b6(_{ey$0 z#l@(89RtraD=T=6f7BQ10GKTXxEAJV68MHs>UXnl)PzaW$h6bM#enpJE+)Qm+%_JQ zB+WeQcTs_m5(fNMBqBL;=Jjs@A`+56|1=;L;qe#L)PKJo$=$nWUJxrV`1XG#+x+jB z{{N-l|Hl_Z+6(l`JJi-hpb8iw-V12yE?{zjyYu4peyvfG#9T6F)y>WHdE4 z_7|Mr*z4EX;KQA<{fp$T-Zwy`O*zi~{e7U6hMwT%-MjZjZM+M~!IS@!!oz?0ldo(C zbtfYwI4lDEqyd*BnR#5LPi10BSJzUIS2g9R-l9gjkD@- z0)mQi$J3_Oxle}i;k9ibLqm{+hMvCTkQ50S6QogK`+!m^h31du^2Nsr$Cr1Nn*R9w z0JR!1)LubM4|(qp5=?J&3Da~z+UL|Oz~GnTENpKh+7-`T4j#Ldln|dIc$70Y;zPu> zi;VymR)&jpfndE+l##OrFAKPCP*we>?|{#~ckf<6uuU6%pe6+eVk2glBnWj&6zB;M zX*A0Lu>>C9)KdL;@W@fZrr=S`F%ZL++GtN2oe!g!OJaOH3g0G&d7cQSH&jYWNddY5 z>~gZP0p;htyTR0~2^Zr$emq)8a+{2ll!c}BA@`Y~h{554wYTP%>%utWe!M9B>?&nj z&^|*kZLQZ8>BYcQRQBx{GxLKn`Z_isf2U`Es^=(U<-xL0Vd{6C!hrIk+QNCb~=wj|Jt=u?*oyqUms**;sOm8z_F$UYtSBn!p8F+zTj;) zGql5S5m+cZqd>ARoxtbC`9hRw zS?2kK%bQEe$_eJ$sF`EwuARa>qIe+7@)#RixQnOWMn^F47`8$VMetTTKlp->lthWj zLNq=#bLm5)Ur-Q`)exq05uHD1Bk8#+^R$q6q(tr&>l47iehv>yNlI?tf3myG|0o!T zsp;uS`Mt4&_iBcLxGc2!`5dbZ6f^ulem;0`!t080t^p=B`{zfo=cueB0M@>CspF{5wX3rPMQGsp`VDU^tUpdj$qh-3!DKvXvNzw)YC~fS z7`?p0=N8A$v=!^8fYW#5wK?_tOmW<(;TQRHCoFYaQyn>X>`=^!Ts?B=P=rYCyk=kC z>0jsi@C&`K3QpGYeJ22klfMz-s@Gl+Mgl?`H0Kdh42H$v;0(YEAv95~cTORc|I;F? zsAy+-xwNL{67&i{V+Z9p4J;5ZuU@C6p5kq&kBdv%+1XiIP65_{E2Eja4q|~-!A>V= z9szX<{>CmAS)&O-BhU+h2L`JkObxAayyG)w8Iay57bdTXONQl7`uk8%L58fZ zP6@^Rv7I9#lJw6U)|-L~<#+pp2rIh#fg*v2kn(4-kahu75VVVT+$6KY69V}=cI*J( z09x4OJ$n+j%vmx0EI5IYVy~cG-c`$D4@lyf~5<)I3c9$>jXX*n* z6aZKo-Cs?yW{tJ(I`!S-_Xd6)6PLs-?eFUY$@R-1wcVS_Zx;ds0{8{_Z*`31JC($@ zl_J7Qdn>e~>LFEHfwQpFqPMD6rh||mKltgj#JP*FcK|bqF%PC@W^e`LAaLRo2y$}z zW}XH45xU!;{!Ne>;F>%iC z-X(E%b_Nd#uxSFucefPSuG=Xns2GF^5JT{Yl+u9Ec3PT=gUSokeQ=ahQYL{OS}2_V zfro&GvA_Q=*nzS*DE6?jdgK_oWPDFkne`DodbG5q#eSmu6SfmtY|zOUfOY3KJj~1c zoLb+==&O!+K53kU(zxE*^mI!W`L6!aQ4tsbAiZrNpj2CX8;dr(hmv*R-umz=9uBiD z=11FN;^PVMW!!F92LSn}b_LeZfAX4EZEtH^h7cm?A|}6zro;qRw)<#CW~fSd{d!qK z`Dp3x?wvbFT2co=Ct9HUlQTe|H6k&Q1L%U-WvV~t{;CfDAUP8x;OzD?)?;V|`mb|; zUFh)rkd_8B6d^>c!xc6(HiDLoAn}xG69Kmtz&ZwrhBmaJ zFl52g(-RgLV6aJDKXYT_1G{!fkR9XXbOqiS@>Vo?+J$*}h(bFqKqRxg9CB&M77n>L z*dE&Q2y`iMw(g8~((m4_0$#*-rsd^j+Wq@yezo{hpE9aFNI~HP@TcN@WzAmN!1zy! zV4wT$=eobFrl*H^GFtrNUbGhul1QC9tMa0)s=Au6MSHM-6Ag}jg4f-agVt9X2zTHn z?=q}b-lJPYm-181@n^&I$HAHBfq`f~c+azN;>iHT#z!$ZY2qdn!MoRs2b6j&JwW_` zk-sgtQ58-n`sl(N#kTl2=kLhvdRX86Eg>Kl-tFr5L#H8Z&4#4nndo6H7K=;Pz;_6o-%UHlrUw~U7*Ozr% zRYir2Nw3fa1bLiDD0GGM`@(&JtG!DcPY9C^p^=8}w}2262;*2F_%jSYvncok3#ycC z13WyK2Jkt#_!+(h+P2e^nZ@Zr{5E92`tl4;?uXbrF8tcU84@*cGBV0rwmiKlDprLbcc|A3xQKthu7%4zJ?) zZ-sD34?9VutM(pWvt)v zo4k%=q1oi*k7F{HO>LUq3dz$(H)<^ZdN<9uOH3}DH^1zWC?T}-!SoWq%CST~G!%a=g;*P}nMsPWWlDmyciZ(ge&;R!ht81@Y!)2Zjz zlzI3~Ivvi*RAIh~2|jw}g)@ZP&70*)8yFs8;eV8%HmI#Fi~&rxSHtETjBYb5yuj>c zd$1p>6-dVi0s;eRy<4x`6;#KN2kgTgE33$?r*hLrAJx23G`g(?1tzRs_v&e2W0n=C zZ8r`Z7<t={ogp0Hks(^b{owy^cPfpa9r1G->$+i5eU59t_aB?KOfckNA{N4= z_>N}|3|X-;>f+*`8yW)Uh3>extbx^lJ3!-IZg`gl1Dld{auFX6;Ay*C6nG}Y1fuLE zdJVN8fQV?OU5k!|nc0F<+GOh8n+1{D${*C?(D8;3K_juo&`}o)$Ao<-TraTy)Np>2 zPbKZG)e2gp>!PmZhd7hvcud?0N6gF*WdkV17#SI1BSG4StAp+nWFyFMQ4R9`uH;R&5Rs4zH$;BT$LdaZ<#pqj)B`Wa*wp<|0{i>Nja={0C4?$q#O zEdB&(MM1`UwopFB)`C__Qvq5I#G&w1BR^m0d&x;QrYTxA4*7)Nq{Et_mIjo6JM(VG z_n-V$eqjR!&S5QJaBhNr#K$)ezYNL-7ccL-ROVvg39)>r4J;#CfE-jHUO6;W7(tt& z;x~vpz!5E7j1$GH;z+}?Ls7x_X0+RSnejj^O?y+oF=f~&-dpX^^IJeL3kCy74?)Yu z^>-*{$M4;s9E6G{vOt7XeW@Y@|KtbC?qhPVXs}h_KSJ_lX3ab@ zn3a%|n>&u=0lN$P0xJN*&Hhu@$(XKV&!C4Gddk2vD(X6f3r!O3pjQh>G+ZN1MX3)h zqG=mU3RrS@`(3|YiRWsAHjhK^QQAj|iNCN$b_yP^JKY+X7TXJ}8bTk_oVxB&Sm+A8 z%)57Epp}3&%6jyux}l7xeYdLvq;V{8PchyC&(Q(Ul&mZV5L7ZUUg5C~Liay|5DWg* zjspiEY9?#Jplr6HAkY1%sXGIn9FD*ddLqY<9|uznm%kis2r;wPcW>4RcLc1mH(=R? z#vJ3@D{)*Bi?iTL5e zEgc#!$6#9tj?=`%niM6~yB9FbnNYyR(zJxiBMIL0=by;)HCcDp{=y-{r*K@DU@8tD z`$MyB=vyS{fA4hjvF{glQTBxwSl9aCTipOJX zQe(ps-{4r*7|10b`&~_1k6cI_#7D4~kNN*3DEqj^g&*f+||ACt-PTmG*CJ5x;+ zt}nH84wWe$vQfy*KE|PVu*nf?W=` z>+1~z=O(Jj%NR}gx?^_9heY1Hm}p4BMOW|qc>ca;`PS;G#5{Aa%xb<@-|NfGYV$8% z4SirU_c8s-bh~+WSIG&wB$2%7;e?|R)(npKtGx$C^tAN;JJI&dpAF4=Vdol_su z5*x;+9`A6w$?DL(;5coOT2@k;x4JYm%k19J?9w5e+?8=W=ZB+V3uwF>r#8<6#$Jjohq&Nan$=P2HgsvEoTA6`C&%C={O_hBt(x6qaJklDQ( zapWn>f_NL1-Uj}K7U?k7Rv}-Du+*z1)q#$tH#ZIh$z27)i=wJLEiQLnM>V_N|0Dm{ zGa6Q!i7qK0O70KbY;5dy71S#;2f^RHvb^&~*>j1Ix8Ji@hBKR+%5~4Gy(#hBcsvq{ z&&^-w%3p`Pc^SXNc#(H-&%nUGe$t1p#H?mjB55XC9#*{9QqYm>pjd7m*xMIn;L_q? zw<}zPmEC&5Z{=0h4dt{hr>_aNzM{G6i`yCb^;Zjc(l@9hxGdD=bfbaP5<^+@aqXYA zLt4Jzz}c_OEe2ohrER=h!>hf0OK)(d-lT8D4(_D4_JUMnhQ)?dGTI_e@?#_VNiItx zWrA&8j|*(pjf>{?7b+AtNHP2_d6sT9p!6WmEPhDubU6{$k01NeJHJ$puq z?@@jD4?@T0NIa>h*NglE>EHsR{#oa_UzU%%MdBU$M%6cdEUKxi8%MjjF3suucykm0 zq>4%-q2cj}+=9bc)_t*qt?skU$X_zS!yQ>a z4q7M>zyJO|%38_MyJg-5wRQDM|fiW zk#Ej@K;rN9LNS3!&v}8S~q5 zmYkBN)7Io;;FJw4s!%h+7!$ukiaBx^)+g^&_o?rv(9VR0=ySKek>^uh+*@+xl*J zrPPy-&d$bvgkT4uAcl><{zlRMcpD<-TcsVl0$Dkcp#j#DailichHXm)DYAPQ`PN+7 zUczC=E&lmlz_K&i<^Q{s#Sib_>jO#+7?nH1Z*!_I;9WZ~dnI zqD?t#-S~Tzi}g%mR$rg`E=4U%-7<;Z(%T&M5BJ~aTT@?UH7g&wwEy11AF8cM^cnlF zF%;h)vGq1D%o@ERq5Q=#L#pqq_r00tL>Gdi_6yk#rATobN<>-yV2tk0brL@|a^(-Y zWPkll4v8jVUiKX&9nB~0uh8ga0EyJDvd9JmILC?RcjptefGs$U@F9#qN=TXC=8(X|?TpB9g<3vmMKNnmaWj zKHJ!~2M)^?y3H4&bb_Kb*?7zIgM1X6<&NQ>k zgVfPk)}?PJU+C@4MFvyuq)#uB7qWcrmbgwF!45n0=&_NastB zoR*}ap*H&!ukB06!oE2zzFfaXOiQ=)`sEv^#TiXCqc@59ckpLY6sO}U>FEF#OUYbL z+kh9Z2tREtNF2>@_DCh z=_|$kxi^%AO_q=9i0br&tBA*ZzCZU$Q_Gm|Tdov$;c6_Kz^SFGjml2foR6jnPj2Lj z8IJkTgA3OnovQ-VZEEId3frl2$AyaX`+$NP(r z>K*lZ0f_MR(CdYk+QO6lKdWE`_IQb@t9ZzzDwH<^g;mYtAlH@G&a2{at&^I!ZyBqO zYQ6mMQg?6RswRvUV;Sl0&VTb`b=R9!8;h18!NPm-=512qCqAZ|$)7qteExL5KEK=3 zg1Gv7wQY;2Q{Sz{)=zn-Mq7T(%Xf8t`TX$aVwss$a)2a9oi|5Mb!WLDytrMIIthTIk1@?2LOe#QKFaB26*=+IW>+WXcB zUHwZ(eU+Pq*lgqG>2?eF#_+ zLG*_fy0tNUi@@4p3;%$f6POh?JksS>bYglvdUT@hV&CekKf}58ygh72HR&C-28IU3 zvG)t#($)!aNBxm9)l~o^V;b1_$dG0=IHX2rtj<((uJG3AyD-W)`5XO(56Ss@?Mi)K zHXbk+o2l$|w}zrUcpJqiTg)+1W~yPTt#)UgA%my$=Wk0~byL{f`0;JO7`@rdj4aG8bp?cp0zc2Cwmyu38|*d}nuXmpnt ziRv}=V_pnPLPhS45eUXY8>!bnopG#t`urJ%#4V~@id&<1U5)(cX{>x`1te7EYJPmX zRuH1_?Gd?UuxBLGZ+!+<4xdj}w#&VYhxYmIO9?Gh*3`t&)}e1`5`sk18y3_oEUfH4 z5q+uV>o4Ui^G$x8`&^g8EVMEIg>z~}e2$fc)moN}{2qW1d=Z4CCV}V!$!8^6*Kl^m zuD10Z7kd|FIO?aSrdGPGG>TEO`g@g3)m7=LtIh3m@?9ZVP$%rz?RsQnr2T@0N18TO zx`+vPSWRv)U|<+lsVijXl(?o!>}kV!^tzU$b9o!Z`fgbTxgy7n9&Bd?r-wOqeywRK zlzJS8MrhL7PfMSuD$ilcYxe3ld~A1z!HfLKVX{hBze4BL@3zqxfcxzib7;p-=JNOc zrCAn-;|A1+6R?E$SJB3%gu)3GBEceyEf#lws3x_bz_UtmkvjySlpI zBO}m7*Kw@tVWuw}LtN(mJoEZg{!OUN;Yg>t@~Y?5<+G7gJP9MF}DLZkMBP~I(jZXBZAnI zUr=a$nrx5q&!1i|A|r*f_fGDr8!;(>vkvOlf${NzDzip$$L%^5av`?{kE;KVV{%MIWr@E$^=6cleGaU04 zKSPEx>rb9!9L=pK&}tY|*xm5ASlL_<>bKk7OolB#aBu)w-&*jJtfKVj{bQ%NwdXd9 z1!BjHe#&0doc)<_)W5cxgc;%K#a?FW7S9vHj-kVuwyV!S`Alq6-*NWt+27`@C)u-2 zy1y`fblgX0X?dpSalz-J`P1LFdT(96G0+>JGS!>E#koOm@%L!Pca8yH%#c zLcv8lR$Hdx#QK^0jbe;Web#X3%j-53D4QBbi|M=7tRkt<8A>0Guz<3yDWX!q1tRZ1 zefkV0A5BlY4;%rr1#_wqHQO7QhYKlRcY~HU3Jc`U$WS}wTH#g%3(|poe|JPY^VzeR z0I)z`!Ei2;?SihuR3e&BSLx!-%x=XRbrsuh`4?}wN>rBI^yG@zl)8HTs?G?r&2N}Wj^nJEpoCxJHkTf zC*Qc4dQ({Zf{Q?t<9F#W2`=4fGvlUyccw7|v922uG6Rx*zD^%Un{y02OYb!c3Hf#~ zuKSa8Fy6N}7r?W43;@!+E%kB8-^zA)g0LQFJGH`06PeQkyy{u6dn(zAi0oES{ym#B<%9H1lG7 z+3fWzHT@8W#od=rbF%4Nzk0RUfAf{EZ0@LF)2|zGuMe2sir1$VeR)6cK! zW~P64SA3UkpUKa3lVa<1k>PE%rRMqk-;PNc5h&?KPp7PSbS8dJbQ{_5-A&heor|3- z+j;HX_vd?_yTyCjjD{N6(q)NU@ZkOb)nT{FlD2icg5m{$equrb`W!l50DC866h5S= zwasIZ3t%INX`re7vFP5ek3gP@VoEOGbNxBosLr@=-mkMbgtMEJxw*L+-T83JgOAYR zvnDTIzNDCzE`@38wg=m`y&N@_?V_zw9@*JAo4i*ijyo6)&cZFXd>YN;BJ#e*nif{A zgwC`Z1uWLT zE9SP|2E$v-2CGx-{pabEkWmw^dUcbIC>YseCyt^$ydPTls^ToIwl($byJy zvJFog+(samPGU?<)~%{nU?{=yX=`c01hG&phhWi2MWqHqrO`N4z_6a`Eq0H)$4Eod zIy60sj0xWXrwVsMSm=W@f*}t)qbw>s=xCL+Q{iL|OQ5T=TWbvEAh91mc>*8FA3)I6 zvM2;fQj&z$wrgDqa94KgxM^-+Fo%K$N|3C@$3%3hy;Z5;=B4iTEV8U$;LtKx=#QgjY$QVE@ zfOqWT&W9Kx2(k~**e3g0@a|yG+3{9#o{RhRX`koMA+nGP$oYw!66K)8HxNng>&rIS zlEc++=fhKuUu9usB@D5^H>U^_sd_Q%4*yy^{dooVpjWnTAnJa6)#vcMoa{ zXgJ-3AQhSO5Yrv?b8d z8{ES?%$>myJE#HxRXep7!WbH}{lLJ&DKrOUVT-^8Jv}|BRxB(m>Q2HO6C!JX@AwP! zT_)C-4qb{b#TG!4$6s$kgBkRNfusG< zedOi!^x3oa&d%1&t2b_hl1IkJ{~8{?LUJ>s9oGY;5lFq~zP`ij3vcY))^MlTT(OD~ zn|Qd`Nzip!7pW_yR1@q6gF)>gI~dX- z{z{O{qTLrX;@lkWLPl1$Gu@~fw&Z3J?_uoXgq}a}dA2B;R$q@7 zxXjC$we$$ABcwE{s)t4oI{M+U1_ja9nbo}<93Kw}3!5AnAy^cS98kG(< ztPR9UFz^_k2L&4}=4p-`A&iPA`JkY^<124TeSI}v3z$kI@|YCBhf@UCj_Cy*+6h+Q zur6U%7^?QAtPC4|20IP@ZX|p6jt&ihsVc3j^Sibgef;FfbN6VmFlH%VIjhq%fa%8! z`Y{Bt?(XjK&^sRNAg>5F2R7&+B_-k}p;7YPyYB{R$OizN?%THyqhWE8(A9Oqj10Tt zFGLA|1RBaOo$z%BT&V_eih7E!D5iz zK`#kQ%b}qmy3h(Be*gkkAuYnBEH*C~)zZ<@TGM07!HxX8ClG8KJ@FH!rehG1=@z@y zuH1#mDPrQl;{+Xg7O(QkO3aP~xozEp62z804I)H8+t?-ZsK}ye24DCfEb~BGx}jvUaODK z16IL}rM8H5&aVE5)uiLIG%E~(H^YaC^4UveO2|JlpT!>3~XTv zy~;uV{wB0_sN&pV^9h+cz91pCx6cB%4%rs8?w+2W{{|I2c(Cnj#v%T*XLF6nz+6Ml zXJ|MGsu`+QXb&RzUsJ2-sBIi)8M$=-VbxJeN;}FP|2ez42uV>CI8n`NE~yLpHz{tq zwcJUcFWUCFLUfJHq41hp#?R^0e%aw#~?@G;P`9li06e4ldl_@b(D!hZx3SEUaL8>@Hqa- zkFx6Fx3?_JU@51rUv%AfR!|m~JX}O*6+j&S-6i9C z=?J`2-Q2`RpNHe<#a$n!rzh+>a@(yDQ)~WNApa1mkijoVWwcqA)eEhGU;9$ z(x_a4>V!;`OX*5XCkP>6t zV09j{XAlu7K*S^v=5U@$NU+%yl4V?jGNH@)au_wIR&;DEY!)lma%_H!si>$3aje*x znwVJY#o^eQ5-!#GvZ59A@?`^J7HU0Yj0b3WE?~+cuIcO7M|J@(=>#3a5bNGwRVZY3 zTQoK_y>lmfg0NF@NJD5BmXns9ea`#q_nMkdX`e41sw5K6C>ObdLl;sX;g7xx``ck| zB_Y9rd|zFiFh;E{_2#o@&ma^<=HmDCXMpX1@-;pZnD=%^G>mDg&kTW$v#2E_>8EWcUHALxu?UO{RlHK-aNVJl@M z%P8A3jLJaWpR;A~Q|pwj6iINganbybgv$GVPU}+_)6^|!6nAzA`&cG4iV}64CP?`^ zK0es78(A=xHo*qAjuA_TN{fk(j^A3okNFeuj>ahkIig+4aO{+dfwFKR&)2?H>w<29lUw{lMLUW+-OGKkjt#RvAVi7Yw8uQ_JSXd+^ zC1a7`qaJ{|bqx-2V(y!vuV4Q`z0pJyQqO$$cfY6ML0)u7H zSP6s{$9Gv-l>62u_7LZqM>r(Ggaga2P}@bLmx?8#$NgjtcjjU7SM%x;$_A;v!?h*! z#TKi+rTQ~YI?_K9-VZ#CYzq>U@S~70XPj)xY9?g{*wf$`<-OON|87%~}BVYhZqLH<9kYD@cf{*Ie|h6|TZk zcaZCuca(5d-j&DFzr~4se0`yd(@|HiuvmeV8^o@?{X#`-j3Du{&w(aPs979hA99$t{ z0u&65urr3?#4AOCszy8kf6Im0Uznc)$7yPJk~D-M{U*WVsfn6dG}KgwTxGXfYQkAB zu`<>&Mp;TxjZiUk+SNGyY61zy-+`C3UUV;@w^Rk>&7D@kFAvLZ0MoiZ$9Ia=ENrdA zY+GrUzwUXreje+eD^a7~d!8q+>e(M2S)Uvikhog4^vd*Hqr#-PW~knu0i`aM$}8Lv z>n`?k02!knP=ZA|G(_$7q$|Wwpdj*dWU!%^-oHxu4dh$Uc!A_Y$&7GXry2B59@p2W zFYz=j%?2;P*;$x58muO)UqN3dH&j9BgiuVvj2Hk7?*tB+QTC^`ns1px_v&Kj+%Q~0 z96;?3zvOtu;rIjg=1@nWxc}-+W0!ncKck&s4|L*0|70l)kCBD{)4BDl3dgS@lQLCT zZvpitXaz@Xr>*ir^n8GSMhk{@4nb>*+{;FHx;^8`8c99kBQ$)%+>*cW3z1PG=at~K z{=Hx6z=y>m#7H$IB~Y9sqe-au7j$^EZUr&eA0Z;lxc~!@X-YXdi4R0h1CCgb?U`iE zzXh^#nRf_dq7Tu~?B2cG?mGkU4RncMp-!SVD(nDNBKnqvd{!=seq1MXl}vL=^wE?% zN&LVyV25CZx}z|B{rV7;g9IM;_H7K?o-I_uRh+~IlCnJJ)_U#RCSCJQ^5XuzqW4o)@7OmBr*CjeTs4=Q zXnmbhc}d}PPwuU@ z;@}s7VTXMp2Z-J?hKRenUAy)JazY#(kjY_~Z(K@By>W(+!0v@Ve=s#*0t2xiv6GXR z$72(~X-OxF32M*-+FC|xUb|M}lqhkgb@+*&Uma$fAUPHRlaKTd%GTZ;t=r~_<10#Cx)L_<_1>Q8j0 zGy{yoIJ@)rmw%L&K5$8NN6nA!sD;%9v`I`}^due|AIB6o>l6tLg`Nqld1Vu0Y3R8nX+~TOeTGrjMx|sM@Bc!r#3kn7d;>CDK0tL$KHf zWb0$6`0LzN9K1tPQPmGQzie@D8F3wvo)$NXHZotasJ@&*T5#U+NSm$x?-wV!><<5C z`xcKGGm)<)E?qe(6L369s^IwR zz_8bQl+?51k9`j;ToSCC_Mg9B57#h8venOGx-+wug3-yk8_{wjkTyM|8kdgh<&d}bv@_(n)SA> zHgl8N>Ebq@8XvmVt-4QDS8GfXtvb>j_ntU>f-CXCqnmP<_r)@mm41Z{H`C_v&q-PP znJ>#qRg3u3Fux8D>iOA}Zq%#3?mD}7PQ~YQRM*b2iC-5;Hu}%sxNsvC;1!Xa6=6nt zfrAAGt6aIV3p!=A0lGEpWM_{>ZiZqhCo>a|7sw3IyaSqJz9AtZLP9G5^dLvZA-b%0 zTC&&yPWI3ULzq;ii=lRC0^~e)?Cf_#F9@RDF;5HrLnS3doAbwZ!IgZ!Z+Ljm@$uh4 z7ck5CK*8b=x0NPr-~SHXPEANKg}QlJ&%oNzOV3(KL4gb9F`{5zUT|2L>z{Mj|0ux` zF>~|sphC{UFynh|#mI_)d%<*~C+mwsG93}Ve~!>rySUBh088m}SXs&QRe_dIKQBPersb94;AZqcX5RK zihz-i)nLOVzsSb=5%VQ0aO<7>0b3pyleW)QQ=Ny{;L^8JO99 zthT4Q^RE9bLvm6g@CU}v>BJ(V#XB0U1N06?QSu#Z+;WUQ(&(R%{E1rkKA_#^X9ec>oUL89gVvPdA2@#5QJ-~XMUqS zZRgINGvZ`x!CrV@a^^wJ)slH=q@gPIE2(b*?qa^8bkW zKGaHPZEE}64%1JM9<1sUSrzL1WzkdY-uvwvVTc0B2h^DW8-WBdPtMQI?(YdQw(f|~ z^NK(@EG#61d4FgP>Ki%a^JkCG&xT*z)%CozhYzFK%-Ww2axq5levr){vB1vDQAOJ@ zq~VEclwY{Rv9iFbiskUi{(%8HBlYUzIj7m!_?M+H&zZSkm;bQU_tc!30_KKqDZhWo zLkkBNXk?PUKly?V+uGT&!kv+Y1*0Gcv(%xa4iky{@L{&P*dU++<(8M%WxVMN7cO9q z=-b{B%$2*z6O_U<|Mt6&X}iMqS1K>**9TeBu6UQbS&^PpIHJHv$=Egcw6dQ1v{p&Y zqKjwUq1gcXw_BQ*`uJ(r{^ARd)AHkz|9&6D2M@Te%~?E{*?uENWcreBQ)9eJ>kYRY3TMexzwE7%iKjBp`O^4yuMtm*m_8x@ zJ@wmdb13g&KFj9Lg9mRpeh>G1&|^7Rbf)L_+mD<~Kl=_Qup#y`FG-k_wIB7;dZ!f` zA=z)7@Gbd;k%7_3Q2*IBa}V(?>_c_3mdKPV<{9m@LA3Gl34tFu-)eEGDJef7f2H*L zroqj8_sPu$St%PwE#>Qcx4D*%)oV+;&AWN_uU1V^kTVc=N*^~Mo=1AQcTe)5rSr@g zR)^=aZ2)5tN!asga1JU}q}C8MF72P885kN0RrZ$I{*acI_NtuRJ`xfDon|H`rqAyP)N|qiCd0p&}nI9gc*~3n0RfTvk}KO#1=yTZh&Pl zyWL8qQE6>h?b5e|?@xO6GPAM9U>#Zdc8)+@8JYlOYBXnK0O*TrD^#m8(B z^SIm5j+y804*Vvy*YPb?tc{Gw>b2iWaE*IDS8O+D&3>6JH8ta`z&Tbg6XS+^T?>K+ zuFDp;Li-F#>lW+$4>LV_yT_ZG(keblT)#i{s(C|b-g&iQ@t#6w$JIdkse?J4Qwp7o zob$!r)zVGs&4Dy;dY|Q9TBdQn#iT6$n&z#q)dLcob`(&F$|-yk=w+1Nag% zQXr@tsoz?-L!t5@H+Q+d`V8j5_7OWC^-8^NF8}un=qcV6&;i zKb@XD{e*$H=g#R99KnGv&OPyW3E9XNT0d`?cZX!$vnBFX49!WRa zo94M54i2!Sqy$B_`^G9h5TqxeWXTtMwPer7xD=eL?=l+PeAyn7d%n`?e!Mc2zVqm~MnVYWUAFWt|h?9YAb! zeISYmg2VUmL5CzOtkoxO6vyS~3u7xGCq8w`+v`?2w;oKX<{`kl5pY){|%SkhWuhDn?aVS*0L_LBkfB3BP@d z?OHulhTfnWL^-6o_^PQ{pB>zN;zR-llA-wxBoQ#KnG98H$h4vF^7XC7c$Z&ePsrMl z$ch2+K@Za9W7H{HId=|D?~qeMWys~CXrJv6E^7A}FJasNt=7XIV`4C){2SVM(GY`5 z?=G5YQd4QueP<6f_4I_JAv`S1mp=VvM1=oW13EgNzu$4y@qgYu6z;#mMCRX~)5lVJ()zyRWA9$f@Pj^~gMx!8E6@pbege?II`o zpAAOP1M~}8V+u7&X%IxLtWkg>tE;LgM4FzaUPsler-Ju^FBKKg3eQXRVV?upBYIe) zv2~)OA1BozlJ}W0;G5tv0Mw=fz~puz`0}D}X7XG|UeEcBdG&-o?gw8Drgih}wmJ$? zk7F7!r3l&4egCs`;ju$L_!(CSKzMd`7AYyZ4!crsmeo@V#6AT3kGV-VbXbu^CL}z= zTss)qAe{gP{x&cWy7mnt)?6bMz#y1qHw?BBS+IgwY(3#p35{b|9i>UTCln_II_~_5 zTiOvD(i7g(xTZ6xTMlXw)I@Nb4N>X;y`SLs&N4alWaqPb5G!j4_Nz0+BkSKef2|=c+#N$6vNLy3#s69z_-y&5BP{M zj&6P2aZQJDdDn{j$6Y+GF&xo4!p;eKMM85|j-3_}F8jah7(>du4(I{399}gxF9rij zNJx~EEu?_#2j+}Rh(pyM8w(!y=Z_x%6BFa(v3cNT;1duqf1m)~7dRbgd$W+{gIaj; z>Q#ELRk5aBr3arX`qXlfTAWOkhXQ_nlaqN^Ey0s z7!gKn+-7lhveJOhfKP^RJIljQ44?26caoAm{g@LsD%I}KEu{CV_cJ#)SHR1c2+B&T zs>d9-c$oiZ22}D#MOAgEzkhb7{}R@}qM{-WLy)N~BMC`Kn8mCJcK~1CueFlF=rjch zg~8p?A3pFREo3pK-wWFW^rUHPYF?3)6!X5fcydO~w93NZ$#$>H($Xi8i)hITb|{WQ97VroiA zgBL145D`88zksKrqodOeBpYw<)X}4Tm<(rbE+;8@&a@CEdm&hTABqmuNja&AkNU#`wSkq5?>6`@NT6181SaMv&`N`MX~PKQRcPS28IP~;9X=V@LXv3 z8KdUR&BByNT35N*|7iivvgbSE?(n4%CU6?75B;0F2l z(h(0mJq^D;rvf7Mv#Dula4_WQk!?i!w*SHitu7ptG3XXyeZQO>gJRfFP-=>DX@ucm zUt7LmWvoT#6sD+$3$1~<@bDl45os{Gy>AK87^{y_+$}UXSO~N4a6))lpf6e+rx6ka ze4)Ml`Ewc2s+ffh7w7BOgHm1bp40yOwUVHik%9fDquZ`m`1<8r4hIGv_Mt3!xrm!BD#0Jd*$+4nj(j-w^bV*nz?AaFn<>9a5r z54~R#6F6fc;f3zwBL^Z6ijJ+%^81K!;ChAxyb23LS_m^6cW5E;Q|R&9bixon9;1Iw zQ_JpPsnO+GiN(7y6PH?4cJ_*bg4^#ePU;R0c!=P+@$&{8+x0I4Qfn)4v;u4flmJIJ z%AL*VEF9AR!31z%LCm^vK_8>rK|^Sz830k)|AB7@QHd(?-(b)uj*t&(bb4}p-g%wm z4nt$NZsjE-fZ4S*2L}deIJGWM2omkXFmg9YM*j6}Ax-89pT|Bl&o*zlGiOp={TV5#`!bsl zk=L8>@FL8wTwPs78fb-pbl(?2o>2WtN*Ws(!5S+*F%jc%$H&G>pVJe06d(S3ba=Qo zx3q){xj4iXZb(p27~J0Gb@8IK(_%+6e>D|WI-a5&ZsDlcHFx)7BriB{VHJb9nR$8c zR@>f#Fh!6A-v&Ss-S0EAv!F5|X;TG>@$jL+L&CE7;2K^r>Pf~iq6<1^n5L!Im45z^C9!1Z07|XL(&5&$>}oE zN)$I;G{2Os%_JX?IL;#I74aYqnAb$>IRXA!!&`HCPL%FLrus+4cSJ2no>_mH|BlX$ zxI#*-C@M$Xug6{b+Vy);!!3Yl9q!&GPt_!<09$(*d>g_O@L;%eFuC}mHE$5y0(6nk zy}%^~^iOxL4WhC6#fzm-uAtZN17WCw;J=nTa*6ot?8U0V$~7_(CI8PdOw``p8TR9s zn0iGNk8rrH(k`;IFPLpAxU6Z1hf^}vZ+sv2_(t=Aw_&fr7FpA=+95exl8I z+cH7%2D8Ws#Cv&p*wql77)x=dN=Zs8bY05^`Hm0>y(Erz&}a)dMUZF#&BGyBVh$Hc z+U@^L;3Y_Q5fdvn{`kbtLpvO*QO~0LSbVLrT2}qyHb*ZUUH8|rWpk|h>rKrpY84c> z=4KoFm`Ssmn@y#qNzgBv==1vZZzgf;$~9*f@zJ4?TSg0ue|&g37)1*oXRA9%5+^^L ziHc+q5p|s*HLa?`4|zcq;mxdeuoouvC6wQu+W$)jC6XJ%$kBqr_?Dw0#8SnB)i?VaiI zzA?N|+t>H4_h~=(d1A-&iwonv`OZ%qI+N9u#T@5U6^z*>e)a1xCG6UM&e(V@$s&;R zke};@vyYP5M>E>sAZxw;$GeH!E!%~c+c}9J=f+xx9cj-|yyVcXB;sOZV1P8*t0B5G zRG->MbQ48a{Kt>t2vJ87sGIFyx3huufI0wu^dTVd3JYPLmxU*Xa|9a;%nRy0jx*vG2J`oii+wtZ^EB=t@akK4SlsU70TNz zDiRG0#GQ!ii7mdUK17A;NckWo&GV}>P1^LDlUy0NL}*_cbebmXTM74e9>kNYu} zu`{fvU*7q|ht2)owQKLUJl|_@v)#J2y0$i%ZN+qMYfCmhmBF(V851I{-*C(hk81=G zyJZ(p&fs_Ngnt63*3m&4hvx?!#^lr#;E*Dxd0=l`NR*Dn?IQ9xOeFc&jx!z!Xlr$idbX=pPr^*9878#VPa-wy+k4nSG)S~ zjp0TquOF=9y=rDzF~dXT6377*xVXx6MHXMwoIbtx%4NhQ3W)?bWJ`;t@7a?eT}ATY z^%iFhuQ|7;ULfztfn0a@H+5R1hYu4}O)DP)INVo+Q&ZD;Gg3jmug}R5k?ivR3aRN$ z~rE=}w5$ea}GV*fi9fscIuU@{jvNS(kQ?Dq$?s(HLB86@)-G|p* z)8^U4+_2|7UX*VW$q~vkXQmF&c}V|gX~Ee`g~77}1GcbGEL8;djAR>4%?P;JIXNie z!_GVXY-l$zx_i&b$Q0#RW@TsY+nn$v|2;YOAv)4P z-@x3=jPd^G!leD~BQH3qV1zR#?!Gx%b9kNNj+WLrGCw^@z>d7<1d0c2xE2@8_L`X; zR6e-=C4z+cGK+gJ?TDJ9dv&${#sAkR$Ou>AFpQ^(juFJRWhToa>~Fq8pKH zkqOVF6QreC_Yc!nD^Q9@-M&Q|S`7VcO3u<-4W(<>{`mEuCEC2<|aa|fn+?8TiYCYnzzdonUI z)~!vPpS~x{sZG81+umJ|NodzDiQ}wnF$rl(GM4SB@+T5LQo+1)dBtws_{H_p*-?eA zZ2?b8ESetDyL>6F($v!csPsttJU_=7yVFd|Y(_?g&{R2y_S-1uKN=XYEi~;lxm^K>}{4aC!p=xEA>O8m> zDZ0&iP|~kGqnL(zwI}yk$n4G0p+9`b)VF}nC||vagCJr zY&XvyXG2N$*n#QZOH86Yj&0JeweN1$S8`qy=Ha=fq)rH=+xOBWCh7W;D=TRr{m#xv z*D5wuS@1tO{ZXTmPt2gc)U*DQM2-~?Z(7@WRQg=U%HG@s?#*Mz_MZCqKGoWwOIy^? zaQ&j@u3Zr!0uA5x+WGiTmU@018cGs)cR0l2)25RB&J);lshmnvk@^$>6gFj!udH^6~w9PJXu%zqMQZJ}u?p_J$tbCvR`p zfAGwglq6xsDy1Qvb<437U!0GMjP0?Tc|S(Iw@Zy7G_;yHiR}To!A&FA#VL!DjWzDF zvVwwqNu)o*~_H-f4z>H^*pd*mGIg`u@QyBsR8o;=bMsMYh95IRYF@p-<|+h$@5* z^$lDmNlVYbN0+ii z4k!7qm^82;Z8!Yc-27;ZNt|~oDKET-du#cu(c2^eKKRzsbQ16Q;=H!V36vBNZJMVX zkcmjZ@A&0Ac9TJby|B0lXIoxY9NQ^m8E5LOG6O z5kLXT6Apz?`0~>A?kT{}ZIZthVn=0I3z8e)cw=MZO~nFIv}GSdosg7dVPLSmGh^Ef zjWf!pix(dNih?GiOK&oNPi#~*+}M}q6rTDT-ZZpx5V6oVkeIlv{{{45I8u}Nc#t(U zRaMVGD1ZHG1hfOcJ1Hp%5Ewyy`12=<_RdUG732v}iXc5ize3;eaC3Jz7IYm7OBPTN z6Tb8U|DF}5zrh8#f-wQUPw)o;$UODy7b<=LD+1@v8SCpG?UIKH$+E?@k)UG$MF9OF z)dn7*q^|B@ZB0&2?s4KBWi%j5XjH*d;wcE;{q+LrQb-8>L<@0=UQA(OcUhUIm)B13 zuY5c_fC&L{BQe2W>Outk+s25t)$|XGPW+abIHW%Z)DoETC+(a%;C_{rQkO2V3JC@K z`GFU_4BJJh(@&im2ciTX3P>#k_jq(jc#y>+2Z7nzCLmKpY+zLwa1Mi25MKntHh@Qc zeV`15{Pe1Tsaf`XKFq-2L;ibw{0u+;!u&kzx&`4bU2&Jza5w`9gH-{H3{p1e*nppw zlkv|ZdxqCSLR=gN3(Ej7zS7cC$h1)O5?pm1&JvZ}Dygk6PZDqL=zxQ0Csqc^NXWoZ z>!bEU9|&rCe4%Trti+Zmwg{qOU|!I{GcrdfB;eB_@I))+#Msy~a>S!v)bQ7?kxTpn zbci3z{m4UGgXIK;0)+&S-Kv_J8|c-6Z6byh00=_k(l?Vfi^q~bTUr*u{zH%Lwmh>< zLmcSj(%jq^eV(1gMp&AIJBr)nKVdE~R229)G>$+zfLDSlKj7K(=lI)kVPR)5hJXf? zm6QN_u{j*az2Q~U+D2nIF-mymCMe3o!>1-Eq4~gRTv|~P3u^k$n2JI9P9h&Qw4-Ha zo}I$Uu>hq%U{aV=L%R$_4w<98oE*qxEK@WK0r|7JjV9{BI}9Yu=s4UB3?Cx4Nv%@b+yEE-r##=T39t-};=qnPSnG7*<$^@a8T+{EKpj8)W7F z{m_|(@|#hoQEtQlwn6Q4n3`H$SC@-}V`;dW1~>_koFG&k4dVHK78eI9oKfh*i%!k) z$C3C(GGfma-D@wl#_Z4}(>EAlYpZ`zP0`Q`EV-@Iuoo%fILb{yuipZGgZ;|p{WTZ>%6$Q9y;b6=`8IAm?1cT z!~Gf>pp20F;CpaaFJtf8$P;*o=?N`3Vg zgAF6`eGoz*Yp-0s{PgKlauKo%!03U#4EFUQ8m(?_h@=g|Vk{*k1wob@scxOa6M?cB-f+dAD@q;scd{nU}snR>B2$LWYOw>O3C zDopE-9KOiBJA|=NRCM|F8D(WvwwRi`_Wo17#mX|P7I*)o>fDFe>G&yn!*HDt&M!MW zH;4R)XrH}%2Nxf}-J}w5H{Jxegk^yaW}{(%Y~QP@UcP`+aD1q!i#jGj1O__1jzyy+ z7&lOSI4J-JN}VLIH24Vg-(ct?#wjE4L9GsbQN*K1!`NXk69zONa?awNhYK*e87X}W zKhlPlme{DMvbwsd>FEQWg0W;^A|X#gVGLTl$bBmuV1L^)A|iKe!4o1PBKVc~6P<&A zo8ayTrF;VrVH)mm0V`e1jIP%}0oT~jz051we2UY46 zVEJc&8Gp$G2*Zg7<{r$9wYBvp=&`<$A|?VC1U8FD_Oqb@K;SimGI2-@H9%ir7$YL& z!Bs8*KLk>ay&S6m`atyW@8S1LKv)?8;E1P;NBaEf)1wXyL?zRv78Z~o6TT3jzkff; z#U)8%V`qoSIgrC(wPL{Bb@+6KhB7c`LhB7us;5VH9n|3vl#IZ>aZL2qw9O*3nY*~G zGP}ij{e8A`R_d>TptmX3Mt-GUG~9t2$}(;={%f;cRVuQ_j#=}Qd6Dm>5Al21Uq3l| z%NR0{w{HVujvZt1qUNM9TGxB9>~is{xqpA{ zmq&YdJ2v11L4WMPfU+LpUslo7#%yRSA}su8VF7R`AW8lY0!S|OyZTzmzU6pohQo7{)U z$xD)o9*{@kh+$=g?YSr<^>TDoSELmEQ*8dq3G56T2VxA$!f&Xkef?VINhqY8=qU&9 z&0R+HYORmZ#C~61ejJuJcKM*wf3ko+Z$)g0O=Y82JZS zVV|p#k`+k4>gp^}N=GU7?jn*C+ebX#U-krj(r7qEyoV4EybhiY&Omgs02cODZKajK z$c~g0i~wQK=)=k{vC8G~MWlHBHZVIolZ14y%Kmo-Dw?`tPu@CytJ=0Sy@ie=y$m|{ z=)$DQ#wVeEev5DW>yZg;&Mr!jym=eAxUfj=zLk7D-zy;G#Efpq- zU$0sx0qKlBNS0N=EH{*HT42NZ=j-hw4>6mKLwo8Z9m2Xf0l}A=vYL4 zT^dp4S$)pgH+`0e2VPeL6B9R-m9YkGVHyDzY`=@KwRI{)=IB=&g@C?_Fi+2i@wE`i z1a{=Y!UB(m-ER@3Rj~L%Zyh5810EQW2bL0u3!I`j_d_nQ4Pmz{ zD{oqcpvm@qbaYpdYXL&n#7hqKzP=G!f|zQam|b#Wyp@BDA=>bLJ5+S%vlX1~4_ z$}hmi=vf!y5ID>3T=f3vO}!W5V5znRNSBf$*F(HGb^Lf6(pj{Cii>Yuy$ybCqNlL4 zqr+Ltss`?p?mLd#Vce5axCzhkx=#3TXYuEZv~l=9-q$j< zNy`f_dY_uQ6u|MC^%G4=MQNJbJUfk4oZq4Yib!}6^YWeQX==G?bc|S1((9Uw z*{Rws>Om7LGuF8G?^k+vmL!Ro|Ll#M=*n#0GNr9-i=~nGdo|r#AS`mz+tGs@%@O&q z6+r5rmzx_d?hXoTqgoc13o-``;O(CSu;}Jdp_s^#_8@=m=Qo0u-Aoe-hy!`iKGr`p zAd=dRV+Q*IJnfSqtfk#2n$K1k{h$Y##X5s({RD4t)q~;1^=_z!& zR9}*MtLmV^?roQ;j~wATeR^VQ$_+UnP*vZU3P@P6fRPd-AaF162QtOu!r`|bP*~xlQM!7aoKUn%9T=8lXP@L)( zx8FRUe5BucB0l-~?<2CZ8w=yI-p{%6@}|42>iM6njdD?QQ+YqTe}a=kN>;?I;h<8o zP*=fXXTcG%u8xlM3s--sEoWp7E#*4#-a}p>_F&suwiN>C!qN4FlC9pXj05U<2>JRjT`icU(p11M}hTnP4j0e%0?vlD;Nz&%e$ z^W%v0iIFUlAlE!_puVc=rm?YZhOsZ|We~cmx}qi?HfLmxKYDsf-1qgluJbz2<2cUayniB!FDoPSoY&FW%C_mM zFYCs}&>Gcw$IcxsNg_VL+!wgABHs3~Z+ULGe`up(JZHEwyWDxeqjNEG#Kb#F%E0x1 zT7YSB6sma?CX=CzR0nkzxPITb+O1u@Iv+0xdwIhv^i z|L-Wi192=e2Sp+5c=ygaS(Qqdm<7qeD-?w&6{I}ZuE854H~kzWPhh+;4~F9;loLc{ zAb41D2ScVoBfe*a0vQHA^f%QVrL_>9h2o;+P>X}_FIo`)j*Z2NI>;fBM7r7D=oJhx z0Z`4xsw(5a??rpaA2FFJ=efE11-+#Y0#oIw8p3w2=DXlX`{89h=dWcyoQFP-hxu4i zw5FZaTAr^Gp6aWRVrOGxP+EUV6!^V>$qsYAG_PD?b*4SP8>(Cs>H8@tVET<-_tWz> zv^@ySsNd}n>&YG?eBJ9OgfHmV_>D~NA~`!uPtW4KmnexKK>~s859Sw+oQ;j{Y}r!ZNoG4 z`5NR{7IDe}BSOq=GdN>t?vv70FBc^LiKfZ z!2tmR!ou;2+%%%%;tWTQOyy)$UQ%MiQh4EvMgABU@9K>6d#Lg!+h&J>L1sG$N)RM3 z{ryLf1ffIJyXmH@YhFmm{yRT`%rw_5FkF!kL>dTBf!+eA_plLIu%X970#-pw0EGMv z-@d(l^QQUNuLVrV0bT+a2Ov?md0kx{GXTkW1&r;3-o%^59;^LA?sxBk z_Qyhkr~ykOKlTLx4_a3W3hqMJf}(+K@gX_93t&ZpQ}Zf0*`MKqWYcW;>3w79_h-dd z=H<0FH$O!w2sFqiEBG8SigWxBoX}|)&_gkW5ndS3avu=eXDuy9Mqpns*|9<)T5z3^ zZiC7Drn=ipTk%Sx(BR@Z9Uau~4C}=S`UUUby~84ghK9O0IsHe1GkeApAb=-Ne)spE zPeE)UVkZe2W+6ydkz^y8AtNQlM*O{ZRRq{R9@}Ry|9G4z%Wh-Rk%7S#-1fAzrdC$L z;u!H_0UaZj9rO2a3O4~UP)Z}a2Q`f(n}Fd7+x!qlUWo;wp`^rgrz{kqe0(3n3#^eO zHZ>8Z4&a+3j|Yw}_jeqT1&A4NNHjdpEiGY?dAUFH(n`i*ze#J-jknMG9`7Jpp@E7A zr2)u=|5s%RohSTf&}c5AZpkQuJRgv#xXVOFm-V0DqrZr?PpdG4PBqu>FL-9592A7N zhI*VG+9gFOfuSwL!^YGvG^{T8qg4C}Y#9PwJODCkTnnXd?@t#5u!x9gX=pg1X&3TA z3No?|h&x9|o`!|(-m~Y*)vHJkZ)#})_k{=^N;I?xoD~v+D7L%EO&vp+&@4{M3KbmM z6#*Imw9(ekSVUP!N^tRI61CsmMo7z{NdemEM+v|Op8@kT7;^dmb3lO6*VVr9vuj@IOd9q?@CITg`XI<50;y*F8~b^!bK_pbm#fZ-YRRMhtUD+1-g3YJpnqJP?)mdXtOTVPVk+t~-=C1f&W`(kyp{h6 zRB%DT8jdlRb;W0;m0*(S?v5VjyZFJSrRS-spWwk6tYc&jE@O%*0W2+UX9Z6RvH(g8 zYwImhiLL+k%yGi!=Xb_Y#>YdTL&GZa(gP^UtE)SKo#|-|mZDx>2Y_|g$jD6G9h#dF z)!1qU#j&Zs$DDa*;<1dc%gWq!bqS0)SL&~!A?yv!^jr6jROr1fF4k328ixfNz+RN* zzNsjvv6D!)i2)z!7u>T+WbeWuM(PP?ua(IzVPDE~=R9xU9vyAwp3yt{BrI&-*0Jz^ z$o6mM1I5NO>ZA!@LrI8gMi1y#_Gt*8Y8I{bpJxn zzuW6|HJY~&;YdXu?c%^(8l(Wq%0U@*u+)p*4dC}0QX7PH!XANX6La{pXTZF-pPB;o zB@E{RqzB1cMg~8Wk|=tRDZms2h>U=s-~ruw!gxF!Q;a~PIdte0z7UQ#<`u2@`@=_$ zqyy~1`*`>69f*zofq}z0ANwdI?)VuL;)~DO4?P0Q0Yaib_BtswKK8#V1t0JsIUUXn zgntlJzPc?^02eYD3R0rqsUJVW7tR5#gQ#N&9rjd%F31rOgkZ8m)_WS+PXMt=h@yt3 zrkRb+KY$0_Io&-y>uYQHB6KRw&7;km=j%2d9v6ySAMfhU&KJSK6s+Is>NXLlK!4@r ztx_R?R`W}yZLOiFZCHBep-Y&+BKmdva z-QQFXf$M;9m1u?>+!*{1OZEA|)*v^a`GuS|9UU?M98vY9!)v3zlLHCDUjf;Dls6Rp z=ustTMSbRG^KZ?~1gmRwXnqTxU7d%ynJKWm-#>`dA4f$&{<#-`!0v2j1_pNwrrFr= zASVxm36Pz=z3OFo=m5KcG~%it`*eyM8wg9Yxv_znFk3}+;g13X(fy2;zR*8I|IuIu zB_DS-tKx$E&(H^L{?LOuD9xi`EK_F3&R0oEn4dHBXW$SGjXETN5fQ!*Uz+X%j*R2` z)yK-<3=-E39grJH@j>=sALDwX<Cz=?!Wgw6crvU(AqN!?(4qIQUhy~fI5|7x--Z7m%akO2o~HAkTd6?m=v0c-@vd4nTQx4MDgbXPrXaAQ zGNY2_e=*1x{``6VwL4>rF>NkKdlyem&8}H7y!s(9NOp?0bGkr^U*L9TtszD-ZaNWo z&2RhnkdjIEQ&I2QC8Mp)kg0yJ55PN+!t)aciIhJeR-wNg%1lRmOtk$0v_>vPL}c{f ztNzQEFEQOT6DTy$T19R4{G=pe*4=@$n-pbb81mHOx@To4%>(5R%9Wh@DeHQ^Rzh8~ zH5}FtdsG{0FJHfcuHh5-+?eIca$Hxp7aI=cWJ$v7*AI}8wQ~|hpF}N=Oclyj6ix_e zctmet%>}lMi2f-w=) zh~1;A$E&hy3v59cFNlmNfQf{X7zp5h!@TH*)0F9uGzbf8BJ#CgAYzf=p&cI|=VoP{ zMJU0E#;qfL@c$ZePz0=VKs^&rMEF;xC|>mi>Tz4oK7BNA*5ujCq>)Fd=g^1VXWI z^*fUy_mq(fP4-R<4`Y_@#R;DNP~a7%5O3D~_~DGf3_yKKaKqq{6+wA||GUSJZHkZ# zq5vUyWNK=^Qt(cqB&L1DM%LE4*RRtaKCJld2#It`ZZ4f9QNuO74f#+eom{ur(qs3- z>pOBfNNQ(}lxVHXb3uMVMk%|OcpA+2&pjnYyBCI#1_DyV=11O- zP2?8^#Tz0VBcn3P;|U(3jd!5X(O%XEkum0z0fzK(IZLGfqBGY<4~XNPJ0Gylc27Z` zAp52k-WAOPaL3%ryk|=P;sgtug}H^@XNk87$pfPUzFqw}3)+hI6YdXOjvr$!tE*6H z7m8Bg%Xi(To|>J%H|>EhyKncn=3fPXveSy4$-A&FPl)>s!{%Pn|)w=!F0QN^tz{ylevW4G)3 z%+F=p+~@bXTQUnuUDv#RK%cleu!dZcZec0N$*o?4_&o|pNfR%|ZdGqR@2?{dLJ#M^ zalEN}OLu0hBv{9x$-dZoHDp^r{6yiqV)KRdi)=-*Y$o*5GHO0A{2;Hg4RYC=#6n*u zz>2W>8JaV2a)=&fYu@UYz!;$!JNo(a&CSh#*gf~8urN8eRgu6zRnDW8aSx%2NdG0W zWGEfo;fRXN)zIt$X*HvuCM*yjbr{pO9xN=%d$satalo&WxL)+-_lGA^Z*tW~g#G{!stNkMGnktJ{jXLpy45DLTgfFbyKy~{x0Fk&NFxU zHXh91zHL6G_obn-_GF&c?Mg2D#j}ghHLXO?hUSz(eyeu;`-1#{RD(hVm2~n_+*O@Id;h_ZMw`h;Ed~RLWhWka*I-3*wXy3-huUihO!WKYJf5$Jq%)Q7W9)K^uo5bq zR+6$f^7r=xL(6mAeKp16j4t5EMDBE|UATBSaxLOv>>k#IiPe^7BLbtyStH_O^fYxU${PfmW(9gXx1WsMJvSd$ zs6Rzy+FwfR&z;N*?bNwDE0IZ&ABK2*FRRNm(|oKTr#8FY;0($|n5gCa=+a%z*o*Y5y(qGNp$nhK^H8voSFbrVV@$xGCuJ^_DLs=izrvTz|@n%EF&SdGgH{k4}0v zc5Sb1KC!{!OwALrJF}=cFi!r)QOxCQcSm1GVzJWG zD-rB~WXJxuu3i_m0LvmU>cx>Iu_LD3{|}5Kv^eYQXE=>tN1Tl=l|Bfi76>TZ14!%u z7W#bmE&aTNq<$CVAzNEuhM`&YV8$)3E{ld7STsPoU}3=pFPY524Ira}8cIs!y9CQu z($h}z%9Ga7yzrMgZ8NYj{9|~jb!qOmy>HmPN`9pg$yqP^k=MfK*v>JKFo>SGs4k=K z>C{8-ZX8pWRCM!(TlO3*F-G>s*CHayOg zol{Tqf`gqqJvH$dD<8gUY=zz?IOP6*R$sn87T*-}6uBG5#xvA!VTz*NcE>!$%67rF zjOlA|5xr6}zQ@??Sd6ocs+zJ({=g*}rC8edB(tRWMM)ar^QLdtxi+t#Zc_Rt6l0~} zccs6V0|+HQm-h?Q=-&(t3?S@VLcg(-6JcZ*E`A7^^xsq{Huv-}QBm0eLBWF4aHzI- zbbPfrrGGQ<@#E9N!T_J`pf-SVg&_AST^Yy;1riX7f#C$@?#u0eD`;cGC%P&kwe?e= ziOpB6Q*5T3Myyl3lC#qMwmGDoEWVxEU9)T=wwLxQNvLf&ET#KbA34-brGAKpr!1uR zik=PM_ig56&cu_!lw5pV@fYO-DfiBi$@A*Lj98J^Fyae6T+v++d4D&8XFGF-FJsjCdBEB27k->=SF1^YBNHJ105;i zUPs3&%4&dtsHj1CM_<*uReK!mZ=gZJ5#d;4OZ`_v-nwO!+Ou=Ud!olpB0^`KXvRow zX~H-2VZxmouRleeIHk}e$C+5C?k4@@QetvbdPG_C>u0oztlFRCoFCtL<|*uK>gC-% zEiUyjY_CuHG&GaX*Cm%l+s^HEs1t2_p24eET!f+nE>gFOT&ZYNfzf6|-T_N4AUYr- z0DN5X7o>bE%1%xlurSpl4kezQ*6eXMJ{fs=x@EC?$1j}D$EP%221&&EMatcfc^(-e zw4u0ib3MEL-Bh|YF4@O(dwnV0gr7E*HGOI@seVyieqcdhU43Z&QI9`kiusm<`kotm zNLhCn>>xW#20x)wAaq}G+0+}6Sm{$i44+Us?6 zX*r)FpLx!V36>v7J?(96qt>vf-gc|R$c*+aICdRofSYGI)sFz1T;G~At_ z`?{})jz9nW3!|3^t7uhryL%UKywn+RJL+5c#H1?ZMnH=T*Z*)@J9{bQQu zi)1bJD>NS5?aQkaMaSZv#kLH7d{fiP^D!-TQEFQFN&fL5<)91O#Ae6MqPso}4GnKZ z%+J=hh&zllXy~M%8|}+_M*00TlIu1%P6b5Wv9WBpRK-)vz2IhlN{DCpM!&Zn&-g7svSUhY!P(opo@{in z*<2dOdowdH|J}tdEe3b7T2G2aYKkMfqEk~UHOx2)At=X~V~L{&q#{e-K8=Zi@|Tr` zzcN&8rlP=WvjCp9fTF;@_QH2+w2=*$)+G{LWm2cu+5I4U)YD^jzhiD*1sMetvH)0_ zb;V(b-v^=s{;B|KGxtNp$cKjgl$7?Kp2@K>bNGs&^8&gx_+BBxWh5kgZD_DTC52je z8tS2Z`x9;t%#el{8y|-f3JSnw_geg&HxN&N0n7&aV6y=JA*qydm+u!orCTH-B$TsF zg;Pi!0P6r#j21R1Cr0GiTHO!t$x{FO_XU=JoV&_jbAx9|Je+Y9n+4um28Lv4?0NihwF7N9k7Pk4$D`k?rRL>|fhhIkSc z1t?At5i#83nqAZ{hQzIjF4GnZI2>|Q9OV7ZZ8U(zA`c~VM;JsEg^P$n6@V*Fc6MaWs>b^5jnxau`e_8;%b!a(AS$E$5+0_*Y52-0@OUv?l;5$a< z6~OI<7ZA;;urNqC84KNa5GgZ;p^5As8ECw}xcH#^1o2Wb z^PB@+ij)WkK&+wK%l`CSVMW~9)>iTTJ0z{P zVAFT+WEB#kt?^{>#iARVm{3WhU>9LTARuNnHZ=TnchLfsKHd}J&yg4RyA&x&cb~j) zskN&Ms;tg`_izU;uGy$?7JS{D`BimMPpfwql&~a+0+lgc11gSyG;UTU6=`r3x^pt!7ag5;s zs>O=o1Pg6^4*&3h2kb5qe7u;LV)5OP1ucZepJ#zGbHl6l&3kgT^DynMwXgP+n2Z2zF+MPUg9S1I>^?G*46TxpQTf#a_;anjK2QZ%3SuBU zm{OcJ{7Ovh2NX2)yA`A+-fIg4gG5MV05Rb=#l@J`2ca^+Dsc75$+;#qv{2+?Gy04O= z8pR~_i04rCyzAKJ5%M-*Y+}Tz!Z%WZqeO7fs+AJjRR)qlmbbv$%P%yYNu(f<+VL063(iteB~RkOb}rtw{J);%I33 zK=H@dr$>ncp-BO%0Ox?0MO}PC!a{U(9>XzJ4Se6!B(<(zyQ$awv*dZugMEXJa!6HK zXUmskPu8n%#OgRvNq3lcbbEArNagRcR54Lm%uNMpxl#X1|YU<${U|)8WIbz{j6Fp@x13Dt@C1h=fosA|6vo zBbCP~#?Ei&1T%+?_|zR15W6A|cOW2xfrs56G#mKXC3=|;DA!`o$ipKHbTJkZF93X= zp68Z6wyX1mH%bNGZ%K>3 zi{c*_?mbYkv@9Ct9WC+Gf231egH zb}9Frr4hT$k=wq?SXy$%BLYK=3kMtg-@VQTc+C0lFT?^V#IXMUeh{aZ#a>?ti;007 zJ;ECu6O#^3$dxM>w$?!aW6KRiBH|3L@S#e?{W&3kj0LVVka+<}CI}R&ja3D3Jwlrg z0V$+upplXEV6Fy=PUJmk^9EK=$k@^{3B=Rw#@9$%LazHqWY=1B*euPgRgL57d)lH@ zBK2&UHTV6StT`oiI1;-CB{09G9P;g|Oq05ez)%f=9O0)q&=U-95!NLM=lpS_*ke)#&jOc~L^ZWOU zxOeQ= z1?O=F%^ueqM+>={^hF(z`JVWFcCXKd+-&9c6(x10o6tj*c7kN$ z)?drNtGibLT{s10>ogqQD4yrYjlU)0(fj9f&9Z0+fUgoOjK3(zbIX6h5R*8f@Kmiu zqm8NePu!*?14qF6)wzpK_y1B&zuDJJ|48tyW}wsi?9nj=#TzeWkZGI@bS^X?Tx%V#}M}jdmfe+v$_D} zxM0=;_6^KAp5Mzlgo_JD01JLqS9c4UF5#w_n?r{jQtEKc2%MJx1}EWcAz^+G!^D{x z$XKz0g;*~xE{i$Z3`SvaKDf(}t>9llPQxCq073@&cQ+hUk=76&JolXRWLyv%b-=Sf zhtf{%7T^@P>$m+e{VKLdsj~kClO*G#eeNILyva&>ZCZDoyRam8zw5T%+`HrGSo+B3 zPW+{N^Y_4q<&I6dG|Kfy)=MP$uKAngJCg)dxmT|l=6VEf@7cCe?W)uaS9n`f!TYeA zT-0T`=yk#GQt`iRM|;P3PjEO*blL%efZe2fmKDzgPy54=)^z|+%pAMz=I!7!Y^{@d zw%@R{byK^!_sap#gGVALgEYuEE4fnAQnBia$Elao`jjfpZy#SB+c$NXG+|^p4*GS$ zrZR8283z`^c>06$%YEK`U)+U{A2&u777>E-%Zo_+H8mwA%gK|j=un1old$A|*KXWU ztT%!90dhd2(5k1Wp_qX509hHbd~}~?9zNXum>PWV1)4LsHxTT?2I=KcY2k|?!op>V z9SVP=w8TUw2qBO_>^OUY;|08M5YqksQOlU~6-o}D!q+lR1&vp$gt{EK5G>>;rq zCcJ=$6C++0$cNYt)5c)vgh7GuQpFVl5*eOO$iJc?^=N7g#-ssF8J%6>3LDvvlS|$e z++t6N4OPt3o%$)*=)O8?vVDdmGjxS( zv}NP_e1};^DF54!C-f+#UuSK3dkbIT%mmsXO@9CRv%{>8Bh|6U4@yVfeTKo)^<65# zt+p%``1G5BdB~^NSA8kN70NE%2gNI1*m<)H5l4eAHC@ZBpvv{lD9;r)jXg|8R^Q!V zLCQz?37alY6C(Tyh%wWWYHDQ{I}0H#BHDwa*@h|!*qHYpZwQ!fBf>%(wG9~>92j6F zf@WG%Wg3*7OsMC1;rg=e0u2m6gulXPWp3Rn@)m=|x9w+V&B?!i5eS|{MsC?POUa+pVETub7NEEY1Qq9J4qTj zkJ`spVD`e6-%PS6pNdG z{oL}jeb=+6k+F<18*QB>LX(-)-yTsO`%GE6x8fj%GzN9*o9*~c2cR72ieja}vLw!hS@|Luf?Bg3J1iw$B?BUNwdbY8D>rOTn_oSbd>i!hz0k z^h0F?*)U{9e9CnzH&C4r;F(L8jL!YsjlD$&$uhD}RG|1iAS2K;M1SN6B#c+YKKC8t z<7=p{enA~29e$=_dUSH^1a-iW{ZV`aMFGXEA=5=0nzfVQwv=?VJ)^zo4=1AwDlERb zvIY&g-@k+i>exJH3UEKf!4}F9wV`idRP4I_x;bf6TP0W5%l0sLVq<(z(Y{kmlU*|+ zG7fBn?d3stI5hTZ>q{kh`P}b~s)~c#+cF08vKb|ZlLb?GE^FL))!Fpx2=i9!-KgwO zey2|{S*<*^wcnXi_&x@zgcqho4i=o5oa(0QaZ)sd8%h5bG(jJ=G7WnQoCxxie`l96 zKo(OG{=h&McN3C3pdCoby|ZlDaK1KH9ndn07eu&TURwZ@GUpECiy(x?1&&(^)sU72 zCPh5BnkWGKR=6b`=Tux3oj(ZK9jI|1z~Quq4$aQY@aPmQV9GtB?!J9KHh%@iuqqJq z%Sr}jwBNokrbUbkRC6nzkyZ8>Rc5T-*r>T#Lu_+zYq80g>7a(vx5V$Hr+i=Cd-bCF zNUnqR5V^MY&A#H0tWQ}-B-vQbYX7xb=@R9d3u-=m!sLY8ikr|xr|rOun>6?8U34un?7RGs7>8yX&lG7kvSWid+rVdV2II3~Y-;feUiuXv)} z7nw5Seqr5&9YkA>hzO87eZibjtZ^jIjXB(T0N6LBrBLgjq9PP4cxrfoA%KJk!v?6N z!CoRIz%>RDVA<&-NaWsR_ZnYAdWvFj5cs>4Q;TumWelmB`07J~BoK)l4d5|wN(GMa z)mSsW6*(sZbtUvZUVNJ*J@(Ij_g*Y6bXZi~h7-{T4x23>N{bXL(&9rK0)_VsI?o7j zUJTxso7rbBnlu7;{rA zURJjIXC?JVUYoqN(zm!rd2ES_&+XQS^b}{wKV{YRoh53=!dE0_R&iK-!>V^`@aooA znuTlCk|q=#$mcui=f%BQU0)?&HZ_rWy(UAHQ`B7aqopF5uP*=`F(kZ`(W+B}kpoiXA!@ydepY336V{+1mW2@$^g`xT3?4ywnf<3$wL7fc~Mv z$2->0WKQ2g=ncvSzJ%`zO_}HVk`-J8kk0&smQt;!;`*yFGYep=kkPUFqE?=9Ekfc4 zLHmp=it=qqj???`$k64ed+Qbtno?B`5EQm(;RA~4;*yJ}^!#}TK07ei2A~q~1O!S7 ztP8d-m@ZWWbR0Y}ww#tGG7ZoGpx>Z3oS4|=w52?HG^BhWNq4gmKrB`l2`wT=L`;l3 zt_T30h~0^fw;~+>#;YG@KF!!RJ@0Af^Dy{f)4)Bl0U5)Bwy%Ov;bPs8V{h>OLwefL zTh|9X2E&2?C!J&>NB;}x1ApA5yNQW$Wu)KCwgCEtr+~5=EJXvXO7Mcm`O+kV^dlc1 z8T5&xCAYSApL;1lm-4NOBjE~Y-^9d$EZl3kxo`<444uWi7bN+FS$n^JT?1_v*OUPX~nHE|8|5%7W{rzZh{l`OKQt(C6?>~NEoTGsz zlmbv+R908x&?3a3F98rNy38OkdMW0#m-X7!s}EF6&F|cSNDqB~ue(o$B*B*fx!c1_x06m1Bk!o-*QDLuu(fO4MxVTgS-nTv%H0@O%J?6BsBC)j4$Gq`Lu)^F*$n zCk595;qNhDd_G%|TPi9P(v{!78N)gNxG{u0=-EL0Ax*$USKlz;#owoF&rk+@gUbsV z%`k0@^It=FPg4_h>b`w=FhT=551!|Dn9_8elV9c$I(v_~)%%Dv`Pb#zMSo#tF%c0= z+)}-NM22V=0db<|#?I6DXAyl6;!~oPU7%7i2YO|FT^{z3M2v~FxNl;; zIDwH=SHq=QU93q@p#RiKr~u#v_34v6P<2#3sQmr>e&7J&s)lg9}7$ccE|+c+~>t^#Fk#!Vrb&U{@R`y?dvZI z*?6c~sxEdCcf#FuY37Hz;$uFKM)9l=rV<@x_0RmP_mrbejZ#2 z-9wxOX(KQC{Miw`1Q_E3kOhn9uj7P%N=%DFrGWtec&=L)7fYry z0F8-=-xeqNqj}LKYFbD5?fxuOem&YjO)m>VuFQIpWs5Bl%4MVt; zfqwz6#ef_rLP$_=0G2f~s=a z7*6saay2!RU%$SK!OS=|!)Rx??Z12Hj?fC|;(?v%m#o1l2uQU4S9m2LCqU{3KdVI> zXSC1)4ML&?Ok;D7i_oNp|1VdjBqEYdflOkv1U`W15<;Q{-zwDJKqC;Tptwr=@ByiY zZ%ab-Wo%ruJY{a`d8=aZUtm0xo&{ zECjITpFi8Xx=;(CdEKg8E`AO<8uCdbD`@JAfj)a}?IaTw{T>2=W@bir@Zhz}m+u`` zQ&)yFPjRu=HXk>#mRAxA4*Ndl;gunmd z_-IvCoF`7~Va0$ET}@4bLtyy;G{m#664JMkPJ>%OnK)#MO9KDG8qyyXmCaZ^G1j^r zrSTsyuLSv=fWRyc238CN#ZW;4mK!Pe!iU5Q1P)l3 z5>?CuwCJ!s*C!6h`eMPYKF5Y4smp2mXQ&(+X)H_xBdF{)^SC>0`Ct!nQ*jI9y{-cpN0Gj z_x)FFW8m1he9u1s4C%Ln@UCUy7IGPji6z(yyl&bMr=zPIhfy_Y0&c5wi3moO>zEgC zNN8*j?KfI!TNlL9-29&z2*Jn;f(}SzFn4m>(ZvOgCr7#EJ9~O`w6q?bRlj^0dWr9Y zv!JMr<}cmLY=K)P9yg{Oqc?sHZdgg|m*jWLeRTxaEhHpxoa)qB@I!PFa zf+vYc^Y$&22AGM`KkJH^hBtySE`+9-aV97{`tlt(VA>`j@iHKw3Y}AUKIkjo-v6$D z8Hg2j4^%QCP51Y4o;}-wWAm?zX98s%5Z8zhOiW@ITTvteTY zSdv<_Dua`Tr!zbIFyb_hI5q(wh}}CA-oLMbk5sMhK7Mv=I)p4#Cn1zC(s1Ro30l_Q zf#OL{#Z2DJ?#e*mU;KT-)4 zb7-$Ytb^udKOq}c-id;ywW$f|@!-(V=ZuWf{dZkmc_4p6{tX9kwDcn=%8-hRiOu1T zpa3VPxb@2tdjg3li;F9^JJuO@IC@3rk_-D>QeMBtZ7`3-94Q}?O^}&zp@teLEe&`~ zFlA?!*#V(6qsh)}|NTsz`3}ixX{TEEDepy0!XK;wq8T~KwKeGJ7M>|k6Vc^IqmNJ5 z^yJCZo()R`bI(83`B`RF+3s?luMmF?-GcGS(ad<{$P4T%M1eG&!s{|JySViw&bzz2 zySlERs3r7L0d9b-2W`Tz;Ff+CFYpYO47eL7d(S37D)Umr?l+bP4-nrJPdmX8eTe+e zOI5jnW=woOU5`)=oLD~7Lk@#$ARs~|v;U}s$W6~ORqnxlAQw3?!h%^Dj59O*0>W<` zMf6`Dq&>*!n1XTCB(`dENHDHBPVwUEeg|Q zF>!H-B4C}IiOD3u6Z=9f9z<7)r`>7KV8s$4cN8{T4w_Qs#s!J3f(BVIJGrpbgb&3t zYs8h*2eAYfiU{Z};2;Sq5*gEFG1?g#Y(c!^+u4o7{Q)##%-e4iE-CeSt(_UN3u{h~ zMW{nKEDEwNMpw zcYAP|2(uSMFWHSDk!@`*-PidyFU!dI#Kd$#b&3v8UY&w74#Ua_2~ams6JP-Ems{l8 zUVT_gq>1ovgjh%Lrsspq9G7Wb(4IbV96}vk>SDC#4=ZWD2)uOZPR^~g#5AVGVU_KR zRx+&A0jLW>5Zd)cSxQ~M+oO;@si>;Eg)EDkj_)RHDbWG{N@*9ad??{Eq#3Zb zz-8Jk4x-T-lsNjIgN?bM`{)2(23Yb1j5AQ_!hXQ>)&chiS23Nm2P>%dQse?kiZQL9 z_$q=wCk;&&BvCm2Wm5YfNK*M%T+&x~kH8qetn&TftQ259m!>F#pf&w74;JWZR)rDYW7k%U( zOHoqU(vpo-pNs2p)BStlsKqm36q;$#l2fwv+L1^9by(!nbKdhmOlw+jISZaXO)#?R z^WN?O3I4C&+-;zYFo6a3$y7@av@ugr%|gM3Gyw@8?o_~j5d1?%rs{iR7ZI{v4CVG5 z{R!(4_Pu^r(D8^B2S|qAE!@VTm(Jb-&Gb*iG-HX-g-KLU&Z(TF5)MFAc>TU`{nbdo zK+H3btq*TfzHzY3@CuJCE2WQeHJsJW>~5@^6kcCVI8<==@N^%>C*Hoj+qC`{%tJn+ zIgRv^>c)d=n?q5m@s9&N)V0;w_#K9K>M(UNiOb4fly`}+b01p>4pOdFUY_hqhNVhc zn&)`yDd?Htv>LvQs-7B|)R7}_$JIU-o&r;E>~;bIT3H!%<5z!MteDf|`}Ymt)O;|A z6?1>E_6XL2|9qog=^sY~TU3syZo+>95aaOBa6YuOwQ-{OuYxN>Cgkksus$HZE$=dA zJ3Dht^LlaY9d##1ihY-~Ur4pKx~|^xT$C_g{-ILR8X8$t=BCz=Q19&Gbc3FL$ZTY# z`^!@e-6b2_urs<#8v(3cYI{jSnBJ#+D8gvnqoDv6nH59cWMv6*aaChx1WX!m5u%bs z8j1jeLKtTZ_g>gwbTrcAbfCh&e@nk>k-;?qyLg?PJI|4NV^aJO)KL1rzTN?juwssi z8XzDHY7!h-`Z=H?dZ>5#r^TDPnYu@^hBIH*ErqR=Js7q}>zO7m2L3ov&?u{<^gi`n zzDZj>4M|UNWK_dJ+M_*NS%RnW`}jOtT~*zqtjB}O(>gxe)g=YL5#K9)l527^Wm)v# z{pW&h9i6%`2m>)#t~P+n1sg{NxdDtEFsCUfs1}+cOokz->g|?J>?s#&s?EiuU+ zSxHBGJL;(I_308^XXcf5V>qOt zaV4{?sp(F($+wz}q#tr~oKEJ?-_-NE=d{05RBVXjG|q(^jni4@>LypRkY^MX)k5+L z?PaC>qLN$daXQNr0+-|4wkxyA=}a3A{v91%f%*A_{T#Dh$hNX5w2EI=8aPk6?IBZ5 zU$xKL^z@K(tq~Cr@V)#!im&_|t>bju{WS@5-Qqo2Bd!HZ7B)8Wq+?tSr?@#qr`@Ov zxg^C*&o=;|$0`U=g`=MC3l&!`_1Q06Yp3%k-%LUxqrz(UgRWvvhZDz7h~zH}r|w=O z2`gk89zT7p!RMxB;TWn%U{QVXsp;v(c+N>GG0+k~lm9hO_mG|=H%oO`#t2# z(7xx5mz-a1=9ue9*m=|kIX2S9?1eQbLC;cd+lrMT1-t zdM>-8NG=>;{s;UOtOlWGxOcBq1dS&|-v6MWQnfs{c~cDRgRMdT)2^yNh^f%N{f1FZ z--rBnoDvI(9e-P3R(-wo;t#n{>%Or*{d{=g7dX}O@Vx&0`#OUab>IG%*fBg$vF`pB_d{Mjik9wU_GbLtVH8U-JlE}NqwQAvhjok_T^DsrSi>rpdl%RKm<`$A`3?d}j+JpvF%V#$mtOMm_A;?8-<9;dFd@vHmr!Tz)$CM8x;d|R{PsTMGjQ<+dw(+5lmR*F9~DIszCUj{In7Z$ zbNl-zF%eX5Q-P-d_yzxG1@V3O+5P&C7>@D;(9O$a zd)Vx#Vn{j6rzIu5F!y#)^bERnGXEtOY~TVg$;$zQJ};5%^t!38PQxI+ejw&tfkSd` zvOE>X<7bKO#+iFr7iQ|W)kf(TjM_0Oih^6&WN1j*&`7eSH6rIsO;yeE(vnA?eP$K4 zDsMVT?({XU_pUUwIrYvC+gaA@ONl2qwiy_|)zt(tAq6E(K%@+_bpCMqj5Mi+*SgT{ zjdoS;cm{^vKf^CYyuFAyM))&f=UL*)u-x#c2JE`dteChO@%C_wt)-ZsH2R~edgEk| znIt7s(9B#e-@HkGoEkcy#lV4ep^~41;w19uf`+Ta&Rx5ZpYa?&z99HCLk%|M}Avbs72|aL*13@nTs9{#%wOFJAlw`~cGg)Zxepy>7z8i0NF< zjMZLy2V8|y{(ettjPcpU3-U3W>s78<*vmaPR838h!}(+(s!=kX^Heu7+P-;c6MSrX zN1njX2ELztp*~NWZQqftMl2uxh!jHiKv9>sR|VsV6TX-4n96O3A&W>fF_Cv2EU%DL zQWDpb??SqCLwP~^oKC#M$hO1FkaU34DUuF?S-Sqd3sP6Lb$lod4NVVA_Otpk{1$nB z@MtLIB_&mMW<@ncsviSMo1>e554dlopVM)15k7RNWu~swcxY^_3O2sDnwf=}C{W>p z_(f%8@8m?Wckcscf;K(w-!e$a9jAuB5Hg|bA9YpGsH5fPIKoSJAT%{eZrevmS@X=V zwgXb+=asL*%40ZkIvDxUlh?0xgczy7JqBsQ#)zfTkX*|f*n>=UrqK%mdvZF5T>$QS_~TEZ2B?`hJ?>5-#K92Bj&kq+u*_l7fnq& z#N)5>SIEdFBKY@5J=-!!@l=FNZlcXb zc!@Gb#OI&!PCJM@@VoFuQy4p6N`H5*|Ng+I#9fh6k5JmwR7-C3Nx$9I|Lt2*`l>{c zlVwO)#E$Db#m^{U-IF?_%#yo?1o8wBhN3N zQrcfJR9B-6ETLOT{7~Cesc>O;uFbfRhDvaMrLb~sZTW7AU$*7GjeQ-KlJibZcTQxtYQ4(bTC4`G`)yg`Q64Rrifm~mkcMma_);3P=P$WW}eyhHxQZ=IshKG$0H z@qdz%(2*xfL~<9ct$9gENbc|n8-NkS5Z>p}(MT*!x{1I1*Q~bRpCOkjIp?fpt0VP# z(e`Fk6EtmM&jQGRa|Dwtdv_lU`bhUx&AGE3Z8=xO!Z^WG5c=4*OWco#mLP&l9Q6j? z5xi+1_~t!Wlhdc0F#qAhhgvu=78b69i+QriH3)MqdU4?XfIEPd53zkd7(@*4>LKt8 zbTu3mb9{tiNikIR*hRmGCw_dP2nix63pY67c118Q{-`I_x(m=1-h4(@7R(8cNEYCd z-ur;?#PskwjuQn2|X$~a?+WG z=jXKL<;~EL?dT}ger(4*0ejS67_kbsD}r)oEb~+5vz#;PVta|Ucpz)XW`yeANbC}v zzOWqN#)hY-L2mLV8R5CDuqrqx??lOLR{t{Jtr-x!oWixMx2{uhd{k7Ckr~VTL7VyU zW6qpqY3w?>AG*6Ga&lsz2ZVNwlZkF63y2qt2?-+`u$IXLNf(iqDmvxR@kzkjBWRE6 zz|3CUc>*HhLLfxZ`+-pC`=Wg`0nWv`h0b+#layzfHlLjBT2N`5B@etRmNI+&hA#E$ zP{_x=MKFQxu8dFvjB7QB@3~it5vw4s8LvA6gQa(Z@*5Q#bo)la1QRxZ)X07@jNIA)vCJIzA^P98fZjh%7r`q)Id)`ge56U(2U-)%>2#{A=PHJADLsH|+M zbCCW%lmid_`~XCjk~r$`OifLhaTI@>a=Oj5t93wI(rrfHV~&ML`6M_^B~?{P`=Ltu zMYZG9fO&d4Ixwha-&uJeVXyC>sqjVLLT*!;baUxKWww6wx7jaa$~^Mvdn}Jcr{6LU zkBX8Xvn`GIGdzsrcn37f*m)xQ-SPL%UzS}#;$~{PBCw!te%FUApimf&w>gYHAXG%)toQ zHqg>Kqp*pt-oc(7R&fC}9jmYHhdW5ro&{253}%Lh*El(v+0EBdLHlnY^S$lkkdmM1 z#p~)C*$#tpCjSpl-vQ5M+rF=bQbt-rLWRtXgd$4WDm%#zSxLxNl$1zh6DlfuN46rH z>`k)w-u#bm&;R}TJfG+F_V#jr@9VnG>pYM1IFG|>uf~pIm1|{e(><^2CW&|?2>i2( z3Yza<=m28jAOV|!>Cx1&12{H)c9=2OvwHN%Y&2tEkZ4NWVl}%FgJbddq6^0w z%Rw(FL4m=K@WSwF3y-%Uic@_Ov8E7opDcHywtEkhgaf7B%!!v(TN|@2y3w&Bpg}VB z+gr|%wuMa9r(HXA9~5clYv)o0}VG7Jzdvx8DRJm4Z|L#H_dA%3Uqff+uXB zc7^aCITp%R-Dvw@^t?obgv3?IdERYKi&-A@;*U|<&g|$A zUMeNgx>7o^Kyl>x^Ok!a7B zsRf2MdTEdlT+2hA8zVv>@MI-$JUS5ub z=z;>2{^xnY@m3{e;Z3sJ1D*h+Tun@HP(-e6qLma^P|#Cctn2-M&%y}oI5Fl=CtdTN z0CIy{p^3bOF@H2+Xn@`3ABn~?NI?WVVb_kn@}^IQIIY`sqTXBIwI{m6TzUol)oyhV zbkO9(ezXat5a@`*E0-LO(XL*TmvIOQJbzTMHNr^IWOU@YSo3@L^hS5h?8V`)pKIRk zb6naeb6+pH(52qXO;ieu^!vlXm)PZGWL3uwz>hW?9e^D>o}3$lQ(}?v6GaXd50pfV zFh2XlO8@KQDGtWhlxP}IV_*v^rKGK=aOR^=tH0k3wdqS9KHTg0M2wn)NFNH+1cz(jt@=%_C{FeCn9NEr6hat> zI^!`kbPtv?>-)h;VAD<3)YoSwyqr$(yl}UH2quZRi4z?TQ~~fgri)R&`8H7ErK4E} zss!x^!io20QqSOJL?012sR@a~eq!b)`4-(bh8Z>@(fNQ12;DtqTofPCh~mdpe=hL^ zJ=C{^T4+f)#jXy@P})>7PwmM$=f|5iHW^2U zoQyvwj=82}XUApmBxj9{M+F@kHgaCwu=?B&Q#UkHrbb6K&^N>?6P^y83tWJ$2^t?h ze&mncg*kZNMUX#;u2Erggt*qxw;RTe7BriMr9Qk;<2t0hk#Auos{}A&lyf@8)1_@- zTJ6=3d?&L{Pv#%?q)BTh@vuZVg?G*2b@M4`(y4M?>`$?6WXg$MJDwo^=+`cG`J3Fs zoMA^QB5bmllWl3F9~*f2F~)C;tesx&Zu94Hy!byZKykv~Z;NE7eJ!*XTV)d6(swPJ zIJ%S%w{fu(Un_g>6hXAI7to<*zQdw7O-eIWZ~$l%TUK_hZ`I!X=@KT!Tl`tJ>dqxc3+3FMeV*ud0Ml{$`^zu&#OJ7} z%L)n>2}m^l3uzUguyy;aK4M?{km4a%lv65ZWA3OqF4Q_{5j3rJKzcEEJC;Ypx&Gcn zc-Qu|qbrg_wTB`OpLhhJYX#$+k*xVQ%kJ@vUxrVV9U8p1A@O+9JTY$Bvd3;NwC>gN zmgU0%Z87JgTM6+WHoukT9w7JJV0t)J=6b~M$8zD{o_GGy%TD=I*Jnvdx0@n%@;n3~ z15p5t9YQGA&afPBc@x$5%0;m8m6&B(cD8mQqo^=3Bd~GJj^A=yO6qD=wZ+k2xz=E_V`OfA z;OF(?Q!1*ro>Ct4e{+(7%Kwd&tb&Cp?Kc*?8#*Tixs#V-BsYBx@?2eKe$48wwW>sF zs@{6oGk+>;Xipf+?`tQ&K{rHA;?`4tS<57uDA%IxRJ-0;3AZKHjO8jTwbhoojGVfRS=&$Q^`YdBtL>j-O%F|E zimdlXyeeNlVj#BGl5_AOwQ|OSuUVG!!|A|n#@5Q;B@AZDx9*xe&E*>W;a1w&duX^Z zy0~!d1pkR=-x6Y;Zz$@P|6`^O@}LT46chPFk` zfAjH72+=AiD!Lk^PiD86@8}cM%6a$`TEXJtI|y&E|G_s{5+x{YWQ5vH-=S|EWoP?B z`YItU70U~NJpnWGgFr3BMDOJstOv#Fx$0X4IAO`2=f{5?*|S>%6PFLecR?w zObnRFy)O>)hD>BxxqGc<(6qFO(SAwzc44(8(VfM|R4(+#vr>peyf@V!aIB8sKIclZ z`&+`@0f@SlnI{#x%&Qw)S`L?3=|=2yx{kXSwL7RfLKm*ZR2f0$n83DV879p@#?t5O z##?S2laN?Aa8;_JD$ZAl`h4zsaE->b_9=&jr*5BrOup!|+K?a@@!jVotCYoVqHu4Z zZ){s{`J`+UUrRfBLh9tq=NuUCynJK|W%4h@U8 zPxN=%v8kRXCnhd)cA*NtF!kft8?h~(9r$R9Qc`X<2IX+OY8cy|?&CSTdu?s(__0$k ztQk@1m*^a{qqdN5{(ZioUQSlQwr8tuxowzh+g!L?uk_{N&|AN)Zr*excVzPT_F!8u zm*+gwEyp6*qLT1mSXj#%85vhmQ|X`GepLSb;K7Fm zbv-FzR_RBpx1Wvc#}8?}3l6@}!d7Gl%G4KVEOmDJ%2{LTY;6TO*a+|aHNTx6^~GeZ zR1(M6lt|2mPm~w74@|U{PhWDhDI&8a=PK{MdxcKg1fx_(+4e9~&*v8Au_d=^nwZjr zZa>Z|cm<|Rhk1{P4)>*9r57EC?U!n`wO{^D3R~`b)w**g3~mQVIWT3DLca6bQy7b2 zI-*8i{vrl6j8^xO_%ck>&Yr6)yVs-Rs%6#HH`)3sVI5ME4Hw(Pjp||*-Yc}f4U6DpJpHqBNs(LZlNYtt@`j;^(bTo?hlj6l<3(>Yaq4`| zPvl&m9S}Imr9;2l39>;QsrsI1t4bDDJo2Pn?ezb?>o_Vm3{M@m=bTgNXZNUo{A4!4 zEJ59Kr>lD7a4eKEkEkAjW<+y5L7|fG+&NwPeH->ShKR%rith)Fi?dKOU-3Bov98;x zx{RDzxp<@nHQ(E}4|j;xdDCFxxuoQe!%B05%Fqhcu)+%KS$6qz0-JNA{>#Pg5%lyT z6cf{(vLlnP3thS)?XJDmAtNF4^`u~Odb&Rp7#Kf(PPkaw(V;(_WaZ?Pwr`&uqG(Kv z&wH`f?L#I)Ic))lI&-YmIBy?vn(Q!ME#nYf6W(X*N7LE zKkREN3_eZ99u`q0TK6VQVO>#as441v)As%yDml*U=bu~-zT|kmMZD-2L9}~dVWDfn zEWv$4mn@JopN9UDhPc&~G9QGazO65>tgNmt#hzwyUERpBoNLhT8!AX1oi{Q!2`SiP zsJMstSg5?#cY`m_10EI8^j;AaB@5)De-M}wJhJ%R?sjOHdEzj?pupzCapu^#?;>q= zmKASLg8~cK67~x4h?Z$ImJ+oEAsWbvD|r9NvzXht7KWnik;@zpJ$~Kighr~#&%`bq z>WN@8UYh%K{BlytzbeZrSr!Z)4cXijO2r%ER{!Rf_J}`<(4nB$+)-lf%I)LL+)QU|Mfn#r9pk_` zZSLS*I845}LN_M~iMe`O(svJoW8~__3fD9nv64kuH)uXgEzw2q z#YGB-U*E^@AqLV9Fr<+4o^@VbpxRvDzhi|8vvVNKlda3E5S6`vZBNjV!;;UmiiJs| zxE7(~=ImrU*#+vUuCeiBjFN#{j`<64TcKfzYy^A}hzw%H7qW1comre0FG7Oq*m#q^ zET~-|pe;|d^UV#KR31`*9td+6*D(tNUVDrmV;IKtPAlC3QDOm~bF$?(hV}lO-f?aR zjv>am5(eDFb`=Cf?E|O=IM`Bl?%C;&skOXo%7KUK8ykV!>OzP9I7nQe=z?90XFX+cu>f)-HW-G) z_K}i;nT>HQQ0f8-PC%jrqbSF^i-S{)>Q10@5%gYr@5nMf=1Gsxu&_P5`}BF_XP1^l zr(L(+6vq9$6C9+QP&(UJdv)zZx9yL6Uh*BA{GfOwP(SJ2V@UqBsGVE$Abvk7i z6Vkm!xp|eq2EHJqOL}_cCV;cwt*u9o#FlB?D|#@$u)x8|sg-SJf2YIwrj=E~0FgHa zh}1~Rmjwh+`6MLt-nsPzQ!}7|gIL$obAk~znV4pr#~cU77@%BU=(-)rnG=Dj zG-h-V`vUqu_xV0voioIOL6C&(2-)%t2kWA%ocx$ess)-E7DwB3foKmFU_E5hJl?K4 zOl;b!MIU$x;X~|nSN;4Gb|vYzEDkz2V*gZ9Iyjz)`%xD`rd{REAbAGEl>WNso}GIY z8My_*+S8pot2h!XWo1)+cBM2mcITUSuaU5^;iqB$h`BJ4ka(h35EiD1mL+f{AS=*d z!mWshMU$L$ehX}~1da3|Ab$9em}xGxJE~udc>^&12{dQvaUSDmOW*+~-c5IC82kB3 zmAgckEQ)Di{{l%Zy}^J%dUm#Fj^}j|R_hzD(A~;7@2nvE?YYgHn`b!tZ<{s_gE^>A z*h4^15l!9;S)XYUC%Ok91)~V3PoEkJlb4lMC=(_xDhsN6Z$4ZtuPC4Iyq<+AfayEv zBw~K6vb*~dD0?WWKr7|oFoQ@YSgdH$pf?HjH4X{zQLtapMMCNAI>uG@6tgK%4qU|? zO6JGoU=?Fp3B3d!6Z-w0^dfeoUi)$;%60Z1ih6kG0+;Xn33R5AZI%g>t#IPg-iEqZ zB3{W{#pbp2BPpq+E3X)SznOwKwiz+CwIHChfsPwi$N&y!L%nxTTeA z5%Tn92`T?(rpJpFYDcFNDx3YM^ADXlTYHz+v2{udCGYchbl15@YQ!Z`!CjjeeYvn) zCj9HO)nZq}l+Sr^ffC%^8vde01?RhHOjG>wB@A#ZphNd%i6t1MgkPZ zLBhMQ_lJ(Uu{-QMw|3S5KwRm1)35Ylt zor198d5bQLpUeP?M&qpChyUfBZx5m|RS(zqwlc~iN1pSN)LekG?~|P99_39-`UiX) zL&fqW)x)>%)V=g_jZCpxejY~=`B$_?lC=AE{s$Ib``x83jlFhUV$RzWQ}KTXf_%tc zlIC)BPK@`jcc;7Xxn3=5=B&~RXo8u$wb10^rat4{OWpaJL6)n7WLvWyu9eTbX+fn1 zKIv^kIWrz5NOG7)&?4NkT)fr1Gvh!_^P*ge&y~r!_p#a(LN;qnZ0a&cHol?T0TMoF z+bE>m&O*w86L139ZMei-$`!+fA2t>JxZ2w$B`$5!^@98M-scnr9|p`Ne^wn8qIl~M zi2Gh`I1S{hNe^8pC_^&?&7Gtq1$)_32jk=8A%lt$czjVGJP1zQkD?-2nMQy7cy)B@ zoc-KiKW&M3GH^bu05KcdQn!x}*o?p=k`Lz*k!2%h!-RE^>q#XB2JFyS^B^D_<5O&TZ@_AIi0PvNmbgVBByjX%RYBe-k2%wl--5C2L?C( z@?^eX?3vAL99Aq`SUwE|6sJoYK09MDvDsLu@V}yPz92P3@AsT~dGy`I=69ZgoM07m zZ7l2%8-x(fse>lKuhmI*D0?6<<0yc(kqe_XJ-t7Yh1QtoD-u4mQraeTp{cn!;=Bdq zIzf6b7OoWojTHn-e1W!2DPh2ygHbU!0&sIrgUJROJ2n_XCVKyf>0h^TW=n)uNZY^L zaf5h?-J$ZDEn-plX4X`fJ4cbkeuCwG-EDC%P9}{JCb#!Qx{b*b7?h~_ zobvb|VX-Yb7Zye@DdABU-pQp+|7ezX0xAWI;Uow6Mwl~$MKfoF zx!8+Z`}dv`h!`udt!=21E;)G2)mc0Oz{+w$n(`a}{DVh`uTT^z!tJWDsR?|xQe-*8 z7&$|36)-4)^dBhb{_*R-i7}{%P!8=A2Z#F%pP|9Rf-xw#u4AyalF$>CY5o8gF^nXC z{rXbw47MxAdKAJh+{C4iFGm$_Q zGv9*ZF3(AyPkTPez7G1uRv~{w?KfXDUj~oCJ>9HeQ}I)jZ;XoIknd6i;(=cOff5lv%53*_J-CQjEryeS+5LwtdMW?9B}MWD=Z9H z$xit;Ls_>v&eQAgaV&QXlU@XR1u!x~7F1eR7Wjrz%rtN?@EP$s_nr3__NEJEe{PIF zs^NK_EATC)qSOl`ecF$0`->>iWzl_oct-029p0U8kKzd;vlxQG|}|I6v5pBC!rP%%BSM&{C`y%*iA*o zqkX9k9^NOLe=9&OVENRtN^ShEF!C2tFT!&iuBqQy8F$}a+SIg)`SV;0IjG6kw+uYyBTQkdT>F6^a&MJ{)XJ$xO zO1lV_Whc$M*YzJw>blX}m!(JR10_y&53=$_=GJM3doPAQ{{4g~S?E~hCM+J}ejMIJ zq7DJN*?K>@zSAsqUObY}Q~m3Qw+-tAm4l^*27b6sinVvr=_M^ zVekqt7ix6mLlCiZ#4kj84h{>hNWms@yGS>d`y>`@HOFpx*k%-^S0+DReq+G@Uqj!XMeJk$uAm=PPz<%VLh2okCXb34d1xNUW`_4>S}LPQr@yN;^BXmq(_m z=^+)C$Il)wF7nf?t?eq=|3p!z3w(H&zfzZ<;imcoiB>&*J!B)cD=)6kEf%&;3W?f( zJ>3$WvM?cjtG2pyMC~ip^;jNS44v323vZ5k48mdr9M8Bpo z5bw6cb#QnX-kga!IW&x?pjR_G0%tN_-ICoQZi)?}9~cCwmMy-gkaslb&kO6JB;)Lqn;py_9(r*{_U zmltGOfBO3_wkA8wfL}}I<}*LPz2lFVpIjbEJj?Dd%R1VpA!Mw zgnQ!hWuotW+vPY}@$uu3#@dyIAu*LZcQ&vrq|qpxal*wM79eEt7lH$%(NJ_fdNc?F z2FxA^jf4)b5tY}EU%#YeTm#Xf2RpbrTqynP*UFL-Jq)*!MnjqdS^0i?z{#YH3s7)p zwf5elwvo({rv0OAR=X~O`@s8lp>`^X0L7J4Oka}YpC!CC6EEB+pz>Au$Q2Jimf9Xzs>&spgx$@JFcqP5dir^dK5lE^w6Q0E#UGPqom1!4qV50WcK&$u_QBQ5DZ;hr$S$wJtD~1MK z8CuW%re9kVGtBVaq8-2)nS^(F^7ZR5Mce~CSxeM$cI7U{P^~tedduF~<8h*&EtH-VG{dYVGyMQNe8pkrB9*8(GB?bbwx8v;f!sT8#tYa)y_Ra6{^K-~ze zhdg#T>OAtp;-`Di97CDte@0$UcQwc2c<)>P)jzg>6KmE5O=;!E%%<~bT==FUY zJl?$UV3lBLG#93+7*Q}|U9GZD{S*QFj?sDz*2_>l13 z*+O2^ehw~+q|nOHAD=t#3y&!!#wIs6R_=RUzYvdGwUInQrfz86(4 z)57#h7(;BtyLUq-1%)u7%xrDNSk>~vf=-0#H=tZdsW9yY!XDV*rId$f)&UJ*r2qkV z+Mq#S^cG^B_R`(!|mYUV5FhY&bp7{LtGqw^(uxdD-3u6qyQQKUW23^BDeJ) z-aOA*ZYuc3ZY5sse*bK@Mw|L+gHso|>5%C?p}f^{<8DE5OSg$O&Q!DXeeqfGTeh76 z|KkGeqdu(oECLQY1rN9Ok+G*IrB3ZAbnm_Gb5%i_>w@U1HkOgfoD}%MNNY&PpH4qe zeSlJ;GW4wHE}z{FB)cDz+&B`jIKM#4^mj}no>GD%fn#7#dA#x0@Yf5C=j&r9IwlI* z9kZ8@S2bVVY4xBluB?rg#o{(`L7h`=BhShQ5Xg{}>DsdLj2(g)AcSpS zywIWd2t-2@noRix1(Cm(gV(T7gz04x6QZJD@Nl*U=MSj}{td|-EGblz8oRq+Q~ntq zo*tiKgzq?%7vTHCZ$QzAGl-BF78d53-3^^74xkw5pE+MqScCU09Js6N>Hu#bR0j)~ zs)CRT{YMP(bKm&}1){^SjD=7TXnK&{hYA;?2jT?QbNvhzB-ia8JW$7s9Js9*C&4ca zk&C=&pZ4k%nW1VcBXW4$B~vK6>hm$Xn2)^!RiXk%-@ZDOXTYY%9IJl(XzJ_)$imIc ztPfT$kRgmL6&g{32j%C++&JX4ZOKf8e7s+G2*1Bbm~^E8k{9nO@wp1cOT@lU>!>JUy&3rS z?b;W=NiY>LM~p1by#@4JZ1}MA=8!Z6YUc=;0yrp}HU*B;L`P;-bMw2oT=>Cb0_94! zfJN5@%-qtExBmXkbn;|BvEUchxRvKYHggoM>F&D`1ZX8(t(F7TMmjAg_Te7G3}Z z>aJ+)euB+&Yf;C*;1CrRBPA(9Eh^sL(96Z0o$H`G-MxE7Ui!h`UsOmR5h$>SAV<;u zIWch)zKlpoJ>CQdt3y!f>C>HtL2!z8a&qb&1$6N-Aq1+nkjhvZYi7sXh@|9Ch8$-E zg37!TZeL+-e#_y3Q+~YVIXFZRzEJ6nSnMil+hK@F>EIb`PlT)lC9KCJL_O;NeT@|Q zT|Nnl@U5>Wu-);6rvX%kn*A?KoyS%%#Z&bwe@?12)=gT3xtWjOQ+ss2>23l~xKaFS-=3dp+ zG4_!Ut56^%knr+WzYU3usOlX=QOta7)?VR26)7xe7N}a>vsJm&w!`j+{XaQK6Y2YQ3Thv?s_q-zRst2eoYXmMmm}1 z3NZ(~$y@h&jr8?Dc)x^n3X`7{AoaDiM_5*Tp^k>+4&IoB#gtzGNJX*#JsN;AKp81# z&Vftz?B9ydkH0T{d{Go&D!MxanVF%XBuE1#Cf3x|CCA5q;8=M|sb^?-7(eUKA!sG# zVPQZKgxVdbuh3T6E_^N`hAQ1pif&4^ECJvt3KsA_vCD8IfuzXX^g_=it+EbUO$4!s z(x^4XbVEZ!fxbF|M!;1jkbca&bDN8cpS*i#^U;!+=<=Tb9u@@+HUfe@Fdgh&*Ix$U z8f8aBbR(3IOq2}FVssbp8buAhK>$I92I+iomGBL|oU8U=^WJ(%kEa1R9MFmc#F=~d z?%imJy0Wl%U41$@xgCWp29NsG6?5~DKEX_)JTgT@K|ssYXXH>Oy$J{~(9t>eM_7Bi z3ZOkq3sNk5MUk)f=Ye#q;*KNcwuEY6yXlx2b|k)XPBR#A6qIbI=Ua*5CcBW<<<`#cr%DaLKb(J#OYGm6S*^2LI&($ zqYDo0(BR-DI9E4(xQ-7>3M~ajPR>MBc(6^y9+wa1={I7SLmpIM@eAh^|-Ya+mlqHVIvu77{*MN^D#s<;mTxq z+jC364^ECueyB0MO8Ze5fiHjyNeCKV-@f6%ApqXi)I^|)?$lw}`Q%Md(DH^hNRI}* z8b~bF)a=mX!e2o+EEF>&W?<-b;|x$y!~X@v0Mty%AW(uG-bj}zBe9x^`SYtQ4R-!Ia)cXpb7viR~PPWp@0MLCBDkHdz= z#V8szVne=!Oew6Zx2wn1g>BhTlf`j_XFiB^4;%Q^eV9nyjj%7u;J1dUZQxXT^a#I2 zuAooj0AmR~EkhI$1rd{Q&rF|a2dj1C9qN}Yn>RqJp*?^^2H)T=Nj|!QISdks7l?0g zn)fV4=l=mFTpm750|ns01>7&#A2{&%H@=4|0n#lGN*sR-iei3kkWUN|-cWci{t6-k z-azF8+Dcv54XRBI2+QCIW48iSz@b9B@B*C$5;ny7Wk`?k@*dBqukY@bXX7K-1Cj1# z@@wnpcu9$)2LdN4V`byi(2tR!qV6LL~NM+mJJuzO@bh4 z_G*uwzINV5C6kfmiiE=RlO6!&A9+6#=@hABxU_xpTw-R9it;V9_FO|Wft6=LMzjI$uK|X^umGqqz{6q0q1bhAuP!b=5dH@TB}q|ALxN8i zPECbQ&aiuGX?eai`StP{e?$N(iCteAcae{aKr+DjvM(`jNEd*UsCNY})P=qwAvuYO zuX(R?_(>W3C%=ZI%g=9={q(~06U?`^x%z-`kIakpJFWt(6&v$&Wazj8&CEy{WpWJa zkKtlMYNBl!mylgjT%2l9@fJo(5S-ie`+h>yfPFj`ieUC*A9{CFmxr4&x~0#2SO_aYtDT&Cv~m*HL3O*QAE&}Z`XyhMJ7dR zdoNg5O&H4CdUx6+I$Y$iB{3|VtfN{|RL zR6B-#UtiI_y>|P+;sJo9*BW16u=-%~^zh{a|Dv}QqBp{3(~LPI*2aVK$z}D-`=A~0 z8U~bqo*8XqfzDR{cTrsPpY$YFxowbFBWIVzewUX15+9GYCsNx`{19~eA^Ca655b#w zVILL~+L%aualnuxeLN&XJVD>?y=||(DLQiX#?(M~R?B&KdB3u6j0=|6)i-i{vv5*` zxpN@=`nG$RsKBIM4|m)t!8+S1xACsm`BzR+8f}Z{eIo09MyaQ}X=zAvL{-(}hG4rk2jHDA|vKszVR={F`Vvg#yLJ7 zN}HCFM*5PJOS3R<`u_64TfW5fe4!Q|ly8ULijLULe78F%=isA?xz?zaCdjdrvLVBC92$d{y}zD#%r^(Z4YhI)~3b>B7d%( zqI`eJDfA)VVD*zmTxp>Wp}U55Pa2Q6-T%JFuc3lHF1X+}jo_?9%-AUb>2}xLFKKD1 zOdh%4x5m30yPF9wR$sDrK6c@Ngp~YpVGkEN3ev6qxieY;RXwEn4J)Y)Vna`Jlh*pw zSRJ8`85bjvYWXVE=lG}Un*rAb)N*KTG|2TbyVTZYV;>BAs>TyJc@GvJLI<9v2u7$bzYa2`|5aHeoM^pFr5l|?~aq!|7ci2g`` zfYS-^@zFX+W7hH6*SW8LGJP4Sh zKALToGCdPQIgnZX{E<{*u~$ArXDA4l3GS%)c=i?z7YWR2N=PXvkdL1% z^^rPt&%glLQAJhN_~6|svXE);amfm)Hl-tt zmhR|St(&`krn8lplX^eg55Jq%)Q)90569HKlJ;_TzO46%iB=@+C*7+8&WlcY4`m+K zoe+gVQG_2S8@rH>9{E00GfcCEx}s^+22>Ky>d4l!4i&H-@;d}6i|D*}N`Yi~Gy&Ta zgBG4z`J1usqs)ThK5}Q2ge`6yy2u@scSBX}8tG3_ALq3#jqUA(r4yzE{TAG@EpiGwpJ&>qA4d5Yf$bLdH5Jik)2|e5W zWMrjIDW!_`@TWx9=gT1{;&9WU9Ttl)^Me8vGQc0$Np@$xXj%Ut@Z^B$9KqkYnkUFw1G~#l;=y$jRJE3t8kp@3S3d{iC?1e`|7` zO;E`p*YUSS94G^C0lM zzhnQaolY!U)}_NFUKC3S>o9Sr3?cM;=WGn&4g6hj`mDB^%rB`M3aP=SRuA7R-n)O# z$k9l}{+(x4F*=OekAn+G`y(Df9|_DbKg;7AR6NFeh%Q&*^Z?F4$2*lA14sH=kl%sy zAK+HhWvR7u){$Tz+Rqt;hF9js#ttDCLA&rjxl3f2;GWQ=#O~+B_M$pWolj+3YFJ3t zqCtF9Nn`Wm><)=v7NO?7Wqa>abM+qhlvkVkzIo$QyTfQv^taeRrRB&jyIF%5!+}>M z^d!FL+13P$>DQlWneOV#oNwc34$)fSs(hNZ?o1Ib^3Y=a>tw(l!)%83*~OhnPnn+X zuzI2yXxX0eWA)Jr4PD(fr>s-og!ubQEG;!wSCO}ja`fsr3NDcwwr=sQGw)>f`9}XG z{_8;hz|F>+n)x=+sMhn5T=+DSKfc_g+B`Qmd%T9{VNd#(>H9%-cSQ$X(q>Cp_R?61 z#2scXtlsA(m3!DP#jKt8E9cCU>6i~+scsbR@-0WkiF}$O_$Cd}7>GPS5Qv&N1~Vp+ zq(cJ|NxC}8stLYBGLO2PP`bMH&Ou4|KR^Pss_WOwAq%P-F*5w7!T_RU@D#wH$+TuU z%E2W-TTnVaUVNwUQ%=q*`f~u3kfETB0f-0UQD5L-CLj2a7y`G|Puxk846iO~KRN8S zW#Lg4Df&Qo+i>f1e1(q&C{B>Ngu3czCun3uL>UP&F*~m%AgE@6!~m`vj4tx>^Rd>A zNWTa$SuLGY)~}l^5Cz4+ITT}oNcsEofm(?EMr$jCAY3_`fMP)@9>B!##ZAy(KPzj> zYR-7+1l4fe7&zAaFOx6?EceXy6MoSZR1@HsmGwVBiSxmDPrE1zeU#v+(C~&h5!;Yt zp&$Vu4^#rB8^Nsl)ho?!4;bgw#^3!uj}n`@T(L{`TTsXv={Sem@rWK*5T6ONmw2m% zdkFW%ZB;ET`A+{b-~$4X6l#YUG&c5h_jioEdfJ3*W8a=bH*}h-Up$kwTM#r4$d<5i zbGw3wjfr=H0;y-I^R~fp@YNEUO;s5lh~7XhU~}ZMR?>>Lbb%io-Jy$t+Y}PC*T#KO z-mh?>U#qD}MhHJeSJ7KQ!t?p_2p}c!jZ%H)*O8 zHn4BF;sUXhym~b_Bt)0~_1Tq(#juZ;Ox7zWk(Q|&?Py|}wC#BIVGFQST!{F6eVTt6 z#5nK))Ny$2|29)9R94|NR{Qg(AwC4&+rZ%9PvHjcef#%=o7ddXP%QjQab#pFywE$?by$ z8|w_`zbKyob#)vV^)E*;7DyZ-M@~u#?*>$QuCsrB!n!p@VL=mT;y>l76!JM|tfcM3 zhaeJ??ce{%)|LRgf~gI>DWz6&5;rAMRJHcxj_JPzpk)k>H)L;IT#-ix7j&F6$8P?v zvN9|fXj`m`7dLmxSK@VKe6a(8Qr;y|Iz(i^SrRT(FL)#vZ6VD z9D2OW9ut$3u-Sl39dNS`ALf^rual}MD|^!l;s!)(!-qi(#(SDjB7gsW8{r~g9K33R z$rgY(fXe8Bxo1I_6~B0{RonIE>KG4jEvz&kDquXYhPB^ZTSS2PN0Cmt>F(z@i#{b( zphrx`HFR}{Q&)9*{O=s}^RVIK$?MSUL~l1S^LBc#$#yjjAN1m(kL_;5I>T~Kj~?gq zxhz0B4h)xL!iKbN#_0Lvt!jlxu8}P{GmY4!23mTbpc)2P{R=K__`5EOG6PuX=x3g z%f@5FVy<%FCcR{vy;fU1v>%1KUD$YkDnd=JM0hCWVkXQ{mIah)l=vS zvzYE>)!VmolaeeDAMu1ZlTHgGe*Y=Opj(4Jj@F$!tezOoD!9plPXP}UMyP$R0iK|t zrG=C`isZyQIccs}upRM;wCBx&c?~O5dK$Qm@u~1|P_ICuiHSWlV?H~{)fj(_iXv(Z zCF1EQ?n00bg_1L@Xxs=%eH6js6(n6490BL7gV@B>aS{ost5-j!%@xux0V!jBW`;f< zs&m|QsEhDDiXH?R;Jb`mKTAp={Spnt5G#fNafXEjK!u+a?o3d?$a{fU1ghz=M`LUl-#7HUxYU$S^=Av<R2j~6ebBqU0ZGO)cv97o6Vyi5qhx**fzKj%Qou9gIG z8WaN`(E)|xHiY?Q+fUHZO^lDz9y}NWc2HOLygOQDf!x|K!{k6)0{A!*5@1yr!a?Oh zUk&fKD0#dB+&ZXM|Do@;9tIfc$$7lZ>@(%Z{0&M^Wp6YR6B^z<<3t|PEP4S|Fp)3TybAGV9=au};2xjGessmp;7 zIiD9VR=^*6arF;26K;ahQInbRR7l_AqV4GHB!P+nP)b^8m)oNI2mT`(QvrKbu_rfn z9^V+oo2Qh(!lOL{{@wUV%z>|61lXb!CMg@*Hym>GcQOZOP+XTC*a3uf#u`O!h8dOo?rE&}>_`ts!A`CMbx_X3n zLD1Dig@LXcAXbRxK%s-N#p^lvo1Te@=0N7<a)F^WVm`st)D$8o5N76) z!&F@sVksSY5WKvjqo3#=#3P>j?-B9ZqalVtv_CN}js+wwB-_B*p(^$5+hc@E{yPJY zG5Z5;ckEkR+w;!_$ag;J!@8jgGBy@qG)Vg&7a$m|#eeIb{x8Xu$3PbXB~S|@qO+1r zewd@cyR(~U(={{%pU4T~R2`97Sg$JVZ~QOL8!M?98T_i}kZ8j;1UZ5|Kz0CSBag8e zUFisEUdEUIhSLZ@(PbN{Q@4I!BAzFfpM^zfTX^0RwFNeV}3tO*+4S#YL zgs!XCuQOb7$jHs@C@Vw%;Tq7a@f*xAYBs^WhUkKu1bI!_S*Z3^K}rf;IT&_7)%}7y zxs3E!#!f^V!g<00KpzZkf0QJ+XF!O<w0p@IhM?HygodH=GV<5|15vP9J)%Hz7GpIFLF&ij1&b%yC&QEZnHr;|`s6>=0Rq zN&*lsL*9WVFLDEuf$}{eas{s2TOg#Njhqa2)cE*!l%eSFBv`)=2e}tUPv>ypko)7c z;8svS&FCiuou-Q@Zt#X6;Rf%N6d6KkPx)_e?X9HKal_SvRmR0YU`B4NP>o-3dzoVW z0AlQQ!J^$|W!I2;j$bH@$;=c$RR&HRDt@I{Ij`5R(Z!-OWk^hheHX-8F@OUGpL~Sy z;nGvPcf#19A7v{1BQ)|JpL>o?qGR^&jy;d9vS4CqxjZ+QOG$b8BxUR|T9I#Ns>-TE zgW!N4joPR6c%yS}E^nBdYxizC;YY^EPmx)DFn(T(v4WzaqWbz9aIya+TUt_r$|jXu zfIz@U27`~^j{*lw@}EDS=^{^vgfI~@fnMB9U0rmgrw}{;-SC&ga9j!ORv{SB;fjcO zoL7|;A*-77cL?mkLs5j{6aNj@wn;@rL3tkFR#ZRV(B1|3pYXvM7+fpFn1Q%Lh8c}U z6h3CArmsr-Q)&f%|1!Y&4#J@qAN`?w@7{Cs$l6*34Cvv?$DPNf6stoE!M!q$mHh}v z@*tlj*t8MvBG_KoJb*1jVu6?R?b=tE9lkL@bOi*)l#~oPDc2F3TJE_H0pNf@-xIDJ z_=C-fRMbB(5W;bsK)1kjf(;uU^8&Fc9S|Hu7|0AF__K3x{KzY7=;?u^*akM?XDe*C ztpDOweVD#C7(VHs)os*-G2U!d*t2JDc3wY%)SDdVmn?`=)@A8YxSN}o7Y3MxTfDZO zln;P{VpJ1~YLlLUp%4ZxXy@at=P;lG6AV8#q1uG&z6 zc(i#01XN(2)X9UzC@YRS{B;Iz3j=;QI_R$v=J77j5k@d&Wlg)jS)uq<-d#zj4~}am zL|f749r!&}r5H?@v0TGg)%Q@!!bbZ>lt{?I(c4dzL7y0Lqy!E5KY!3wMa~1X1?t)5 zIwl|LAV`^I(Y=ONJ6!6JDd85;i6Di~apVQ7ry(Kmpw+^J-Z+jWPYbrGEW!?uf@t07 zx=2x&bzdBKd?ANti%XCHzNM+<*6(Ua2WnWRZOS-;$cSt#;mVE)@euv${}Ru&wtC7S zTW#e#bmJFvGW7203LQT5VtV3Ent?PRj9+qvJM$1YiKvrarSzT{JX8 zKt;up9_74^l!f5EWW(e^2?=|+PaJ?B?M&YjLc}60C5R}@*vTl#mbJf!g@xAK3!^8$ z5c4IjxU~G0O;W?xRcoc5#vmFyLOQ?1&^B#TUDea$!r|Ju2SK2W2cyc4%hTGjc?Sf@ zXTs29SM=Q7Ssk47pdltsF)}4B&FAt5aEHCUymA~C@57cNIJgZv9M8+*y{aIM$^(ua zuu28<29@H+$PhyQeT4HiVW1OD-pj8Mz(4PHA}+dYWi@M%UxB{rwRGx1Mg^x^jf z4&SFMbocbN``X;J%Y_7g?0t_$=z_3ZOBhliZyPSR(;&;rBjlhtFVr`gc+TWtiv|&= zj*$_Ck9Pv_1hke*I|yb4Fu%Q59ZX0X`-*meZVeB;xw(gUspswvj7&^ieE9bT)c5cO z8FUPzeU2*=KXniv0}}NvvwtK-)ju4^lM*`-A2UHl-rU$ae#XZ0jHkI+T7(gu-1;`l ztoJ~>i@|mExpx+~vLg$6us)c&=hX zPTh@+^SYV~(NS1arRtYs z77i&p!6o&p!x`Wsm{i0Uyy7s$iHm&uMuXU4eR0@OfKW3lH!z>Nl_tY{snTxZjmq0g z4C9s;8`1n1K6#P|=Z5yi&@p%H>#5$t>3(ay5iti&t?thsf8Za-x^#+&1tF`E`hf*+ zDza5F+Y9F5x|i1btMdNFDcYX6IMctmZb{z`zmHtQs?0|=?}G|`T;D8x{MIJMu~536 z>nFE<-PqXlm8=Z8b9G=Q`8l;@AGyfPT7{=2B>+KIm5yR<_ft?{Jqth#0ZAS%cb6|; zMxJ!_>JQvokjmduzVhJ^gs~1BfW=K(W@ZHP22>0Ad3m4Be|G)&aTPL?sF7)p9n*zOiIdL{D`HYy0ZXsl3OnToeEZGZoG;Q6 zGB34nyh-~sR})sdM1I&tpt5sVG4etG18QYuR?GDCG=sy3SmWDVEX)!TlZHUMqN1j) zcW~}rU(OW3&%7eXEHN@^zA|(3rbft|>UC|`7oGO=YXT>H-=`Z>F;i0;zDjrs^Z61x zT23xjGjkJhQmgqHsme-Jh|7~}Eqtr$+S>(Ls<&=Cbgj&8CPV5nKK|DD|vysQiB#08^O zL1{61@)6SRKvHzAy7E?MA}=Ad=j9cO{>lJ6in0k9FzQ24w87bcQ{vNi?+#N?Jeu6N zj&V?9Eprq(POEc-^z7dvUB;$k#7|+LRIAlEPja7* z+2r+jN35c}Ej!`LJ+r+YFLaC<*s7z017`8ADWFV%2bdSxmY7XT-1BRgs#%{NJFLJy z$?`QG6Zid(ze)c5;hroR7!)X+m|?lGa`Wn)gqVp%In(ih;W~)Q8p3>LXe2c}+Y<3| ze7uJ{@|=>K1k=0DDX9R=~|xfsmC?h<7cl9Ys@A#5CG zeQ4tnVuf|i3^5*mo#T-CIfW9$k3m@*!@zn`_a31_ub{XUa#|Yp3mp;JjNVqRQz9p3nd4~ zQE`_IoZUF%p*7t%@pLnwc-=oCb9&Jp%kq45p* z+UQxX&|jx92o)p!`FwKHYI0$7pli9n`M`L^vjvxn)0H!SZnciA-AbESakeRPmeED? z1y5mc!ygzMCt^7O5`pLvOQsb}Eubp(^)oXw0k-J;iQM7c<8a^HTqnc&+0&}Y$ja{Y zI&^vr)@!$K<#w3a){QW%T$0m({J{cPS#M)wOiY@%S@qgy;eM(o9mm8|b@Gq1auk~7 zu#cI2U0?nZc%aT56PWUmzEY&!2lAAlXf9 z##OrAFJ$a1h3K4(r;S!S zxAU@g!I(bBYroiX{?Xc~`t#-y{ar#b95-I4=pg^|!lL4Gl&Hk`NuhJUOvZQ*+1mcj z4=AVg62g}R*p10mTco5%}a`>?9@}%<$uhZzv zPJ*XHnc>__&;B)}TCc}}p#S@;lUJcg#t>C+vYV2Mwwg^{^~6x4Q3V@V?^6y=5A^!j zP9-qMizw|782Wb#(GCa}mM<-evD+8hc>(7rh?$Isb)XL4CQ6;I? zcoQ`IF4kou{LSXisXmFjyY{iOSKChPOzXWw;w^WjZ}*GczLf!h=i^1 zE}4gT`tu_V%F=Sb z&@#`^sjRAFihL)dC}p1WP)o<0OOhm@GVONJ$)RsvZd1+K!(QJjTz*uGP0dtt3pz&y z2X~wB?R4XrRP3Bv+34yDu(Imb>E776)7g~Dxi@%!hLw<3`??atDFV}cn4dpZcS+-> z=5x6##|a6&2nD)mWI(a5b9}SazjD9XQr7Z=_sk_)yP}>3zv+CiI^WrvdO-Ig8Ks4( zDW7iPu#vGhvd;|f8B+dwa_KlYRPbu3fS-@oH|Paiwl*NgnQCuu$FqlK9X*-yz-SV9 ztN>Cl-$Q$h&K-pu03qrkLYH2Eh6<=0WCh7tcqr)@e23|5&`9@O?9pD@`0{g?h~FJ| zzu@kk_CHM-YG}f#?RIn0*?IV zCw-$jssIww&CZ{BUnME}$DoJ!t&uvxq=$PUd zOk+Q12GM#=9Q|u!*ixCFFPnBd)8;|fqxtfhvWCa)nU3{QMsW#hsw&2#_qQjHJhIBa zTv6fU>u1D%X4=Ni-*vepHs9g-o7<%hbBh@Rs82*uz8wbAIdbE0L|`1|H63hb zfcpyRH9&2Q{{hKc+1b70oM}e99KeVr3jQTPl4ZMh82F!qDC~Q6z3E*tdA++oA4ZN1 zy!isaFMW=56_ieZOIpZLdRI}ik#ytF?iEyY-=aK%su-NN+v>hny+Ai(a)M&{zOhAo zeDoc?d(tGey6I&IHO-EVZ3zu6Fj=5`O_#Ru_lC9PT~m3V;ir4bQ~g)griH9e{{2=N zo@Z&UsE}7sU~Xk)sCVxY$)1(fzd9mP>S|d)`E`pT#?N?AfwWC|HnmVmsy38PlUuu@ z=4)ov*QeRBByvWw#9WLzy)5N%>ON$o;o%9JlkvI>^OQ4o%Y}|cbHC%t2iDGf+Pyf_ z5L7a=ukk#ahq9W~w-xf%?RH1O^r41c&c$sdNQG2Y0@alB9j88z(NFj`H91!XP#iS` zG+SHi8o+@hZIm|{5E5i+=qr&V0Uwo?R2|d%_emO6JpsJof$2mqQUYl@25Mm9Vu-TB zo%Gp13%Y5zegWzTPX%5>jgfB!EU`Kx3+?%*JFY11vb+%b(fFfc%j?@hLo*W-WXBYL z>`tzguyAoC`&0E|K1$Ix@W%BI(Q$qb2Mi6Cmkmh|aNjG8=$vB%dh z>qLh7TZ9H3+_UHV9!pCY-1K`MFSpu_wGct}@I3A4dH(a#o?ez#RvK@_qdY@f9@W2l zw6M(>eu@5JL)hD=J1MxPvikJ;N$HCOZ}lx0dD2HH`uW|aJxfl$!Opj!H9Jr&8=$PR zDJILyA;!pjUPsy?-nXXSg8uK?1<5Nq>oXb5xagz{3)t88uf3~jY>JJKend}`eW#PZ zKM6@F1Fi3M~-OL!B{KAJDskK%g#qp(SZ;Vh`{ma9pX6ELPj;*qt zUF)8+8QE*@P2JbqKRGxyjaXzsDb;y{(Z+zeefz)OrmBY(<{{R9_xYe!em z+wkzOyQg?ata+8P8#mbK2(RKDyc+dszjvnZ*TW zkOE7>4EBM9}L#_|cE74AN_88YQ?PDJctA7XAJ2G$Yi+(SHf`VBib&3sZ z&krY%9p*Ywa?8-9Ls_@<-WYGcyqf6*$};BtOjze#SHWd%PTlcef;*sXy5dZ8b zaE?DT*hgMV-M0Qfy~M6gvj^9kn;af2X!Q9J-4qt4V4UK+k=Arwo$(>;I>+V>jS@=y zjH-T|93Do1%JDzW3OLS>iEm@Akl!HqYpk{_3JD4wD}MZFv;X^1T5c^QzcuC`;_&#(C)15x%qCC5p*bi>5!n!vjEQ#X8zL>(E|Go!5MpOk?rD z@Dj+#_HQyxi}gQRt!eMPk&1!Z0f7+4{x=t9*{LKK$;mxALcK0JWur1{c&+lZ)G0!bxB|<=RqZ$J$2kf>JB%o@P79KRK}bb)=plQq%t*MzkUF1 zQBaU>;&|!L-dOXpPaF^S1O(}5S^w;f0H|kd6g%D0AJpBlHBVtFDPb|*-mv=7 zVmqN1-UcUtf3PA3av(2arU2OQKW*aX0kb91K)46PKI|5Rf>>s|>H&>oWu$@**kyxx z(j~Zu0i1$}Dwc&OLf@?Udh3rDKPh`DeaWsd)pZ3F5Q+*9fB)2+oI{wmJn{YrpLF(% ze`>444)vB=+F8?Y?c~{sg$g|V(QKcDj!q=dtf;77Ok)s;0zq>^8D{`~+OLL&2uY`mf$Sk0>KYO+ZwlggSGFpIPUcG9LeMdu_9t#`(J3hugh>|9 zSWa#(0O>BM8(_|ZHHeW0B7~ih@P+}5le05w|9CsUUCcC0Oh|81l9%@(NR5eDcE$+B zv$5Fdv)By$GU2~jv1<3E1_BT0mGvWcJ~`K*t$Wng&OP6HQDM z+;UESxk3nQOGqFjK@hSOt*lT-0=@9@`I z^>hu#!&}22`J^0cB_~4iX*?PRv;*ulX9Q;}U7U@@lJ(w09u&S!Cg|AZY=>A0wiy8h*u1=+3fsj(?XQ`Ib& zBc#@3{sl}vKE7`%(>`n3sg#zvFA2@E?`1l&I<>cW&z!kX(dNsN?4D1LIH+T&4@gSM z?iuvO+Q1a&(^dJaH?y1yB0{pgg0tK(Abj>%*V=lo`D)B(;d5+DrM>62x*f?_VlWli z?l~0x1Ct~d%-z9nQ;52#qo_EA@u?ugG>}~k55Sc}Ei$MvaESzyEDV{GK7NFyC+fZ{ zfW;kY19ZY;ghrYZTHl5Fm&MqOOEn%m+_QqA?dA`5T#@XlCokd!kS`|3AUutgsv3}lLxh(hR z%O94XJW?hVOfzC|Zt)&EcH8-JciP;0XG*6R5>ciedFxt}0W%jra$k3in6EL3`Fk!b zh9o*VW~Vh)WEakSkV-0~f4CEq`zlyPiHkYYGA^%D?_zQ9Hpzmv#Enb;!v{`Z-wJW& zaA_=O%)G||5rgb^gwIe=NT|8B6$Ajxj$orQ1!Wd^P6WMSWx2xDtE5oaB`0(K_w>+& z2&KzndM@Y}80xP9i<5P?sd9I|Sfk|33wQ1s-ZU`%da6v8p@7PIBBNP`V^t`NE}YIR zx8r(9=na=N^5UZ=1;0$Doer@X#((Fat=B#8+E553!A-%NCuLZDMZcx5EeWN2F|RSK zPOW{{P>xK=Q;2d4yt5iHbm7$n2>w}oNqi z9&?oY8CJhZ{IIE|$*HzliiEYIoUNkV1?N(vi3Q{uSF3(jF=;X!>8FvyD4?~dXwsKl zlu6t~X-~y3Jc>|A@|(3C$M$pj^nD8pCh!*T-$%ZThKdSCq-4-Op??dH@j!r@eDJGR zaWOIQaY7jt_%uf-eZ;z{xwpFBE@SGUii8dEf|ku8DoH^JhQu8f!qc+N`w<7aTovAW zZY1EH>~87fRB&%rf26MdD93GVG0OI~`fb~)ZjJ$c85?~*$tx;~#)C&(66@W>$bXHb zV*!})Q)=|nV%9_xLc=)4*r=&T?8qaCJ>NqaAwf?3d(;9v{q1_gP$ zk<%JE^TV|jE032UzBSCx)JUS$y!Pvgxk^YYBxgiNqmEqveL3YBnRghu)eAz~oifn^ zz7Z;dS1#+r`R1L535@y+@AaB6vZK={WBI>a03$=g3KTh&m2iA4iI0bhQ3hD2uI_f< z7kAKEflz1~y?qbR2ZkE|m|1iR6}sj63|H20yI*W2u@v~)CM_k64Fci^=BBQ$8Vp4NepOI01(Z4sCubKil)l$1RU7ZPscrvR zSjfVGYU%02*~>qIRwuN_U z>RAFw01TIvO)5v($bfrn1`o6C7ncqaaHeYW!-F&)U6jVA#*Wq_azo2kp#}12IP*qb zpnlIir<(hfc_XfMy;JE&nX|hduZoqH#kR%fhUVU2PP==%a&d~Gx8<6wQe|1W=389` zht=sHeCalsVa^=i@+f)fA2nPgUg8tJo+iqZb~z`u%egd8?EkTD&KvJR0prWOpEm4y`YgxtPJ=^+ezQUS zPbCM`ldf|CJMavA3Q|<8&RH z8*(8&5>7{{yU5U{=~_^-WxeS_9Cnja$0Q?r=D@#VK;Pq~hj01R50TA|;JTm^x^Oqo z(50GgKKH#$*@PWKly|<9;<1l9f09P~#`>yA$@x2NAD_GpWJ|%h_9Vml?zS7TfTd3U zk@qpZ+aq5I?nH0iY+Ht?$=mQ{Tsw!QPcu^I{3htu;@^qNl@$am>CTI7o;l|h={{f| zwTnCk3iJ;jK7j3kof5?e_C*+DWM|VbODidfBaa?Be-PP>jmOaZ;5Yh__E&+9uu+ke ze-20>X9qPB2!>fVP)QLosGR$)g@AJ~ZVz-PN9S zgO*!ISeg$-u7~msqf-=ITyxJeKaF>WrF|~^{L%P6mE=!v@4vRTRSVZ~n{L>sQS*`c ze9X_sKe_LEMA^qC7Z+F49lqQz8<>Mc0lXUEZd7KEZQJmNG0mBYjF`}o*(0n5jZk0Vlas%xv?&{4~a3aV)s_Pr!!7ZO68oKH!D zdQ&q*kXUw8ZN(y@i-{@TKj|I4Jl7ug#>oDqpcvuSd;)92>$*v_+`n$6Uy{50%>Ui= zT(M(UQIR#=x4)?=5rR7L$f0<}w}Vr48V+u(KB()!T;k@oynp`^iHfRf{{v)GpTnph zmJi?vz!e1suV0$H=P*l!&B5KfUxi7+H9r>?Zfdbc06IUP1N)sX$Uqje6o1k-qY9`17?|w6z-qOIppRGzxO+hh70s z#1!DU$oGR&!VXV#F30K0-%q*tyK0)N#j-hl`82 z@5Tjnp=}P&)!ue>;VsIQW-)-+Z_(qlC77R-C9f*;xc8SnkN&fd1R+mGMsDsB8axbP zK5n{VcgE$wEB4$JhHju*T)IT$^{qBiBrG6633wlv^KhH9`}*!U$?lz!BrO6voUtf` zghUR}(1f?YNy+6y9@%%{0|mi1LUoHwyc_R?%$;NDnw0?w!v-dadaVF_H$g#o zgF{w{#fz7Gvgz!?!nfO}sE=io?MUJxIspzJ*#3Y1M3*RU{%B^|d15nfZf~2avWlSd zvVv+&Wg}Ap7Rj-TPPlH*Y)?O-0P%neqqle&IzDlg^B2xrl|?c9+=nOAYBv}nzM*jJ zM+b`15Vo_%jq&HU-N8TsOAPMin3OVBwI&eZ5kcl5Q^-<5cv5 z*R%3ed;mLJTlf|b_&oyQCW5(u!xn@#ZH#13;RB9Oj@JBK5jCPjLx`r3a6A(x>LkqJ z+a#cl5xO(BS@I@9Hh6cD!u1V{0i$!*^W5@y7jJaVm6m#1H2jr?A*cI0QVMeCzbiA3tMZ;& zPWu<;X>AAv_1dG=k=|}kY`5p`T}^4)G#;kyP*Zk%ml}-^Um@u79RUaD4XxB2EoCGw@ zw&r7J-u?L0`H&Yc#wFUwRI@zS)(Q>=ad2@xmlATZYcAW$>l8`O=IPz>&3Q)w9Im{y zHt$UU-gztfRwl{M^ZvqMU9gQ;J!7%c!#})Y{RfDRyeO&}#d0SX3Wh|^p4$$18UE7U zhh=V}GLNb9az#peW~iULxx(~?fnLWWDg2oNxsD4PwI2@oc$QQ(S>)iRYYrZHI>q0q z$RPl34s2We7E*Sw!IX^&)AC0fD^@52RQ4TE++4-E%AIlGTv&iExN;E$v_h-QLEp$o zmIV7b+H!?E?B3qkj__4Um=9}cd^=RVS@-T}8z-a=sdd+~a!g&vKKX`(0Nwj-$?APB zgM``mk&s4isW=C%)9PpyX)9>k0a`h@i}% z^1WI=U$?WjhaOFN4mYicffIK*sz2qg=F`%h)q7iUMbxF9TUkcMj(#BE5YFx#N>#A0)wmQXW1|&0}ovR!~Hw zv%P()H&gr+3o!=7Cr_~$ZHI0`$cjlNwh21eNnIlOQgnzXXKvr@jgam2gyH!agZwWo zHz`Yw3aZJ-{D}&%XDJ$wk8+iAcD}1XZq*?#XLU~UG=^`wmz$+s1HF8FJl}5ci>%v; zy7c9^wOVpEwj`eB_M#^aZvx92>B(1*QvqhG(1tG5N+rYdT?LhSrkePLrWWqoIkCM~ z{ej`nHT$b4j4TF?T$v+l6{<)V$xgaNhK3vRS~iPqJNo_gQ<@gdxT&Y5wX}Sb$IZp< zlzrp_`!1c;;-^nY&=jBQ@)r=`4_+&9Sz7ZNEkDpA_~BcOVzXjz>Em8yZP%fuL-LG1 za3#E>e}hbi;)M(+O_dlspWph3BOu|2{K; zhNX1vmR`_PC8mK9*b1@6vATv^2szF1_>R#LW0|h!O%kSvuwzfJ+nFxGX;VhJdhe;H zXMDlxa>6ksO*C6^y(Jee@L#jy-_I16rhaL@X)Tj7wrp*aiOKYiL5EEkU z|L#+|(_(Srq_}_pAD8*I_dqNQO93hK;KWSW{2!@aIS$kw{~PmycdeAo?WbHw< zy|}eqDp7x_{!6XOmU;NtwW*aQv7=oEGzQL=tC#@dd74h|a2ad+E=5a8eYCH0)=g!Z zj_wGdL5_4~ytusa#ip7flqC9d#Ao|IzZ{*H{06mdzR_07kJK!%i_1#jle|BWs+j9z zR>vgjI5;23e2t>hTu$PrdK3pq^je4KGI#ImQ~@2me7%#8_9~8MwRRdNP08fJzFXv! z!q1;u;kh?i%{MbI?Y1#TO;5I= zUv-6I6<>LzfSJ=_8ipfG)1Mzz2fBu;QU||mXlX38`P(t3Uun6zZaRA3M}BErndzax zOV3cdX9YrIKdz+GUmr(6XnRx_B@t*Pefe~Iy~QW?$)KXDNH!+5{G`j)DH00_j$M1lUGsyX8yxJSKhIT zTqumr?dj_AT?ujv{L=PS@GP(0s2Ynbpo6;aDvNO+F(>+WDOP#10hi2^uIU_VYzA7D<4q!c-DYSK+5B=qIu z$J=B58+Xwfh9^l5qiTg~6}TsCCr_Sax)@>N=tSeZ`FO!9J=|lrW1zmou}Ceg_VYL9RsPAb-Uj&%Eh{YV#6-g)8X#$V?cBzP!sElm_zzrcQm;VSnCSV_I_EkT`R=Z}*CTS*P95Rxv2_ z$+i!Vx3J9HEMPLt)zW@Y_xJB#DYog{n@e2Fji*#k&GZ%}rY4`{x+wfL$;A^m2XoW! zCTZc{zq=F^rM{J|ESo%-5HNoWJF(#${^nP&wl^8Wm4o`JL+(roXP!KJmNG`@hcdNd{FiF>uOhgblVkD&QpiDO+(w_uYZ zepK@58-F$3k6SB-4>sQ9!@C67px>;P!voRLsK+~06b&eLvFyTirAm-#6FP9{!mCBj zvB@U0{^}^Pd8&Pmj*g~Drt9S^j&0x5O60|*B;?#s0zTXE_udW}8$BsZ!bLfOb$3;^ zR+?{KgA@Jhc3uESoMxQgLj5FkVQ`B>2S(0OjpBI&owl^&p3k14_9S;@J-#25Z64u3 z1uSq_@)o&l-Ob`VvRNGYCXARFrRG&()teg>%uV*o!F^?yZZljn{qt4#^9v6*?~JU4 z-kU{59}ZG*nV0+Ua1{P6XZeq>5ZH1A1|o%wYxmsan#aG(PhWIAbNb|Y(t4pCG>2#g zowuA;%67EHM3Bo}z3TG%ChK*coA>V<3*?(Gb~y8nUvAnNQyLccku#}i+(z9w`t#El zJLjH1d3ouYOdW615J@y#z3jPTyerj*lhUm+7xvAkWUl7v1#U4H&%F0^QD{xG%dkp4 zlcw=Y^#IKQhCA1UScUknExkV)kbA7ljmo4lPrP*dmXwmss9I@kLLq#>j6RPpMju-> z8hkaR)vo1kxtF?i_Us>EYVR>(U~O>U8(~)CmK5jE-j$5={>v6+DiOhn1qitsO_cQACQ=n zvwWw%&fotvFE7SETl2y>n1Sfd&eWHn>^*Sj&yuUz*!nwiM|IuIESeMFzK+zqsr>Qd zBp0{W|3+F^EcS47`?L0qxRa4VftIU;dQIBt>f{Kc!=~r^_kBPV5D|nOkDQ!5lvC@( zJ{!#+-=I22`~2LNlt&^ZQ>(e-_hY?@t%T*_D^*vUG+JezU9N3@J7Hxgp6FK_mFtzu zXl8CPG&rrIGEo zOJjq!X@Oq%?izMX<#g(2$$~6fPMonh+6eXpZ;RF2URH- zcJpP6-N{zzmBQ64bDtmFHjSPx{2`E`@~cZ!)T>B|i|+z!WbGBjNHL>py+=4$3O=P3 z%ZhSG2|2D;HRtB$I#>Ev)xVp{HVW_vS*$ivLYUmMa_K0U%W^+A#n^S^49;#pl<=Se zi$+(#f}wNdkK^KKMwU@~TYKg3Sf$+R@{E0|j)+5F8Jt!K_*IGX#MBhQTNFqYY97#7 z2+rbseEN*kzK3AW3(0Z+oI`(AA<<%%UG>eFW|@5|`z{=6?)52EQ&kpzB)rV{zWDz4 z6Do4QFvApn{CP;D`pwZdnQB|_x}x8IprSd(aO}wPruOSu@;iKvHY#!|{p?A88oVzu zH>WR4NSu?kxhCsaxp82!^K{B_#fYwG{rA)~$7uf5|;zy0d+Cx;9xO1))C7)z}`BxPaL zQ!#@(qO9=Ryll>41ELOtd-wWIQH4Jw#Lv{#QHnJC?Ew%3dOyHWIBD>@y1HVBi|di> zuE2|Px7D)i`Biy?k-jovvT)(8^qkt-8ce2QV%FDVT)`=ag9!>j-6Ds;moG2DrYbeH z1Ti9zG$me~$lB?l3`m8QH4c-C76}(!SYw2QT!$F^x6)xFh`_;%x&`9t%J5G8>c&RC z**kiAi^aO1KgA_}uX`XSwyRk81PQ7G zz7eA+&C*Gl2~Iuhq|+REFjUmgxCq|Gm5#=S22ghiX?jrK!LtzpK0rl);gJ7WR@R3( z%PTdDV4Zn5Q%Aje_3WQz7|TBh{dxsvU{p_cFb*2RQVnDOEjS}ggq?*)q=H8Kks}bAu!dc;nfl@A`>YqOg zpr3=H5$_Km3^+TOD{|-sRaC4XgT@p-=G75jc0ActRaaMxK}QJQNdV^5jsO&Zn6kjy z$_hM`FIn&Vo>kerBNj(US~$bJ4fw2@8MQq4 z96o!?VSCFNR>NTN!2IQ-y}|>WM&hT1Z(;KtH3 z?U~yp6JuMYm;JGEcqe>idWPcI_iB)%i(4$0j_g?x*7fPQ!kB}ABf$!VpvJucsOcK10hN4$whr`*H#0X6LYjErmkN69ao*QTe(GRGR1 zQWRt3$Wi-e*!;-R5n6Xv*l@t^7)&a88qLk%8cY2L80olZ>VQ zKR&NMaY$Nt-4eIqy1*Palei)R;mxRO*TyyhRtk@s}uR2Z8P3O#PbTn z1M3G5%I;HISA6?s1CtIgnt=XyYj1ZI|5aLT&-xx0D_9_?)z+4AjhM&N($qZJ^7NUN zh}RJ<%DQPEV;@3kkCOXpmDQTaO9V?;ov*~M2W2VvFs@J$fz5~~Dk3}_a$GfKWnZO( zJe;^=gm1`p`h)|E=#yYCo01vUJ$V{G(}uZprhNpm%P!)m&ZLzR`9rS;oA7zjFKf=NgkEiF3&Xq&r4-?zNjA zt%!+Vs|Z=S?vZ-jgIs}PkH;P=LX?bEpp_zp;?om5Z~EjIkKHG}4GM|mjA!mZ=Z!R7 z#mkD#Gn08~-|=7JBl`($Ub`T0q4ILMMyR^gf)zKneLoIVG(_n{u0-9w2z6O zQmTG_llvSSm&iJ;cuQR??n(OlmV+t1n!D+V2%a19g820yY5|z7;045aR!uy~9{Qp7 z_V(KFbGs&<49aV0@Si?SGC}i10i86ZXq#qlh+q%|#>a`|q$RFvt`;8@U_8aA%_hS7 z=AkGbyOf5Cp@*2Pm#m}Nl5&gk;RCdkt~7RID{ij@T-)A2^{PeoppSLiet@kI5ndc)txx6rz3d!AbS;*OG*VGT!p z5qLSNn>Pog^;&G%Hg1Njn>DwSMB|*duk7BC+yKnIkU4Mhjx2v|qvQQ6S|U$^jZ+y7 z>}S=rwS5S?11q z;;UDyRCG^+l5n2uTuNMqmWzMU+e}fb5Dg`JrMnM55a2A@7xF$z`mYZkSi7ku1s;OG zWq{vv36kd4mVx+zrIpFQU4IIUhh!cZtSXH~!IQL(k)iG9_}`=%-Ga2_)WN>~E26(U zx*mU>DzD1nh!l%FAc=8yrp`T^rM`BSuz;7TCdrl$wM(;-vL@Dijxuy@ZVwroTl{UG zFwbhXi|Lf{co`X7yz0bv^~}|~KmJ$LCZRkbU^kX>3vhJp+1WvwJ+nIiL*-4BZ)%!Y-m9$mVs~!kcNrj-(;Tcx z(Wzg`TRc{RUPY?Ae(&Wo4bl&uaBWKK%=Op&`#v`ImVxQPRnE+WLNDS<1U@j*({*$; z`BL3|TOp|PP+0ObJAYzEiB*}tW)$y{6WdWbqUxV5<4@CAiq!Irf%mJ)#E zwPUu0*QU>Mvny@CtgEi??CAPj-D6(xcV1j9_ki~8i&j6ot?cjrRy)ie8oi#J`ca0& zYQW)&ESl&QekV04>bZ`#A8qlT@!x1Emm^>NP9v=K5)RV`3swh8KPDzikbIXZKUvAP zX)`E`#crS#*Tl(QFaK_TnwYTt=-XLxX2x%OTHd4_p*v!dWO_i-X{Xb-lB1>3liEkP z7`bR^TQwh2veB=2lMsdXV|;n%Hz|=`6&Rm9=gvVgu!`#HotPu)CRmw(E+i%@YHeea zaZTVA$Q0O*X$~L0izyZLo;}ZSY8EA{tVcD2BI10bF+}5KZkJv`m0|N069#FtI`Ukh z9cDS|Zxg}~hKWi&-YY`us3oT*ujUgJ!JGIx)04O4kglpSZ@QPug7|+3!tVb;5E3um z{d2$I@#eTV*Or33!qM{w_xmU0<%Ma!TS@RL^=ddM>#D4}RTv5z;V^ffT^V;jC=pxBz1mSdrX`-04eA7}l#C=-+#S z`b+EEwAdWUiwUkSn}a#sl#U)R-=`nHKw)blNt$SI^%g1Xp|}R}Xtzk7EZ#i+Z4qkO zYjXa6!D6EoEdAAXPU{Qn^%oY;%Tb&@-QX{EE%azIp2h%IYVmZbM+T2H>{|NXd?^nD z;Bi3dHg&1S>$I;GKkH8Y7{R`@X6ZkC3$)>{MPK7R**vHi#Uj@vZ4GRPX1@0xkhHll zl4+IRE$r&E82f$oyG~?e>9YS{>TNa&B_+%2pLbFpQ-Jx9f)x-G_-X}I{&OOL;#ejM z@+L1Yf(a3<;%2%E2FAz7r>9F$CxEJn>DJvyi>n8ysjGkdC==d8CmzohpSR1>vaP_X zZ|Q{oNp24A;lGX^%1TNC0NeJ;$V}9>kVI#fXUEjZ(lm$An@q%f{V*9hNqbi3IpIkj zogB07_?9PFs%bXt`+@tJ_b+b|%4xcX6;(xhOad(Ts;R2*^6*j#gmnKuyX}^lGsLvS zh8n{-xRR zqv@yjX?cUQl|0CqsHK{vZq%x={UFCSP~+ucs>8{rxm=g$y929by~7y(isW|Q3DdFo zGWqC5^P%RrXK^>8bT(%!&DOjYe}6a#rz--VmFBdJ^Nu^o@CSm3fN*gvdG&s;E{;(n zBMro~7K!+Hh(X~Jgi;&gRQ<>yS{S{-8V8RPf!WIQ6IcddR)w?;c0}Byw7AB4fz>7Yi@5qh5)ag-mvvus z%sJeBy^Hz!|8LMmXsV652=z7EaMClRS)&6vHNG$Cusci7$(_*9T6G^}0| z@F@agu?U~@vIY^}!8vO7@;A)PCTA7DoCJ>pvW-)0OEVh;E3zkI;3On&Y&c=ij#zMz zLXxvKb`t8Ptr*mYu!qFevxSSvtpaOwhbTs9VsZoqo-4-j%EHAD1|r(pN5wX`xhhEv z{Mlm=8bqDx8M!SNK4;0HYOAo-fm#!!Tg8k`+;rGeMv8O68)%! znFs377d}3)~)!L(BiYP`Dpd>(0^4P1cgw6W6Sx+Z00GesWG#(Jcjl3`gH*e6cBX}?h_2|=3x|~ zDf`Chy~Q<8^uS<-Gu?JvIWu?&^H**FGGawj%Gzq3-UF2xbvr8#{cF$|(3KMt6BB-a z&jKt9Bn)t!KAmqpbQRv-Zf;-jO;BPXFx=lZTh1L+5rju>XliT2C3Q1=;aBpY0)e?; zvsylc>jK9mY67W-jP3@(1XIBoo5pM+PE;yM=m#*oLIZ>y5rYRv4g0_542KJ_|lD-q;&Pv+&CKLK(K&oB;dHC0s*A|QR} zh6T;$=4Jq+h#gq<01FT({RMuPKYs$4CdCiCY;P}c;ewM3n z2vq}t`r=E4Y668+o@MVA+=MY!0G|f_6+l!H6BF++QaHlI25=ChEp({ow*~^l85kMy zP2XK~T$}$>ZTa0r)9ZvT?j)e8VeH*(Y_Mj-&7%GfYB)wq3kwDc0^vxHH##m(2Xz{> zIt~u`@M(nl1sx)iqLIdi72h%jGZe~8lJfF}1m{W)jpH629UY+8LkkSu4J=n>;Fka! z4tzM{`}Z*zW;^`vS-c3ZGQuf1fI$NO6gbB|pm3Nm2L}Ekh%t(b6%`Z)2M6CqMk1H= z$Af7ZG-m&Zq#WRVczi<)94cz;$OMguWcqG)q9+W82Y;7Zqkwddv5VL>or%V#K1`UK|SshYnr>@Y{k6hER*t zUAP8bGm1JVr+*%ZJT(7(iMy|3kjVq;6A{lL5;8J4;|Pn3yW+?7^z`5RzP>2rv^B{}NPye$&o7V87R){{jD>`RfI-6<1y@lBj)8=wUQi+ZUk2Ad z`ZYO0Ktz;T_Veeye*l*614v@T9-lvT%4qC&9MDt{y|Av{-4!v#5e4)U?n3~FAOqnc zkU5W&;Zy$GCeA(k_6HtpZ-Rp%4p^x?<_b@vTg8tN4nsR}D~3f(Y!mYWEQS9r7dhc_ z{n!bLETIN^VukvCbYcQlu&L3}88Fj@Hs{pIlX{FMG+-mn&d+}ZI!P41^52idXauZS ztY#u2Yi2e!JDfGJ&;v}ei^ziK&SR3d`-a3GSV4Pi#N-&TXzQ!vQGeW)3OgW)c)!xF=eK&5KCy^0jNz z*hgUm2IL7^gJ7%1b|8rWf#{AN)&F;KX8-%7KuCCi5fPQ+% z3o#Nx7qRIh+x6~0Ba{F6i=csH3Bm7x`sh&@0E1Dja`Wa3+qZ$F`}P5a?n=M2>;IE( z;e0_3c?zs~3JZ@CKJBFzAg;j0AP5cP)Kb;e4X}Ot2bwTgRw4`l$HIS}DbERO?$TjR zEiHH)LM=gf!zW$vdc|g(o1M*xMzue!5v^=cWaQMJKW_htuRuP=h0rrFxJ)3%;HHp( z6b4xsIu*hO9k_hk*wnPMz$O9UGr_J!3s=t<5*d8T9fYOE6OE+4z1S&GXsxaJ&jt|& z*Dt96n1Ni}ABaaChWyMP5CI^u7{KK-DrAO=z|jNS8Yrv~!(lT)WD`N#i;y|h8(`Gq z!7f3vZbOpVF;R!~)YQ7l$`OHMaC;;={NH`}w?e&TZroUgv=QFS2$G}EwE|PJq+~0Q zMHZVh1TN{e6Nn5!?k>#k4@knh1fL8`Uxow^9;|G`gM#L16eH>C4RX|w?xE>pB!?tunY%DfvybrSntOt?ejd((sLC!BM0ILKrSqi!<0Lr}V z>_sjc)EKyi2Q5#YIH9Ve<41*N9^l#1;-asQ5B0%=Czjf2-7Wz;hw42aDEIrz1RWni z&<+LTQ$IiRQK2^cLB%n+HDPM(>|Ffp+4qK{X^TmMV14rN@PMif#U4KnYoILXhM>bH zTrA-H-sV{4>$#`S+QXEz3v^tV)}jzaz$s{3a;5*v>(S9=i^+q?D8tI2s2BH-5Z$3{UgiKg;q0BsP}g*dc6~Jc3A9 z#7#yKjm_^QJd#D^<>UY-7Z}wuVX5Nl>wbRd?QlyPNid8w75`T~HJ^1Wi|c_&N8sK0H47Yqg?=%gg@e1rF46GzF0$pZ$ND5Rx{W3V3do`N=w_@JEJTn#m~PSmkmo9hVcxMGBB zgc4v)hZiQM?!@~Kmd3w--h4z*>5x}uW1@^>17S^1q zn8$>|2@sn!6G-seZz_j(6-d5z$^ zw5IBy(kQeV@I;-8Z;blCaxos(o&AYMlVNaSV^j}AMzV?fdOI=3 z#B|}mh`s{|$ytDxaw??Fc+9XrwZgap265>9R0=Lbz!eu~@c8i>ZUp??W#g3a z5yIV79t})=aVLg}agwD8`PwnZWk#ju?aCb+HdBnRqkTV+{GwtmJRKtK$R7J4`?8~{ z+!?-(UR8F|SIHkF+s_!Va#Z!$Z)^$n_HOp1S$(6ebPzwb`De0qgu$x5bHu66XT+#D zRnjlU@@Li0$c4}J2ah(kbtQlL^mw3xqKMi}!o%JDpDSxw!`Ir{Vw?yVm*R_%k@>(U znGoKnqXYR-HRkBWJzUQuk(Z8%BH-+6SFb*$$H`893N~VbG^9gF0LScK8-{oTA3r93 z`s8qmK9?q5pJUbHcem&&GREva_867~_q6sfT1TwVQ@I=DLMU>(1mKGTCzZS7i zg(din0sE$wmc7Kp>uYO}B~DCCK$hq--julOE%t^oA{by1?IR}_#l9zGnRZz|tUuCV zcgD?}7w+zjUpmmeci@nl5hODDXTa-8K``-rONd~_G!!Q;{QY|2cIV|K3uz=yOPpn4 zVYJMP@Xy6+MdbyfPmJ1eAc5zK5yGEt?0`g@P%7{GgLWF1P1|nDgTAtiFGF*2NG%sw z7l4`1pM`s@h~D)n5}LyeT)0#y*Wr)}92kzo7>DAW@GAs#(6Nv(U%W^-_!FT45fXw6 z8*)zRXMW(vtkH%DtucB_cVs`xiU=LHqopm%@>TBCx%K;^Q(N{U8*9~rEJd02yWM*T zPn<6Z6zE{SMV-fAEu)IvL;-P1gk$368||plF~*0~ZkGhDr*GNW<))=^_h=El(=$U6 zM0j}u5?M?GYkL>*{?v@D;LyM?BGu2?*tv=;TI{yncFk691o}os78ev8nL)Qscs+61 zy+36!4T{2!2>QYle+S|nKU#kM`J88uoE@AWb9FmsC5}^_rXhVM>6WTa#2kVyDiXe@3m>g~AApVYo0m7q+?=V%Ve2H@=hs)>UN8-fiDB{WN83U~ZcO+x*w09! zGlC8hzRS1N?G;X)F4lIQ|P})n6I5ZTjc(v^iC`M^!LsjCprd$-Q6%+M@3yU zJ~T8v;@G#yj%$ql>Wj~%_qXSXx+Mgi2-y(F6YX_EO_38HL&6cI^ z**ND~{`IlVrPum{@0CyE(Ln-t!TR}!ZgH$VR%H#x*||1H)niBVt*{<jK&(Y@%S zbZVQQn_W>?kuZ8@dLuO?EEF2oYn$gz5;>DF-8^E9%n`r^->O^Fl+@L~i;&S>m6@Br zP4*ci=i-L0qUh)yc=+)e#i*HF`yx}=8Ih#p%1KAJ!qP&*Uj3?@TUcc1`zm{bdB^Df z*@mj;z+r7B+cLfx)tiiu_eJg@7rWKfb*PC?by2} z=+9$&>iDdY*2s5zF0o=`J|jEV8^!s;LBD?!cVDy2%hATshU}QnuAGDq*+*8z;Qn<4 zVxE=V(<-8D!^1V4oTJ-OQ<_@sgEWU8l2c?fJBf-a&}un3Y?plKPw9;~T7cO8Mdu%guSDt!cfP>@A2-mGkg>p&@ z56#$+Tw3yHLPX9bGa}UR#v9pEhrs2(6W7(7VK}(c&25dgqh3pErl;r+5D@*lyf4D? z-i$E#Uc%xUM0*U3X|Cu|Owu9Jh50U}cQyCe)>@*(amF=rA zLm3gg`EzqHk*m<|ulnm#!=juI(2^0fQ2x8w3Su zr9)<>r0zy{ip{Ty*V#SSgh+OJL9fO~C8x*PZDuwE@EVeowUF z;lUEsG*SEI7a=1T-1K|?g!O{-{0%geGhYk;2pKmch%{2P=-Xx`G9^@6&tZrgd z46Mu>2Z5N%_J2&f9M7CaRtg?HGV=`F-{9jOgxXb~$t_AOh5Z)vmah0I$NBA$h~UBR`dU(1=wISFjxmS&f%Xc{plgRt8Kl;Ku2F_+=1kvsj=~4 z?aemZLzBRc+~uOASWe7i#Ypi%T=*{e&!Yo6w)oNBQJ0F4u%tf4r7EXiqLl7ZIFVbQ ztc6j${U6)%TkNXzT*T#$KMoOIY&qOeEioewa~DXId-}8S_!y{sFv_F6va*N6ux^#L zIiL8WHI@zQng(D3Q$8PW-LlotfMZ&Q-Q3jh&&(QOK+zNS=A7c+YrTrU?%v_aJu#M*PrYCc zKAaKUt*{lMke8M;hCO^Bw8r^lck>{6aWS=3LSewb*t)!7C@gII#}Cr)Q^Jixuo&>{ z1|)5OnOQMQ(c8zD(*2S4+-|jt+8-_Fii>en6u@yh&u)pH{cJ0# za&)^YU$Qyg*65DYs%R4vFPJRyF0Wtsy;OvX3Xxu-Wph6D7#Ek2y9dr=Y?+wn@u6WQ zx`V6Cv)Tg}cN`og#l%;-Rum0TSt5y!RtMAkq!ew~P)#?ycXub0g|fJnFj)0g2Ud)X zAbaQ^9Nds$yV+OJ94x1w(@_e_y~nFK5k-;bmSlhNg6H1-^3R)2wS!qw9UW5-?&qHj zcyjD7c7dvNqp58Im>4*D?=h{;&;*_?579ROMYik@ckWOpkJ?Kt@}fZp-kOAI7z58I z5KW7AZS5kVBWG5GiHXL>j%+?hwi10C2c)lG$tkD?=8DFe6w>J0T=mlo_R}LZ?|frv z$;W;4h(%4>{}87K4tApKoE*O;=eCYhb-DcUMr=oyJLFb0WIehi zHWHsd3!ik;Ep`24(D>TfLgHIn{s0K-3nl9)Z9H}_A60Re+a0robEzly z)5-e3j)AwQ2J2hYYs-A-sIcQ=VqiPjEp4J|kgm#9JD+_1IBZ6pYId=bQkIEVt_WiqSU#EVBIyQb@@cCo3>Nzfhe04A^j*fmxck~pUUYd8J?5~nu z(~}Wi@w8}LYYmCiAJ<7O6$RU~G$v)I@v=BJEI*UK=5r6ld9@+31@NC#2Ukl z>xl%|PU*gRbf_xmd}yLXL*r)OjJ2?^@;qrm&^8xL$JHj>xNV`#Qu`|T`Y`!{P{sL) zOZ`Z{&cq)yJz+|@EaG<|mvJ11act#pse@k<-$QiD1 zL4i(anylr&#epG_fxiAVs}Qrrm7>UqcZ6)&6CX=E8t*uUo2Es}{tUlTmy>%7^D-1A z#Z~^kzv8N({j`@NSs+@>zBQ7#zf~i6`xQh5lmPsifadQ4_G5=q&hG-RVM6{X)$-R< z6Z-W4V)A4z<1oMRI4^YnBOH4};#d z)PudH8V3?a5>}7V2YCvGpFcmy$?hWMmH|(QrT%*vDOu4`G2?O?@j=00YCv?j?sBy# zt!ixCH)c!U*-0=u8cUMi|BYivN`zX3>)wiI-E)L` zfA`+LU4c%jQmmQi-ITQ0oa*7>kf&NoEfw=#L2ct>mAYL=#K>Q>ZuocJIlMBw3P;$uB7~GBi`{?;Fk6V?CIR%d9MNKNOb#WoHl6iHs1~kguY2wc(-Y zMPHmRl(k(g0%ApPS2EV;uoAZK0`_O8Alhrr{`Wid?b{I!4!%lRC*sF1Eeb2Y{E%xA z^b7sDTr|2ncV-J+1^>0z$gxZ}W4WU=q(BCjqJM67a>AHjj}??B73 zG6K<`FJg7u;|59bCn26&Qzr`fN1$O zFqljrHfCXFJ~;7Zs!agPC(xhh=&UuB0CD6WK0aE7Ev5S%_dEEPYLjZ3dYtMaIOJ4h zR95v97w#2~S}rcr@ACAhQ}cpDMlZH8NhnW$pU20iyc`^~5}8^KfB>z1IOl-F+2J}H zppN}9&ch?2kXG&O)7AAiqULYRE3%KvA8P^v9EgciGWt(`lb?!+v>g7?O-`kyrBg6? zq;d6^{-zTa_G!udl;C2Km519HZ+Ck+*(_ zN7&v%nNJy896t5jocsC2Q21O|1=j>XjFbN4M7ql<{M|+^yQw)K1x>F;`+`4DLr+VK zSjTdB1oKCNG0o1A$>XI0Hq z{q6g7c3Xxh?ouFqN?YyMKT#Y&ZE0`Q?sh?ZrD1GL2bPU$Cl_nOLkEPA`wx%&qj1N0 zuQFL-Vc8HyKfs6bcwqrV=_rYf$9C^fQ303pbKsOd8nLoc!pEhkLVPHin;pWtqdK=r zCnuCd?%~^)jsl))72nJz%dxh*bC=zPP@wpNbY-1alzfNKTToKzX!Ya)%pO#rldM}^ zwGZV~EG)Tx9Nr`hbc5DoK_s}kv`I)#TsRf9S_B8f$Ll?LLXnh&lDPc?!)VaWB_aSD z8p-+{Bc;}=`YNMAmIf8BBoCK5_joK*v**deX6iP^03`K(3ZLs{pR&303m1_IdtrVa z{Lgq`Zv(&%6kiexX1d1^Ub)#~=YcgCG~J@2q5wL*Y=*mVs~XxHT*E`7t22w&gw-lh zG{{5&)U6@n;mHH!UO#`opP?aZt973dmZ7}v_LUPMr{dNvc}z^$VZVcOD}k zA@-W0=G^p;kPC6!)fb35zvL^OAhM2UW#!F1p>%iRWhDK##$%ldZQ-$YgYLKBkHe-= zJQ2svVR8@MUOVZfW7qkKUdogB--VIN?@_LDXo3>*w^h#kmAW+&x)QM-ktb(ltyzpp zUz^1krKMz*I_}}<yaEa zHJCS5bGUOrPWCM$@?RG*x2Di)9eNv);JpLPz^`?G|5{BCJmYe^Lf4H8B^+vRla@F; zwpemea;yUV}^T%nq4Sb+uBVnCw-ke>*{u> zXlZ$E_b->jQdCa}?!t?w5+)`TCzcN$8jU%Sm zA1H|#tokOFHgQZA6l|O#A55kSpa}J0{e>V$F{`2g#R~?uXXNC(PZpG5$i(5a+@H|D zLn?y~#%K6l4rnP}6lEvtmXAtJa9TR?Fg$xo*N@&`(26HbOzhs1c|gd;unGmf*ZO@_ z?idf~)-<%bXVt4te+E4xr!O{au`wcd>!KLV>K}Z32jL+)wEC<^H-82NX?@iHXsbE* z6JG%jZ{~{QIB(T>O{2!{f{aW|LBaAMYEU$g=<%va0v(eWhKDpw2i&anbrUEGa*<9W zjX+r*oSgSEv!?g@$zD^3q}ZQ;z6R&&EXA)3j6GqZ2;CM)-YjEIT^7g~{Va?*~ zyjAa%`PURZ*Yzvd4fse%%nMmEy8k{@v3zTwLa$y4MdwPlhvFblg3=t+F71$4MB#fv z`46h{@EycNsUAV^Ze?L1g-g|XOpE53GF~C-e%0R};c=ooglmK#ssYKzb^s!Vz*8os zp4!@1Dk@Q2$PSkKl%R2NmlJg6Wk*Mcx~u?*$_L5aMf?fAL-{ihx?**%>~}p-hc~$G zMErx}n8*Rj#XxH`wWA}L)%+DhrC<_9Z{&7=MT`tJ52VeFHbN#`U2?gsM0t%N+h2z(;~BYUFiGEg$>v;=^|H{zC10@igE5dKpb zkQPMfDNZO3MAO3u{A%EoQ~24~tMA9>(+Z zl5!&>_`QM}{ZRk#*}H>wx)H~uS)t0gFIoZqv8G2EypOP`AoSD#7eV0cL;dhDp`$kt zTK&*0>f>{9XK5h|iVLomn>L4C5JWptQgRc7i8pOxcsH~F zv{usK?n#aZFVUE4_oepNk4#5F`UTC>N#)v_>ngOeiVEcI%JS*~@HJ@L+M%9Ie`Y|& z%S3f8Ie_A%KA3YM=H4Z$cC!0MRLO`ltvQ(b&Jwq?xS$w{c2J8v2Ho6gcuLBX?sR#m z6G3tIM_|j7IQd^t#0~~BCpat50PB@cVRz0mJ}vF6H3acfHcY2%(#Qt4&fnqyhYu?S zW8<~liHe7~cyO;~Ku`EVMzjQk#j!*`G+GkC|E;$MdU=5)oR5|lLZd_6Y{M|=`Pq-k z1G@`;i6VEOdDPXOfed81_t%chtyiS!d0DD|G{1%hSu8h*!sqlV?^)_A#t*#yT*&_? zp66N@QJZk>x&$r!&RjJ#-))A|oT%lFmzGLnsw1l1rEG5^jFVWVmi%OmC5`jYPj_1- zU-m{)QDh7|E_(@4oy6|FjM3HPvz$TdKWLT^dGHRG=b=-Nrzaj$4V{at)Ug z6p_8)Z^Kl!R6?ALNMp%vzJfVB`&Lq}-hOl)Ch@CYe^Fl4240^r{qW8w-ICzLz&nAn zj|E){ZZM#0y?@{HXdOyJb_V!~PqEwTVz~kC%5F091^NnUAg2I!f29IK4>HP~Zxbb^ z7AuA#sj2(J=RZTmYpzkaf)MTOmd$Gw3<(IWqvx$nt}Qwp%nTZL=L+lc*>W!J&L7Uv zE2@Ib$HDdkp9|mPgN!@36K$hyg%agHY^Z%RN2<6RchBW&gmK_%rNtnNJ+In+L0^Md zo9sz*)l|_)oQ;fYOo4MrSD+aW&x|F@ykNR{`qVGmy0|0W{*Zo!Ntfgr(|Aa8J{U6a zh#Z5e(78gj$CwF6r$$FtxAOBzAvhVF%(d8-mCq6zP;t4QA6E>U!!wPCkAK})xdY@p zlTV*53|Ce!EsEiJ!6Z*aAA#rYX7XYa@d?hd76_R}MziZA8)khK9nfo&<_fwf>dW)YnSP zm7)mt+4WV4Ql+96%P;t?#o8iEVE((oqjtO7`LnRH@V~$}b<6@3+7tk!(tf0UyZ(0Q zLa6YGFs%`-b+I-7t}9>|Y+ni(Nxhfa|Gg}E#E_Amp=k*Q3Ai~0KY~M(auqaGHMWbt zz$k}`VpPz|7J88yaZ+x{rtzW@ryi$GfU5(utISnLR~tLCX4DRgbdCyPOdGxc> z6TCFjXl`-e!Feb2LFl6&h9XCLW;X4y$`_1!E~`&FpW0}p*6o@d24Vt|;zVHVLA~78 zuzb;KRjO%4qMF>L>3b7OV&QCITcN~v!}|j%8CwcXPnuwSi_0SeK7*dNjzOaW(fYu@ z7ZdQiE1!Q?@Yzm`k532aFhLXYl^p1D4i7=778b>%)!Ub1*MhGMwT^lF#dVPWgH-DJ z)(KIEfJh5Ap;yL|?QM4->{(ha(Z82FI6JzBtCR-fkgf68u9!L8x4w!VZpNCMF_1mA zTNwX?0283xr8^&CxIze+i_v5>lnl=%`%&Kwqy>Z$5Mtr@hjNlk*jXJkKF*c@0OhB_ znoP70Jn32qUoqOmWNZs8wRE*1TA({oFRXWI%IwU-6lmRPJ=;*5tD#|HVSe-UCu^4( zxvd}-jo4-GIj^APO9qOenJ;rmgT9Z{n<_XfBA+wc@9p|0cukke{%OV$IqUjk)q%Cc zjSg@bo?-i}eZMf`G6FOKTHW8;GUw2cxmzv5#?SM4 z2L=cJMf_XmsYax;v>Mbk@odIvhWT!tlxBuvwPr?+^8OGxmA9*oUAW{zCX_DS^w`8S z4(qGT%;G!a-G6$um{G`0R@}wsY>DE<9Ge|_V*(7;Pky2FS|ky7;~hQS6qg9W5McZk z&tJZ?Y%zEGB>>l3t^Sq2uIYiFCqFtjehf{kN5MSb368w)4S!C`@6Ot>8U` zjrkS$T0}#$)3JZC`P2h~zd2%qBawhWji;0U8{?r%>~OKq}tT z!_3kqwQIk|=?PG{Se_aBmEj49{eLS%8}zRH&@PoZJJbq^G&DpUp@wla(8dK9j{cl9 zrF%kmu+h)%-m`|AH*pwe!!upxGPl7KgRQ^vni#ZKDb$^!)4)ROi zys2t&zwqUWl8)@>BTti$&M%|L*l$&Y3Zp2is9NlN;;lFW{3&$^YL`Nr=Cn|Mp3a*v1cb+n9PD#_j}m~m*y9fr*!=$;`|4Y#h2sV z+jiU5e`fp5cZmC7@mF7r#C}C_940z7$gV6KKZ?oQ;+)#Zin+~zbk5wsuVuzN#MY-6 z#eDa=a$eN55MN+&Wf#Wvg!45E@+3Di$1C%IuYzP4XvAEgngRqsIlA{2dK@3G>7rDn zAgV018%1rl#AU_*z-0V&8$SRM={s~r$lb5C3_ruLy9XkKVQc|ke7nJN+F!5 zE1pBk)NrtGJUca|SgU;ze>lH4qg*_Fn&gTCNA+y*tn3!=gu|z`QrW3$U8jFG zBllE2WkN6oq&lSFJQ@JA7pB^&{AH+*krMu&??oAD1of>4&eW)CVeVw-r;q)HVNJfL7 zNP@JRsMM@D#<=L?%A2vA0H}r9c@pq_25-^oWz6$o4VB*pG}P2U$D4^od58n!{wU;H zT3VHE2uulFXw>{7Z?h|l{J6v^f*{~5AR)5C>3xLph-+rkrGBD{iJyNvbC?CIU#V3b zs?Y!@N0NhC6f6bvO+8?M@TKT?YiI-IRmY(H;-@bv)mJS&144QZ0`g=g>6yv3v#Gu6 zv}#$@Dy+{9d(9i^+#6GDaXK8k3)#~nTM8u?4Xzk^rJtJ=DQLio;Vx!pE z8iZA!%f!lhaEi*8U=BMgOe#t+_x3`vU~6F=%c>wPuWVoP^-?$GS<(1O>aL+isHf_MD)C2X!`RE%4-ceu1;u>e^~q+0`Y6 zA%}U~QY5S16J%i_VN3yZvnew}!(QeW$u(Yz+D?_39!LUW0un|SVV$?s)t?CSU#AUt zk091e`9(qnx%wy+I#RR3j-G6N?s;q^A0d#aSz0}!w$HI*F_+u|bdYG7mnSio_Nj+8 zr`mK<3U-7SLp#%oCVTEmI}?8f&09WK4gQJ_WFY=iw6)H++5rQa3XU?whdkLFg3riV z;zmRO3?qP&LX$H1cJOKE@t(%+A|vw-=NuLm$;El^o}Nv6#@@<{KfiKi1X_=@Q}87Q zaPt~HTIATZ<}^i7pp-1{DAKcPZTS&)u)9+a4|!}1^P(-&=ulAZoo60T^o`fIHTz(+ z$+T`3ohERoG4ruZ&U~Q+rKiC1!5+uz!dkELZ8wbZw{y)PiS6rSp`~2~@qUnOyuI2d znjkUP$%j_p{cZ8h&#yWz?(B~xNFtptX-$|Og4o&HtH@Or4?z$h?*`6lYS7JGzS{B~ zWMu*QpkZlLn;$+@HC2alqghNd0Ve=`PN=RvJ8gQvO01^C`WrmY4C?AY<&^(g1ez%e zYxXD5Y|mhQr>y)>ozF3q%Xp|La>KI%G^llRmZwW_?VtlodbbHPu>L_c4$&T8K$WF# z16s8Iq7C=(dV~&NxB<4Uyo>yMxe*Uy!WXE;>m8==ptu#2`nSIA(IuImh8L-$FLF+? z>Tu40MnQ|i(fJeV$0?7$lH7Xe_mKC3jP`}d^3SUkXTliCYTWMMG2M)fSG=X$j5UrO z9YFFz9b*^b_x-F=VA@OIJGx2`&&sD_S@hQ6>Le{U!7Cy2u;Vwwl992|l4@sKS6csk z0Qbr&Rr*Ti)KLu$^XoTqLS$?_Y-|)^<=%edcaTIp2*b^ucE4RDX?l{y6lCKGD~Mi8 zentTRME^KkyU)xj?c(y&Kbcy0dLE+-+Y7T=4`2wamK%Y&D+CEI)QnW*6)jrU*cAVM zG?-vp3zt>jRH^7UIr$dyKg2H@o`2cm4yzGh`+CZs6@ z!ZO_3-366CjB-G<^Uw(Lt8MIbj^_W-0yN&obJ0)F>LWZka&|Af~nwIb1 zD_`Ar#QCqAd_!I-QFwrFh4VkD3uBgDSv%7_vzvEy3(c(Zmpx)VqTrSXTxv5zvmiWvvnfto2tZKC zYcQ^8Y~zGHDou<{3Hc2x=jw7su;`r5ooH7T3RFD2BD}7C8{1ZH@}Ss}QPEmm4t=+7 zHvY#sf+27&aGTbstw;;|Ew!&#;L3}ohn@JZ@1e&{I3At~e^3w%$&c_l7_zqPSR!*5 zc@R*&GrQs(b3yKLq*!X(aV;ept*U+p zKpMb;hc;MnaExkg-ZE9#wxg^Y22wfK0b6S%H9u{!!qn1aW#~wU_IR4_?4fK zZ_B*3nrVD;Mm3`1_qvHNMxF zb;eq^O7kY`8KzPkNc(p9tl353$<7eF5s(=BCjV0cCj<7GKsE7l@ z3V;uX8rg3E)qrpb3@K-clPh2t2*%W4SbGPJr4g(VfiYfbcZKG`d)xVAz^YVMlP~PZ zfwn)$pHWkLzyu()q`I@vl>hHq7%Zzchw-(tYW%J#l}_o-0R#46bsDbc7G+q@;fK~~ zn)9Cu##-9z9hbU@E8YA!zJC1*6}7&;tMi&`DdQK+#LCJK)q+!w*ZqetSM?Sdzv8?n zVQ>9Dbsw&C4?F7@5W1GRsi$85Y;v-0M?}{d@3k-!VZRH8ZE4%-NbVpxpY3<iA zYT1^#gYf0%jlT#ao*(Z3%iuOhw%~=QDAXuiQpPnMdmCY!@meCvbRHTW4g+rfgzRYl z{S17C%bU8$_R+}HQF(@8+Gc#)Fr>15Y8f6+99*X1f%XUBOPwjPWp@+9SHOe#sTMr+vKz!Dc zi+pWil?(#REzCRAp zM1ezJhBI4N*icwjY4(ad79`=2e2|3UO#aG2SXRBBZh7bmlE!V=Tyn#5PnHmu11@rr z`qL_ixtu0jqA!yy-n=1aX8s0aI^P8Lk4WtX+nrW|t5-LnK==iS9XyDv4GrOe%dN}B zs#eYaHVl-7x6fL5DtVytP@>P)VoOwGwe<**vSH9uDI;r8akwj0`HMx3b2Ie zm5EyD_};I13=BG-0IWFxchbIck#RZ5qfLPVdnDc%Jq4z?#_t*m_h#AP%`);sJl_Pb zA6~~bajqFq38AOOr*E9v`4a&dMIou)Y_N9axs%$$tuJWDBM{zP!k}OngPM=+eS}jr zNlQx`hw_rd*MT?(1A9Gk$ZH3yDk^h&dZd$8YoIu~@Y=QsY%MA&S}&T~2fy=LPjnAa z|2clXSu_NMtDqjd#P}!t86gWz3-w9$#7Yk;u1wC8cXr!$iE;1Vy#v?YMeJDa&08Og zQ3%wjQp8UAD4-2Kr|=QsaQ9KSW`oZ=n*AUp>7g#}PV1!iZZ)9xFGzGGYlCb509!H(>?>;vpj zgs1I}s6N`(T3xilv2Gzy9WxHkOE)%zDnaw&!0;fa`9z8KM7f(;8m0g@`g%ObcEH(& zmF!FTJJ73@w1<_J6!CZmJi<6_5M&k<5EMi_%mGp2)m=~@U#P!PQf1_okNSQPPu|ex z^0Rntr6;H#GmUrc$gaYkPFkr$qBX#g#I|G2uEKiPa+g2FdDLbkqo0>(pW|m-%ez7YtR0CEqkydq^i8RzA8YIC zPH1_?|8_vxqE_xHwm#0Hxkfei;u4)yWM8K+Z)ot({fffRITus$UpP59vK4bH=NCoP zzT+@NZiP(R_~>BoEyZa6!zIk+0_NqlrDM?JhybF!m)9>d!opnQT{WF zH8_fAVPz#$#R|jC5FR)TuZu(#~!EGHHk9M z{rP>UdbLyr03?pm9Xs{eax^|wbCoDc%9B)QnJcHZt)B^AWY_V5`NHD6O=QNA&B7-^ ze^C-WPLWJ#2GGc!nJZN5yB?tKG#L?jZ7T4%$$<+ME%C8 zt2v)ga~9UivUFPsUtdfC3Y|;*6b&J%B8VQ#GkwAX(aHB|DUwn%jYbAQH+1*UA3%BT z?#AvTAR|nB#m7TB&seFjUwc%!g=Kn5sKS+avqf*yMroWw zosyh5{f=tqDDzp z2V1MBqYext4DLNSbfsQGv$4w+vDZG*O?UahYT#qIM4OnlUO>G$(85?EzoFj!hGArC zW|(e#e6Sb3YBKnpt}|yTCnPvDDk(FYTUJ!qnN}eEX$=bbi`OpcF8XI`@=bZ;3)9q^ zWH*Oz2Q>Ck?%mtjwK-Da!&?R8m?Eky&e*&-u%c{e zSTi#IV{cbp;}u||^TrPoOAsYAJkC%2lo5zB$x}?@o(Y7nmpTfI*S^Lt5kbFRH_{+iKE0mW5 z?9d)Ausl~wD^aPTqGvXZ_up%(Ah}&1Yh^S?&?HSA5={`z@#o5=FfVa)i;rN0&nA|M1P~m#lL~TM#jcn&e@drWi5t)2_=qG zXZY0pmpiY&-r9mtsHX-7=8)7lMX(hV`sZ!8BwVrQh;_+c3=A(hoeITLg|z0?Q-aQO zZgw&Cce<$OH2%xX+>!vU3>KZ{kGH^Olj*Wk z{udF>=xu7s&@zhdR@!OLh`#l7Y=Mr!&N2G^bAm4ly|U28J>R~(*dalixQ~E`06l#e zxNJ2KerJ$18=KPo77PQsF9I-<6NtO;jLWYLp!-~m{FasGorq$j)7HK?;T`X5l%OUnH4 zbHNEc42`0@-y$+jkT9U_E1CKO5GOW4w?^E(8DJfa)jUDGL!UHVG@09e=?tgy-7Q7ItMiUSgumFI5 zU({k?ZwVwaP%uQ=^ZR#s6+PVDZ`oF4$T~1s(26S?@6y$#ZfY#(oUHc|TB?$MOVAC1 zPu~{!r|#?R4KhV0re(Kqd~}unJHzt=?1SgXy(P1l25zxjRvP82L(`v~yWX z_rJQIb;ah-B5Z41E(z#4RIE!@W4>Ev2>UmdHGc8E#jx}tf4I;l?R1Lu-- zA`zRfvmpbR5kw)!(CXboP!@w?aRlR>B#=Z>zEv}wsOv-qJ6q7?Kri$1n$c1F zDJ&=mGH`d>`(MzZ*a}0coyJ2>Qc{iAlUPQ;>!;5lSX(7QMsA2-M8KPevH~b3cu-Ir zFt!o4o@+Lehy3*{B{Ir1FL$}%dN}hU{RSZtlaiK3_w*@b<?Y5`CyDe$(*?ZgKFnxoubK(h``MI&c zKsQo&mS>2O&mY_bmWp7R4#S@yLj_I7r-tTc9Bgde8jHCSR5sDW^_(maZ(!n8Lb-SD z?5Oi#-(J+;ConLc$DZYSP;R0aC_|mmY@{PaSEF~YwD!K;Wr~1|#!R*3;9zg~XlueV zFv{(^3x&`y1Vxp6cC%^XJJa3(A>Uu=2NbvL=G1UB*T$qjp%8`NTjWtmke2Pl{DJ30 zMOmfV$Isn`2;m_;*far#9Hc?R3?Ie}=FWfb(@c89_1>p8yl`5J=XZhWLO>zR5+hsP zzQ&zu(J?avRZk8mJy5rBrBloXqC#O|k=4J&HXuBPu^kgrklPjrs{)EXC}2Q-!9(&x7#32 zN~^>@u#(Z;PiS(th4Xru5hiDWVr!R%;C7dkbPA>+iHV3{IG=>i3EJ2800p>3PR_=r zSo!M~(GRhCpLl-NsA!0J7V!z=IX(+P69ef7qGo#k(2$I@_`&uz2TVnp=8Zw!&8V~Y zO+;pGQ^R-L7fkM<7f;VH;)#fHF4$H#j_=I@o8o>f2NLHshs7fYC zOalVsYHd;j9g&pmTFN^z@^3o;$s<|gaZMCK^!tDB0h$1vGd&}t`!z3-_-{Tu4JL|# zDF$MtathD}($o7x8V!TO0&;Snz~h_Sb`w^Ow6rw9v(hs&jXr*?<;KO+hW#I~6#tp} z%{-nHym2dQQ26|r848&2Wn*ag4@?Lr|TD<+)a=#2;-+Pdkmr$Sj_+Z&BLcRUp>*?-5v5LKtlYV zw@SEqD-U=p8mR~}DD>H0vo7FO`{kxQf(QJmuQb4lzJB3-z4>V~(5u4i^Y#C~e$}Eh z{`YX`KpFtjc#vS-d@|Fww@ONL#MeLcU;4q1iHQkUkBJG&O<%t5AMfZ?Qo>)pLb-mb zXK28|VSJqEX6{(zKHNu1Nl6g*Ak_YC0wfjpz3WXVKR7-<9*{k{x-c*d|CI7Bh$P?z z84duDP(6T4{*Vrat>KNXy~DYob9VDWzPU1V@cE~wr`t*||NB!B>YK|F7#0?`y!_El z`)1-33XL#00pDj1#2kQp*jwsu`QP^;y?Gx}bP}Ej*!^LP{qMfP+a2G4!Xn6m!~hrp zke&a%_S0}&z?!9?rcNy^?7RL2$^QFvgmI=B07)X8HUk77nkn!Y2j2WrnhEd(LmeIz zV+c%-#3d#7c6MfBXEkoFJsJ%ALaQ7ISui~S|9UzhGjkOf4!=bFuYX-lU8n%h%*_Eo z0!n&Sz=HwLzgjGqPDc=7Ip#${z|({ptexoc9Y0Xp;h_Pa1lYz*_Pe@J(){mrtvZ4k zmSGS@0nz(img3C+Ja=DMZ?0Emettd>RTx!E9{%s|5>q!q4X?gp-MbnY|*8qZoVm8eEz5{eRG%Wtlopps#Ud^E3U>3(i6PR@YC9t6XFK*bP zVB|UnhU7CdPd6vHVdi{%yv8;D>(^V&VQ^)xUt}=GiwA_V^~tKS?(RUC2e))jPSJfa zAiO~1{|HlSZc*D^l=U)Dh^k~@SSdU#T-UYDK141^;|m7#EBktSM)I}XqN52b8C>p^ z!}kPBshK{+|K1zUgiqn|@#aQGQ7|MeEh877p;Mv$;Wf$BTep}I-IG?Na*u#$7hQcY zW_5Kui<6>01Dm~;7Ecck06ESOV?2xjSvc$&AdJ0E`C#eY2C_gopr!!*V)^M4RPjN2 zTacd*0Di#2*cj}=Ssn+76e!tmRBysv@bdTH0OYvqhu_1OBVl};n=m2{cU$4@+kMjh zsK`iot|}@jDBS^=1$aU@>`%|1I{O|{9Ewe`<du~~L^I^Ir%$|!Ua z*x8S2k=L-cd+x^punFwmyazc%e0}M;T^&+dIMXex;>*6BO9WcBcg2&?Gk9P?OA&0} z^q~qr`KPva=kO4CCY9CId^gMH3;7)E!r&z02P(Ms_WpqZ05R$TFu|6Mh~4Z%;3nHu zyW+&aTFJv?_TVwh}8hMNnl4=^m^LY5E{ zO9;3Rnqte(pCO%kucRbu5aNr=NI*#F>*Yn}4j5g~*usPrFbLr_ipnIqR0Ovlh!t=Q zj*hBuwVdjxf_fyELHmexKj|wRG9zsOxOw*UX@YYv)kT#xKj(#Fzu_<`qeYE+50roa z`V6xOS|Mk7^TOaZ_Pz<7Vr)lA(d zYd9D|-3&Nbh>xxx=1llBOaX`+M&^M>!)87yRZ$OTX9l__T*0x9j_f&^zq*A$)&a;L zSbPCg7sxh-;eBQc8ylwJj2jcfqtpBl5I^wCf-UZ)+I)wKA8?B)DJdNt&+cJSAD^DujNbzS2f*vT z-F=Wh`WgThjow&rm;zV=J_h0(7_Z?ryXG_jlm*^OD+EfDb{D7CmX;vD23{tw13+$8 zqts%-&RtV;8r}(bpSs|sN=|OE*n!Wrl$cf0!+&p|@xL1dc2Og301%7kbu6i@gkIb} z>CsTVAGc|@n4&Mfr9qiFEZm2FyY{+n86oNytmKM2ym{vgDIWIT( z;CHUViPdXdCV)Ni;>BAHjd&?)ZZ0l3`oM0upg&YqRTa3Sa68+Znhq8_37wrU;1Yq< zn!+7qA>c|3)D>tyLg;1B!a^mQ7zd-LlX>kE$? zwPf2Ln|5|Wkqedw(A4ZaZ(r2!xvo#x@(fOqB5%7HTk56!h|TU0`WZ?r@8ctpl#wKq zk|;1Lfho*F`>ZecuhG*djww`+Q~plQ9kpCsZVnHZJ$3Rs+Uq6VT(aa_+T&U}byvoj<(8yC`Lg+fuezX^%X8LNcT4_#b7l;QR$3MR#1=< zfPPM`4Gnt-Yf1vV;B582)Qors8w(4|+W~1R46Y~~A@F=)1*d*6 z80<%I2*R#2Kd(Crm4{O}Mkhmme0;DXLv}%p_N+mU&~CDv+*l&@-w8H>7}h3!Er;%%FY-%uq-H;CX~|4je^*XboHz^bx7s zup59vh$Qsrq(T~4WHIsa{KwRR&zL%xM+1ntMexEDRjPs`ExCp)sL4_di6n67f^+Wx zqIVA%e1L3k;KIanfwK<+b$DW%xUQ1~rfO4|8G~g369w*xUnk?6&ps3uR6w=>HJ|#@ zAt5S0jM<&zO6Jk?G!xK{P&Aq$o;Ks8P!JQV9lbV&tA%im576D& zaSSlsByoKAGc2sIpdhFW+J*RmEm$>?H0m!0XpGLXmjKTPoI1C>WJgsmi0i@G6J>1{ z)A{l|GCDf?Qy8r6`~aPJ_+xl1;i!iW3iw*xBTU5z0B$V2hKGldk&qx+msC-Khqt_C z0rnO+k|DNeluZj>AHy3l2`G2>>ve%pGX4U}g6i z0Cr%vH2Z|t20fO|6wq5loSc8)K_!19Dfys)4Xi2XRZGCi;^?1}`%AJ4S<(m=Jqf6c zk;N<4&U}Jlt12j4!Zkgb<5hojvvOZiU0*~zBSdg`x_+EUgHTYW?xR<;!GbONZ@~tR z6G|lH=3c_0f;96{SdP4s5+egcHCzK&Y7l)QnNS;6xt898stg3NEnEAa?4oqu0zd}C z1w$kKt7-5(St$ix&S?UHWmuPPCyYdXl)xW>q|L1fFo;m;6C8<628tm(MG!zX%f4LgOai zBP0r7?^Hevjb}_;T!5M8WMqgRzc16Q`xZ*41SdWiK7kWEEbIM02i`EA2&VT=PL=S~ z%y=awX{)M6!|wrcB|Pd#>obtR1F#GNSGdoyfq{AOzma*@lL$xyA{n37c9@8 zTIr~;CiWrupPi*JaRXrgWYr#Ww!hWqz8vb_T-XO*S2t0fdQi9n#0>HFZQ!3m)c{if zCMRK)fWBq|x9!P0hSQd_<*I@KUctYz49M=K_)0Hya1QRJsi$Y0Qrpp-PfWT z0cfWJ0`-udLTm?Rw`;2Y_Vnx7dSo6R9@x;UT%q*pbg~NtUjqQQy1NT$Y9@GkB3n## zZa%qt=Z=HPG2~=z_4R$fNWdZws?!jm!^*h4Y$na8=iNL5RM52pX#yGenm`^5OzxiE zUd%24`2zbDoDul#ckhrVCnR7H5R7s&adV$RM$Be9YBm2i2x#P0@X;W12MIPrh;VK~ z`ZBYyun~*p*`Tbd>gn!I#AW5hr27jNBnAcs43fsgbTl`orl*UQs{(ozjG;-0h+-ll zj;HGo;f#QLH;b0d`@`&k_y3-9%d4wEQpF`Cl$4P%Gcp3FkdyNhOEk2AOQSUl?wgZN zr~-x`kxp;|mkE6^-T*UKUOwpOPb!nlZy3-dl8}-b=?CFt% zFEiOK;*YEA=2Wh~4pT*K?d`B-5w<@_fd=K(>4C43h*9hmxEn}0-C|RfU@e3>LFB#s zCnd1#AOpP~ZAW84gS4l6WQ2o;Mij7DYtJd$ySrtjrA=!umVjdF2gMgE9yEXJANO*h zti{04)z|lEjm#C=4Zo_YAOVZo-i2idNk7oJ@#n+)bQIsc3x#zEBe)P012!4&y}kXP z^z>y2wY&n!f-vy#fOPu|D7N?OkkE$~@!mnoi9)VI{f3UyZAO6o`@Nu;C8XwG{b{mm zD=X5+%%CPh(0l~rt8e7x;jqz$&Iug)P}%=eR@Qtrf-2zk{rh!n2ua&}&X4bDu4wV-k3G%%y>TUf){A*7);^sG`<{(H~K&8Jq=679`(X0or8Wc1%YN;|X z3MzuDT~L7ECqt(*bp2F#axkgGUDhI=F|UK_q~deYt)ibk9+M3bxbz7M>U`*abe$i=%kKr=BA#!x zu3jnIOZK-Hmto;ky^{eLMwvMmVbQ}Em>z}a^}$8^dH?=&S|2pJ`Orz@|NB=yET_I* zT~%CMLug%8dUd?LW2He47{1Vs=K1f-<9rAt9T zT4|)Eq@^1a7NvyJNC~1yOP7RngLHRycYODH_c`as$6)NShYD-XC-1moDKQL3_ESjK zh0fSe-&yuTQLv?|h*OKax4Z)pD$>STbDv;&A{L+M%rWCs5i9Kv)M$ome>At*Kc3()1q8LB2etaCd|9o-Zn@ z2+f-WtdmEr99&$5r7fh8^Jk28lyn9X*s$f9z-zZ|=f7;|=8)?zJ-ecFBC@sE5z9Qfb_ zPVKax7ark7i~if%k4SbmHd6YzYfYEsA0%r<#E8aW1{*(Qhkqx5@k&`m?yaS%uqy(m zJVVRRRa@ zau7rM0Y@3&H{fIizm@+Uonz>4KdBM^?!S8gYLvCGnCn6KvqI>-&0po|9@)(0hbb*N zsH#2wQg_l83m;!3rUAe%YQxUXj*aIhxZ&~%w8HR^Z;6P5#5#!90ymxY%H1TI{oeW$ zbeyp$DJV(*`CUFWCS9{m91!%P5h1!_`ojetAae~)573e`%C>yL^Luw6G;;C1<|^u4IINN4WN z6W8Y0R6ZXsHiE&I>qVW%^;B$}uMjWYyeXOQ^N9yJ-9Fy7HBl%jDRvbWU(wdPdc2(a z8T%$(L}@7q3z>o9GYm~m(SHwu(tHCBkJy!;^5%K@9iAs~UqUx0tIu;MoehX-9@?-K z_*+m4IWxwwLiLMT-dj|`bxC-i;4<0_gd@E#(sL`~qg`$%SBI;KncO=xs`hLwdGc#@ zXJ_sZ^gw`d2Lr?4|E)67&rVo&g$;2aDG$v=Aa+TePlrz% z-8Jk|v{MLOYzZ?+GKuqg;YQ}vIC*7ABj841WR~IhFn{+bk%>QxLcQUbyfgHZG)Cy9 z{vHVCLtzwvra68gtYDb|_e?zmt8o7mbo<@_>nFG=A;bWCU}I=_K+Opx+L>5@>zkFe zTGEOe=en}88Xp&T>*Vyuj~~zgMELs`C_dSxrK5uk5W<5uWX8VCY;0klJ{1)gH^S;P zum{~_=yZ@;PRoKb#ZDRQ54pa+2khvboSa(@AvfQP?SRBFjAfkn8Z*g1`b&;Km8Zi2NWhq!1`l@G^7GD#Ou(+r7jp@Fu4xl?V^^dWW1|dPiK6 zBSjL&HBai9n=;fFpcpa&A;{aS+V31F4RcVqSkFaz+Vz~Dlfid;E>=zSc>LE1Uvb5u<_@uk zME}&34WLzXbFB>w-a9?yNQIIfvRB4DthMFhLeY`U|KSy{%5$2Nt;zGUZGP(_RdVSh29Mpd?*@jTUel zasvqNPC#7W+ndSU*1GA{D?>6fDob1IOEqO*9H?eCfVYz`bVNu6>;YkdTPlXqumT-0 zShs|zHxENYA$w?MyTh2-nxPbHZiy^G&Nc+@guh0i3xKtS?4}4UJyZOk!uPtu{n!q7 zwc;RTr}yFGA89#HsA+fI91#`%{+~bh#`kA{0y((t2A~WPP^cjmyT-J&VeJ-lbN!7@ z|4~*}ntFXKK*SE2NL*YTC^190Mi&OfL8!q9R+9R1Vsm_3)k(ZEEQ9Wa!KD#=JjlhE z6BF|t8~scmwN3Y&az6PpfyzoWk8<;Cd)s&G!ll;est-#08Fzvp#b)8*gtb~HNJzv2 zN2*$5$!#tQDWe4Adg>#jn0cv8-3(M^{6T5X-MwD^1&5A>r6uT77+6_Uwsm8J6|^VL z7o6jRT0BT9$gq4=ZgSLsUmHk&J9I@Igm-9_II;7vk_`pdop2`wUe`7Ken80?FK{Z` z-VW!vCOxVjY6IzaS>QPW*n={PFh%urkr~QbIqmHF`*T2khqr<<8F(N0^@00>VM739 zcdS~4x8RfK-nqEgnp;o$&gcP2TS18fa4^=cHC#?4bk}%o16z9b`_*#<~pi;Ka(z z4)waz4k9?rNC8!nR(7n{WAMqk8<@~Yhp8L$_%TMqIoV&dWYu!o!5E=K2rP${U%tF|I5sro^OLE# ztPJo=;%c(5gQBf#8aJ|I*>F3G%Szmm_%qq4nL9<*Ec3)0yAlxdfK)|y-uz1aLlbLsa2U(cQM<~`=@BOF&w%uS zwz(PHn~p{0s1}k)_#E++oFv4?}mGmsU>?FxsY3Nxr-9>z|1Ru4jrE;1tG44@Z8eo$8$ z$}Qr0U1YLrF(OKOPt5-A-3;#GU#sy$IVBrwYhXXOLI_veQ+4&2-(Q}~Ni-+~R%6v> z!@d6O$wgj`&Gv|@b|4OfxL<`_*xWZtBfuC${W1)O87_K?oIR65d<7HGg`#4s~~> zx4%J0yLotUhzj9sGZBSP;CfUmfEhXQ+|@Zs*I$K2?Z_>zfPlQ?qPF7lI!j7L-QaPny#scjXGIv2yD3A4#OwMqoQW-VP`0R z+lB|r^4HG0xlGY>4$)0kcCp&YDG}Nd9{AoL9IPBRtsJ8g6QZZ*F4DeC4WOfQbiCHm z`t3psXE8%(heu?DI79E?pwqm?Orcsf)HBcJy6GQ3reed!{DR6+v*GNR*xpoRIDIF_ z@BN)mH^fYOMrWd8*#Y|H@AR;jooJ~p0P59H-4_3~w$68bnBX1l2}UL58i$ADgILLxVuZ2Vij&6CJAXQq#3AVwaQC)0OU->BC7k zS>pv`!*KGscl?On!1{VnAx=6^0txi?D$mSGrhQQXH~#~X!iuR-lO=h$#Ad!i7ow0wSTA{;wqdPkr7To zA>t(~Z_k??+-5pning|P=|5p*d(0%Zy^|T~iHce#AyKl#q{Hv_(Xq8J0*{pTM?f+g z7VD;?4V$I5_RYv2G+{1$1`;74EM{Z)x30An=EOe9q`jzS*u%6L+O_h)Oy#0SJWY8}PA zjX%Z5p#1pm-~9re$OX~(!zm`$X{;y%d;`{5zS(kge!zl71~o@$t|ML!#^ztG^ZB^n zy#9RVkTR97t7j=e#0rif_mL$eOjIKsY3ab|=x9LHky#zx2^fa}9szbU@-p>!>DxZ) zuIOk4gkBi;v1mzqT+bg;7QfVp)wJ&xZp#0GlmHz?!hLDgqRG_=mKG@yOAJhS;jKL? zEUb)_-61%*v5%A8Q&NS`&6DZZlq?sspFR-vDVv+ZU8R(M)kmiMJN2Ack^h7N~M z-89ZK!`b_ik`f9SD0S_#)Y1ez2a9Uwmzq~Q&Cy0jmV*!DZ#OUdi(bE7e0QMG7nw8M zhLM`#B2jYdjEt06f$+Ot)5Ee!Z*A`p_BTxQW&Mvt^vfo1>Kz`R6355QZ|Tw3L16OzA5G^nU1b3S%_d}-H4hNKgS58hm{ZWj5 z>`Iafxwcltqf=YO+1jJS`538STZHKK9gRR7l0mfev(g>_?V(OWYiwNVHV-1dkGSEV;0psP`-?7HjD7D);{Lresm?5S+E`lh2R1T2mK5 zHvXuTmsr$WoHu*%Cq}9^CFKhH*IKK=th35W`upUiPxz)WYenLu1u%-S9u^c9_@f0z z`7nDtBC{@-a<7y7$-%9m(a@-j(hLVheJp!2yH;7!^LVp=F3-~!VsplUI3gw8!@!oY z;CPl5644Ankg&z0*P@1YmKjFl*dCKd$MOoKCq4r{GF^2~c&Xw}xOSiMpF7&jj@nau zP1{-AT~|G9Vn#*HdqZ~X04qS?cVk^0;91E4V}rnf!gi=!`CD<4S+)O*1u(^R7{VN) zB@6i&-o%+q_(xtnU2)0r?p=jlYe}4`d`?J6`eL^~+pzT){y{m(!yzEz>Qhi5-a#q;h|e%V zP)$K@UH!$2PCxWe(48gb^vk>z*pU0WY|H&=-C_2P&YYaefZI2DZv=j~A~UMOyoqw= zustPWs_q=`cG-O;fl;6^f2O`SyPlD(WKKJAe}BY07>7!ps6abUmj8;5(R3V-)(ba) zwr@kaEbkZi+LMN3WA+jn27S6$Fn}|dBtt^55Q@XpF5^Dr?YSG9+LKFdpaZuv|bin4gjMqA9 z9fMKhd{=Ic@5M#wwjCZWq5d1wQm>@Jzw_j2MP_^hgVkP#XIDw%Q_r4J&CLaL$m@`j zf&Lu+au56UQP=rFd_#s}H{OGO&L_efD~uz5_xo z7KUHN>5U^$C`!4uG&g_sjMC6o-lwICL|NkO0XbefBp*5^Keo^bSvf_W4p#bG!r{(8 z-;j_JP7VxbU)Obq9=K2VQhZ+Nh;hN0S<`(i-APf8dz1rawAn*i?n%OKZTl0=WaD-_ zF3czoI`#?K)1#lXz7Vx6ZQe3?)h)!#dgsQM(fXprrC)EEEm+dhe!k*+vcB>5c*LAo zdqRrkf|Aduw&dSOg)o~N$ClaIiUO~{c^BAtz3~PU;=9%tnQe}LL zW296`OaZeNWmQ@l@Tg|)?h&l;@RC!RKl)C1);X;|du4-2sn7bBM@WLpLPdL>yCCsQ zYE87~^MnhdU(WB`lEuG=9p}OlKkd6+-_7qa(v!eJ}~q~EF5 zJBn<5h?@qvR2QUOuul<6h9<{COO9`13DEiSlM>2)71xiODPi045`2k>)&9m~KwEwD zcr^0z%V!M`Vg`$kAK$*s%uK)J5HyH0lJ6ZD00i?60MUR@0Cq{@zbY?0j>C7qi2)uB zq-M2Ctv+bopZ`={?FIc+D=U_bJk;h#*&s^o=?QCb<7i97scjj8ZN<`YQJ=HM_GY>a z4wrEoY4L)hni@jIBq)2Ujha>`wV?vGh%>XY=cqtepW-yB{>-^u&6Py7+j z8=Gv=0#}G09SsLoVQ);Ac?FWzJ_R_{3su$dE_EF7N0pTC{vp-FV{;iOd zRW-6>_g|$5&;XoK?Jr)XJl{ZtfOV(vpZ#(e*an6veUl)bpVM{X# zYug+iU78))P;uIG4%EMv(@2>rGTgsXQQEpC)7q25>u39dU`upXsoZ$fy{Z>;Yt%M* z|IVk#a;DDhsHn6t`-CKDI?$PV@TFMGU5|1lKKH@9%THP>?>ZbazTxgV|MA}%E22$$ z-zjFN8^6mlnx^cjWu5E`baEVQ={9B>h!6QrP9>H3jF8jXgA2E|?I73O4Qpx-2&S5#FU07N3VT$^^reSoB4bTZUuY#yWKU{$i# z8_yeQ9$9YG(o81gt{?P0y?DH;#b!I1yagf;ot{KH5r?10wE1`UJV@i~J3U3~YR_@a z%0!)msp#p42m1R5`qzu@T>7VHWK0)YF30E!p}Ub%;-?&K-~0NP&p0@K6py=z+yw5w z?T_c)VaImua&O{A&tHNLzMk?MpT9Wb{G?l(fuicSH}!H{Xm8arF+Fi~6^@P0af&0<^jLGam0h9 zUp&`Sqi1LRQHv^)EU;PQ%!P)QcYZ4#V38w1Gh1@5R0yISm6!Phv2DG*$wr$SD-7*2 zvB?>Lisi2m6A-N5Rek@t{0Z63E2oQwo4j&kArQUd1}loe+?{=q64yhz*Mhg_zGS6Ln-de^h~x9i zB3_zFqwl`LywWNH<8Ydoi`_#9HAw*JBWI!tlJ{mNzSqLp;hP~50`_qJ+O z3cUjDHP}QN?hfAxufeai1qGf@_kzpalQnBxyz%^H;9@1wo+#bcd9khXzt@+yb~Z_; znK(Mm$r)JR zvXho*tfgP_#<*F*dAC1H$ygZ9S6TtH_>$^#Y)mpri+bKP@wvV{jkGB`F)hw;m+UW8 z-0Q00-`>{KkyYOb>OA_Bl$f$RCZ*K;JZ2uzAc)PThvwvk_#6~<;hn!8-jL50nc!X5 zJmQ+m8a&8}Bd#5aUvi5fwq0K2t&z^|<|pZw#U=fm^_f^6A}0u1tI*~6?nQ6MexJn{ zpJCoLnE6?;eKUfHa#?wGyL_`=zn9Y{_Wj5C`c}U!s0nzmXnA<3MpOKb&7u#sWH`vb zD8w0N0WchzEP(odJ(k+leiOw3l%;{u0Sy98bmC`F*!>K8?y<489>;7Od0a$*vo6yroj_SJ{G8!rFFF>((hJX=qI*LSCT$e!!co#9h!F$mcWCed;^{Z` z9>vZRK$|o=IXl1B)a>o=M@K{ZOY|Q<=_y8%bKz-R>}cPBW>lnipPfj`?{Y*U-cV}Y zbCd?4l;OI})O}d)NeT4#=j8n2F8ct5!_`*5+P-j-uDh)%kRtRtXKT8qA<)di)|SHG z1ArTGH26FZlP1gBjJppS5@Pr71}9?#uh#frq4?Z$24DM-9H7AgC{ABrUrS31y2%{R zJlUCroM&e2F&#=XHwcxI=w5Mgv2;$EO2}Au{W3uUYi`SEwPsbSZ#@ad*_7`24d19i=o0P{0&+f#g{}y502-m*=0f@GOoNl4i ziLI@#Luy}MQQ_t32^I1>W5??wGLHl-49fb(7=e!;%TgL2BO>PJy2~q;mlpsj+^L~4 zzP;7m)}>I6Xk<+L2!drBlL-+QITe{QIT67vvM+R z;v%|Wy@~jo=|v)*y%*q>S?74Y%rPyFHv@DobDSA96W4PEwLKFzj= zaJvBD0vG^b-}wW`6EO01KKKhuU9VJWY8HSS7$G9yaB>=E<1x3iCX4@u90nAL+sNfQtE#0{;kc>{$nE|N8EgAH zC`4pY$h!i#eY-(H<(gyJ8jZ2~a&pWyK7H|DhmiP9CS{gT zHPjB-lb_?VLk6Qw$0xGzXZ`b9@ZG(wFPo$D7|aTGB8HWNS=`?nP=i{y^=Wa> zo}1BiJ>oIccWGH_4!Cm9sja_U&A)%gJZ0j}*f)Bk#{p#nI0~NcEbByXMMAd)sLTqW z#t3S%IO1>|0ZAQ}9?Cc33vja;AY>2jF&Pou=x*y2o?kku%36a&QHalGju4Mar!wjw zbBjeGrb$v-LrtCc$rGBUrrCGp#GZe?k1;{RK^NLQ9^JR75N|$>!?$n)vC3yV_ttRV5mM<-$zE1Y{o9-kWk$@W+;!OK8hHD5 z^)hW5jlSBfPHD90Xc{PI+FSlQ_Pf;&bVm#}9p%+!=IvY`7j#FJpO^5cjdcv&%OwBK zqg{#ak&@&*;QXVTt7C_68v9)8`E7Tefy<)8lBhJMR3-AI;mza9UEb5+Y0ixQ4VD+x zWT15}>#TRiabkt@zS?S9H1C!Wi+#Ud#EJyRXdM!n=L#qwICM?T%%*|H6&+2m&W|Gh z?ju#2r|ZvoHa56Bg&$BPGY*J-gJeldD*=EUT3UDH4fTHJ`r+WkM%=@wjmAG#HH>L} zqsGJQT>AM}6{1@8MdF%G3wg%xZw6fgdw1)L(!Ou$-l=M9XyNPI1YkoS1bFDA(nNa0 z?BoydBK6AFF_Pn>6MTO5uh!+Ke2gHUpz`{N*z0>^CqRpBqi6A2&~DVtqg#)f^nUeB zcWdK@Bi(1ePqFc#4c%Q;OD-m!16XtZM_aKcrXRn2flixajCPHT=f(N({rb$f9m~RY z%nbXzdnL(RHm(%0iB)x0{Wg%5KvFL>;V!!V0jgUJ{$?GyQ#E2&Q(ZTzY&iVUHyW&^ z|J-^`OT423sY+CGhIhj`f3PSiC7E^Z1KYC3U_(;&9ZVD=6}SR{2D-oUj~Ylh$TNPo znyHn-SjLP`!;EiuOInqO2cVkgEECg=4JLF@IsqZB)(-s|J1+)9@TPC%RESsBR}uN@ za-kZ>n*USIQy?iYCE9Pl|6HdpBS=ti%*pJkNP$L#x_`1$Uyh~Bjcn{HlS5ivM#3Wb z>-$!#`qBU5eI|=X^>U8{-6=U&?^mpj(OKe%F91N8`#MRNU%%x<_@ldJo)S$Vqj2J0e6A)pZ0sPo5=IY5LCxpz-=7lKf1g{h5mp_D-1KF%6qS%X=TP>z(&q5m)5dtNHJ?g{~aFKg;{s8NT^OzZuJG^Xj)h4wutBEy>z8_(>{n@KWe^C7>nXnLT;APC1vtBP2G@vuomQYO#E+QQ9r*^)szZRdzRpN6VrA zO7}~pOZXRtiDd@<-9%E1} zNgC|Oqt8N4P8)Nul~X+P*W`kIqx;!^1OF zD-)LbtxjQnNg5+}%I-1kkzU+(W;=xmYnMV3+Gwl}S+YpjN(v4qCi6H@n9gTy)WwpAEC_X1CU2&V&YiBOZwN9 zR)j;0)_B$S?_S(AI;(Id8n#V$+BNIFNP>OWvPXLt;W}FT=<^CE zp1%k6WMGu)K!>$6S&_ccPmX!-U)#w=!=@cQ^#1}I02euL7k8Z%&CGChopYB+rS&#< z%N-OH_w(KPKwwo+QyaZ`Lj^iL0ET(=e;{)sb#qF&uS>Ejist7^zUaOolefB0MP<#( zs{Q(PkTihi0kCihXm4N|0v`)->mal@h=>#QssvD9V4$?Jav#w3ft%4)Uq83L zE@U~xa_3GkP<3D-WE2+St;eCrD?`aKp40Dqk5jn^17+uU&C(k^$!WC4nk2+7L<`_{ zC`5(8!v;7RVDurltRsv=38^W|*Ap1Ed9b`N*Kk~5H2uBmm6XLA6>0x~$zmS54y|zM zX-RR^{hoo#o~6NT^gnC16Mvi);=I=M)wyE+_4ix8eG7C51~#@W0Neni3+@a;Hl3OI zc@<@4!FMxHL3HJq{u!*u(2pP27J?rH-*3dASQwLNYtaNr^jwx9j@vrFQ%H^8ED-`~sfVZV>L?qGlhQ9q03Zv&{P!4ysdUA)XzUspFNH+K^p(eGhl zLFyoSJeUF}dPYV@S$F$d+~wCfU#py=_g~=5_MZ)j=9pI5eKpK}emcABn#1+}#@~QK zU}(Lyu;Ak007lgdd3jPxhaE_le%jej1$+78>(Q$T6019^)BKn24*JeQUR{v;@O0M-k>K;x(`!U-jWG=F~?DOS_l zd}8H_hr~wBzrRB(aHd!%vkZI{*wJv6bKm{9vnQT zO3Tjf=xsEn$*c4^53BCFjlePN?%=W}tKUs8=cjhUGCazR;C3y|(NZ}&wA{7?Bpmj} z7%e?RRD29?y45@xToM2{1ON#Teqj}YY6g&b++1BjlwfPBdK`Q%ot~o5A!#}DMb z6ggo83KTgz$HvEA5Vm%-FA?JZA;y2Aq^fE)=19$RSiZ8dg_aYOaJNWFE%u8@rHj6T z)8XTMDh?e}`=de%@gR=n#iK|f#LNFuFtkT&`Iwk#cyr7(*S|Tqw!RbrlXuKYQg*NC z7s8cMprUs5!>ggeQ5i*1;cMlz)YN}NL!vHQI+1Hu)^kN`3LZ&z2f#bDOX&d!-T z%P~}e<^WPDBP$yffiqbmE;8#)G-Ov-KHN9pdQ~?hXM>Go^*b!xgFmeM->d!x$RkkS zfkIA8PcJ7UV@QVn{=@97^oqY~=+IE*xFH{Gz>rt_Eb?Z-1#e)(PC+z2&cj8)BX;Z?I1s;Hpmp$3! zP|U+V4Xl;=%*eTj2yAq0@WLW91PGN z`H>>~TanYaNm9}-JS|+m{IlL(n-G7{$B%v94N{DHHJj_h5J8TRAD3#aX`@6t&1gM1m z-p;O_e9Dz5d-zV__iC@UdVie>ZeMEZUHN(2%|7-Nx$+9L5S>e4Xal#ut*s4;vv4y4 znssS$k%y1Z2Y6}YPnEV{k0Ban0gC$o4^MC{o0qtiY}!2jP_Uxt`^|M71JAd=vV%i0 zrfO7I<0dUcth**A5~2PJDCK((S)ScRc}&aBuB4$6>+cWH>i|E$n~;j!1Gyd5%xufU z?P-;`ctEM%`h$uZYdiJ$f`yWnmXdbS@nq*E+p;vqF6JHF@SQt%AqpF-N-vMX7hkbl zU8d9>3RJspU>|Gvu(S8}?g-lL^#|Su;5veumf{nd zzu6I6d_sIM&Rp_e?(}R1yC{rQU{}vcQNr3a&)37n^n|PO0~*lzV4>&Yrgii^)+Svn&Q1(;;sa!!r?UwL0JN5xHgc>f|`xRAX9z)kMHrf-UlNR z6Fm>%s@KT(27Pp(AcHX3ZeZu};2?mUHKlJFbw@F(019<5YH4HC9FW{y!g)0;H=ZNk zcqO5>_8MTD02<8Kt&##U2PaaLfZO3M(987?M1jyw%2r#n@|r32f_S2#$|-;p6_})qH-IMQ*yYPl-_0|D&qIk+Ku? zA-7?x$-=^HVq(a8O6T&t4Df~Cl#~rWTBSRYQxumuj`A%d zFOLnH>R_t}B4q*o{!nNnLQ1b(a51jxS}+Yg3rI-|VxUM~E&qghc72qWdUXS#GWGWv zUY~MDvC~rUM_MtjM|cF1n*SOmEOd7R?=uq;x9(w*I=<`W=Bm7;3HbQ+>w%ic3-C;_ z8ZYf?mo>P{Z788-$Jq9MM_a~DiNG|NMP1Fv$P6>M^3(dEfM-#4QpfIm>3YuAL^wh|YEG{~xebKewzk+97*;km;G_vLgc=8M7OB@o1EFjW zJqwU8f&2Ipj5oWw3c|SE0AmfcJP^+?Bs2B|ZCZx>U*)k;{5s>lzB2Xa2gLc2^hI3U zEn})*psEF>SO~bxAS;GgORMTW14}f~~hSfKW|HXl8C6?1Y0-w}AJ5 zsg3WQK3@~9`|zNmNc96qdATd$7?{rxx7QP)$DzP&^XhW~$wT}QCmfFCD4$E`-2p~= z>6QXsu<}#}8K(VxU}*s0cC5@c1;FdEv4mC<$jZDUIx-*i0lg~yXU{%>v=wCJ(2GW% zE;pf~4YC3aii*Ux(-0|y+=o@l%dD}tvo|(AZj|&xAJ%#TuQl*(_l}N$<3)&%KT+vq z`Rv(^p$hob?>TQE`vJece{TR0pb?&{b2Nwjr-A76ie6VL|wPZ}H!VPRu4Rb+-Vmn|%`2Qf`g{RC-L4nq(Q6!kpmZ5sQQeZ>K~=rBm{?98XkyU9-rM zxWNLHpGHXcHPx)cH%rx+H9#1n^D7VL`pX8thzR?xuRO4WAlncI+XqlEfXxn9_5XRh z+oVGpieoxO#O+9G{3@J0e!^tVe%mnqgd~|fBxEox>832@NffbYoHXb!JV?O@Eq2-6BP;v1kW=N{w^%w~#>~4h$H#h7BE-s|<8jyTIYAB3c1mY6Z{}v%N z8m#j;eApDwt}O?eeLS}rj1xSeVG;&P2>c)r41g3egXXpXH2jj zex{}x0#*@z5j2%xCP8e5qZuYGlJvoYevp|0i4X6K6G|o~_t|>15f?cB>Yf8h1VvsG zR2Uu-5L7#^_JJe$|* z@Gy9xsHvzxrZQ6Lv~HZ>4~%RB4tNN?YIm@JFR&Wr0tqe1XLBAPx493d=qUcH^u z_Gpo`xOm#1KNs-1Amk5~3C%!wB@X9TAfYfeW@=&r4KC2N1AzqO=z~}yGjb)|`ZqdC zjE_&l!m_cx?lIRG3@-pWF)$7APA7HU-Q+kp?|@Q3Egc5C8=yL9>(Akb>nMDY2aGSI z=fJ3d7zmO}kUSt0at2LQga7?7KWH`Vf#wt>h;Ue`WGMdaONXCqrbfQ4CnWX#kVN+N z&CSlXbph(^&|eFApktHQ0XoC436@^nWia;dkz53A0yJR0J?INO!Os zE>H}Z`T5K9@@#;f2Im!MOZwqW zkNAu20N9W1k%D=cBI>WVVQxScNeB*fknYmcLu%u|d8|U-_FtTkNu*onHl|TtLlxb`1XDNrsr`#6}}r9e|z6Db|a2K zaBN(AZ9TgKiRY{Jx!POKz(e$ckOl}{0&QO@k$<;Ee-YLZQ3i5uy}XcW!z?UA`TE;y zYqFA({!Tz!LHVX{4C)*-iauutfPLWLAzYuPBuD z9_WFl9PDBc2&EWTg&*d41&NCL;Tnt=lnB6q8B&-FX()Q@=p=$u^UyOGI62UufL8JQ z|5a@K1{9egTY?Vx;}E;w?;LW49VX^3%z+tZus0KfQ^fD%z8F8xGNFT!3KUM@>p(*B z=6@&KZAhj8l?;F$02d^SdVPe55uV=m659ckFrWf(eR2ALlIGXrTFO?RA=JfJFxf=R zrmQcgh}_j&Yw*NZppOfuG<046w{za#0Er`L#Im!qM_w1WEbqRwu|=ut3E?C<=XD+} zLNHJTzx?>pyhKvtQ@Qyx;j2t-CTpr6#e}rl37;NTD9gTLN;jT9_F6h_8MnnVVrh#? z-d?`q+^<;{*>>peOzPw|D=3*Pv!y6%K}2vH6TzOc@Vf zVSqMuI*3Vd3sJ!n$Txrx2Qjg^i3txJmN2;7LdY4J$<8wmgA)nPipL1e(UGN%LvT*a z@F0#Bm-!SVcp00J|1U~JD)Q#W!z&Nr-!*A_87=1xb75#3@o#y{outmU#OBwUPj>Tz zWm9hnTt+{{IYXy-I;Vqjl4ah7~t0DQK}Cc&|> zsYZG|or-RciN+#15$`{Ws9*R!dK5Pz4M9T%M)sx+oSE;Z$;iWjfi_y@k{=(h<>>h2 zzd6ELOJ3SJJcPJM3M14-r;eRX>i7{z8#*<__}uhu?k?Q@P!{ZYBNN8yPlU#UpzmA63SiA4&hkL!Y2V73~2Mtm$&(rBo^Y!k3D_u)VdwHxj zm<c-NH|L*zt#gqUsuFGD7Pr4*DNmI_e)~$(oz@5?nO6o$KHnMMk_ys$RU42qZwOkWs_P{x(?UriW_@wqtI>8!}| zMHS^NU}P5gtt!?lX&FU)_r_=pX}M9cijtO-@hVP$=wLT+AR$a5rW zwv)Fd=i%Wlof#jmqgS1pSsLRPm?6iJZ!=xJxLGC0>l>MJUr71lszJtsF!q}fb+Od) z&-C`SxAVLD{ZV`KjxbUnJ;?Jj{`kUsL$Y?w*u-qOw7}hUNe8GrpyCNy?C75Q$l3RU z8y8fb;G#)PN`ksOXq&p%BJbUkJ2fO-cE?+~8y~)A$|w;&85&R}z?aR+K2)7q9sGUr z;2B+q&hRyf<$4kI{MKchmv?ayMG7!^hAI*T3z!5GmE;fB&T>H*1Qv3CX*}C^kum1O z0*y7J4HAQ~+?Dl&((E{*{IJk>0!|kWXW4Pp-WZh5YbIpDm3G zoI3;i<6mxI0Y!L5ctp3pw>#^E`+D=C9cN@rMKk)Xe~O%W@iqvIRI^#!S#6iFuW#0f z-7fP#KczzVnA$J)Zi+u<35`rqda68Em>-uLszK(z||1lGfW5sg6<*dW;=4&4@ z^|s!}Rvlr@AmRFXH$qZ#IVb!RkCVRp^tG$0j_0&TxktebM{ytieG!ai#4S zZf_UX#OLNW|D3$A7*W8LJlzrq%kT2JN~`?cOS@8T*2trWYya!Ked)}7V7#hw_~-~f zEB%atDf;X2h2*ijs(gsn=%{|v)aryJs&WL(BQ+xH?%mVI=jIvZ{{EU6h%8_6O3K4m zKI`eUt&ooQs~~YZKKKyB!Ql=>S4dUM4u8KzN&)N?dBrZ(skhPX?tNrvKX!cPperIC z6#6MU1NFXd&st|ZkHtq~l3*Hcsg41!)s9~yUn7(^SW@Cfig}WCd{Jri^co};OMd)_ zKGm<(5dWb(f74f$j+j_bD8kjv-^Apn?dw=_kBqF}7?d0rR6TXyCNCt%&WF1Jk2=mzI*lb%TY(tIACW6;-&t zK7dW}?%Dhqf!S$BkLMfRSp8W-!i2&;zs8mh2|CN3Vo>%enkbjjR1bJD$MLSv*4m26 z{XEaXjSBs|HTc03mQMZY3b7e-a!vY!fkAkK7?PoPn3$9Z7&cCB>*VP_{?=Y&IvOc< z+xleV@glk%QdC;DL;Ya6IMEji*6~EDd4|J!qaph zsmgLx{5^N`FzNjDP>iNaNm=O|Bbm1t&)%g|nZ$&>eR1509Z14kvXv70`b+Bf3a!%fy~Jtk zJ};Wc6*{(=li$Uap zfArPF9}gQe^=EiEVeegMI~=~|zQic#s4A({w<~n88;*9?H7N8@>f0P=w&aUAIoY^8 z-j{oUBQUZ}E*cY=C{Ev9bGU;dl!~q!K=Tgs4ikxzJXV!}{Tu?$qDK#xil0keA6(>J z<*E9JOJbBfd13%#Kr^7)wr9^yWkjQ@ruV#=A!N8A;MZfs32md$ax~1J(AfAXZR5Gq zI@|4+^ughTl(#(EArM;eeNF`w3s8CMOca0&D`7+Ia}YAPJX^1Ng3kLX!oRNll>SkGA3nT`j8J#twWC6KSGDpZv7$CxO99 z<8y)2Q~AkQ^YLE&F%FAiQCaDhw0?StNfC;qJ4q+Q;+2Q@G=1g`L$o!6`~s^U@8|wW zW^-PEOOqm#xN6O4&4FM(cryIBfERAsZHoVot^WYWvi;-7aV-=TsVGTQR92BB$*fRD zp|bZT*?SZsGofseS@z6Uva-vTl|3@Dvj4C9`Fy|s<98gt-+esCbM$z^eP7pgp6C1h zT5sm%t;DNol8v_&=BQ8oVvd`)GXGg&eLLaAv(=S_(T*r12kVr(ilTCM{YktIB`1Pe z>({z|ba{;mDbhwrSlsfz$~kiU$f+*umogEC(~48HnL7HDKeDrJpRLc>ZmjWMOz8?Q zrYSVLVLD8YQFq2wEs?=;r%n${zs>SJ_+DxzXSc2zs^;c-(1!u{*|X%l9EdI?meAa{OR-7Ny6b31*f@{v;Ec`Z2kAN zhLfl{cKA099HME42*781&3e=^q6jpTB4_E zU*VD_X~AptReR!=3g@byH3tr!qRzc~Rn&IQApH~dgV$V^W<}#Jz8+$tqz6;SvT}^O z3asTH{_?S1$Def3p~uq%pNA0?GC(iQIv#!luu>Fyq} ztT9wjI=<=R_`QNWm1=$4e7Yg}*q>sTnVHkn^i{RKt-q5K7Pb>%{;!c)ve3- z&GXVv4hOlp*o6$@cICfjR*Bqnlz2&ON_Bdow|TB)kQ9LT&sUW;hfZ(lW$&Y+jer}X6-?T#%F0CNF*|TSWg%SD*N|}(wIMAFz_#YKSgUM~C z)8y)(E+>bDH7w{{&7|M_+?E+(bC|9#*!G=A-n)y!0`2t^y@y|jY#%E3)zi?>q!Aeu zdM5V0J+u7^%>4;es$g-5O5g(XlbujCuL^xcER0#EgRe22cq2uHn!K-)mWV#suEN z1!igaW5@HZUMq9i9Ql$$!De)CYz$)w!{5sC+J06Ot-8$P8R7rUK=wKScEZ4m@o*L^x|`7_~Wh5qgf8 zNinh7#)I4Y&?e1O359EU4KCP<=-tIXx+H&V6s#wW>F+iD?cV0vL-@XJeM~#R8@h7s z8W>N+DQa3@$CqESg~Dt>c)fh_@?|xmQLzg>l$8ymL&@N(LH2{tg=6`6-4Ej&MR+)u zZbNNzv)=zc53?9l4|uu8E_SN9uszAFKmcc1S#)<6zDV=(OyiaK1OKnqIzPs~Sbtzk zPnpCnB!hv_LgRPGxNNW8y>q9!smWhB_A(zZQliR(@w_jktR6f_-ne8YAF{lvb;^HT zr#~Q|&&BI$9a`mP_GOyi^XqYQk~9+K#x{fx>B@fdvK-d@RTqh^%g@=$j`_pKh9Yg0GRY zWX8_4)Jckmw6|6dmnXg*shO0y7#z?XZFHY&PMNjG#-%4s-~IKwroHYh-)@sh{yHw3 z(ze5{cJE-Zf~?&A-{Yv&8Z6h2><;vz(0ukWha-b)*ni*A(hWiNv)}cNBvps1nVD9C z!ZD(R{6>)N6uGdM;JE{05)(}k{MK&|T6;^xp4%HO<>O`e@e|d9*PYqiG?uovWcqu3!d)ZnUw)t1SeWbU6;hQh#wJP> z6cZ;9?hVnulsmpOW?+edE@Fx}GN(EJ!&@r%5-z&DQ0&K)D}OE)WKxhlmywt0O6XdW zoW4LuhuS-GPeALTTcszDyD--3ZZK8tANJ`lBO91%SAMLQV?s47d-dpn(H|d|QC%#q z%n6aME_5&EDz!4zAK!^*I^*v9j+IjtgW=nH+BUB&cVk;cX=|v<1T<*zU5qMO%xJ08 zx6sxYwX&it6Xri3@vVWyE8#^PR@dz_W3w-z141jtWo=yDfHjDmuVF|Cv^~w?+$>0Z0#wjFgfG(^QvdOtBKBd*pR_a;Vg(AX zZ-~w;Q{F!FFSRLB;X`6*R-hQCRxZkoGGFhsix+hqy%Q74%CZvgv0xR2G#e)TL&Led7YZUVr|L1?BXJrw&mZ$Orp58 zuuJGtkS}=@op?#f*KAI-oxpfx<)6488>FI&$gd8%C*%69-sp2}N`7uGc9vDXmD2z2 zm3k4tZ6qib78P+~$HL0k>pMonc>R>W;5igf*0;L)!G|>UPv=>U9`obo{ttb0C2bE0;c4%XNKR=%1okJV()iDx%iQJro#$%G2WhC7t-T*GS^4wo z>S_dHK?ny~0hVip4T5mj{`~_H0%SFeuActjk5eL^YQiN&$)PK`>8T|jN~7>!8J)vr z{P8PWZ99!!Sqi3KA|r#_8a}&YTLFfo86;8E60MQIU->bKV!hw}|Fbgw%ciP4?p6Ns zeu508a6yTasojQk`p1=>v$FcU{;JDa|63);kSeQEV%yr*CMPY8wEhpPWEC+{$V#Tyx+a4x7LDeKYG{2pK2_$Cp7Ptm3&8`^VK|?9A1(~a^er<#9J#`*|}%N z{%m=YtDI6;57U{^qC%{lPj)}Y_0Q6xLZ|uO>`X0p=97|{%|D~oE(_`WxpnecHSNr< zn9vK%0x>BYoF);ITize<*=RVI{P&S)I57>RFI&e4Oe(-v@pDHA_u=)*$_ixE1Emfu zmoBx{oV*`C%9xbOet~hjuUVI9lM@C4pT8CZrS{f;s@Wjkq@(LAb#TJ`v5J|`+pMq0 zXl|@qTd3Ws{>A6mpxyoZ`->OuK}oqLiHoFUHXL;v4BOX+gj5uGv7cCft8M3-;y$a z1q+CLd+EeoRR}M?|y{n=lQy(9DL&rz# zSrg(|3)H4vbIY)(v`yCL;3F-el)$2gEGM>26)Q zl0@Y>ck1SPvo_f^c6L95vBdg!KiGJ6UoN9c4`{Z~PybY7E!G~z6CoVC>A*Nqea*Bd zJ?n`Eagu(mdA0-<`7GTn)xSe~Son&A6N^fBp2zgK19hsI6|mJ`XMJRSjkhlg9sj-^ z`aF%n&+ijr8NND5Td0(Jy@-tU`N_Yd#VX1uEY_=g<5j?F-P%1Kn;Aw5h1*AuEgf&r zi5#BjR|$H%&3(a-z@1mfpyU8$g!P9hFpnPbOU3SUA-xnbH9lSd8-`i+PpPQS7unfr zX1s_d3La=3wDuHDI=@9bSZdf1nuX-|sOx5oId1D`2{oM$uewNiTf_aafA(A)=jQdq z4;I_4wzNvVveBrhG95=z7G?%7a1;Gm!fy_M%uKP!(%{aWm#(gYt2q$DAn@NsU;n}i zLYdm3mO@h`cWD{<(O~~EIAn2t9_isiq{#D}87LRv=T~|3$Qo@XlEE!~4jzVE5JV9i zxDAK2pCN>uRcXw6xu<=!_mCoA(G{F+&T>Zgat2;j1bP3&=nk!+;*i%X?51Arv%A ztL79P9V9KtUzbIUMo-U2sNLn{a$%!FeCVD%ZirHxt$LXZ0qTXL-chAaqT2B`EeC7O zT!)_5R+lZvOgI)C1lfy;GvETOjOrH9{Hm_G7#c2v~)cMB07 z;K9dc7?xyZg~^Q15Q=|GmEdScU>$)qbl1f&%&%X~K|90JH#7YWxGKgno;-G}y|k;@ zXdg|cWW=?;avPU{DhI=!^#=cau0n2=Lyi8KrX@x-Vw?y8JSFucb|)j*ii;zY5a&6$K8>Yk%5Ko-``OmWO0Q^ zBs@&Pz7F!e&u42U-*vmlb|DVht!k0+Gc~(jCfqi1I)ErIHT6~2<%_9e>x+Wn=#%l< zRD{$OWGWV`%~(TNQ_cRp!f>JeF%S{ivm$u^K4GF4g7+aSz*GQ8#gVFy=R~>OnLjPl z*Z2OaCKN{^KN(x7LHJE~n1&9`XlRzYqD-Ba9i0ESOd|YN!O-+q?&RjqyA!+J4t0$E z_gjg|BSkz90n>n5(E8Q|Hnyn$hTVBl*e^{yc>0vx`X(}7-@kXg&xFAjkd}h0|BiG= z43mTs7gC+BuFqgjp&WO}iZ!KHujuWKc=3$v@vOS_8MoAVS*hf5o7>_V<8BI*DY4uA z@D-EBmaK;1k8%e*#CJI}bFuNZ4HTEAa>{(Y|r* z(2pN%B(E~idK;ntd%?_+a|Ig$lKgQR>61V^6crhXl?5?GF-AvVX4}kXSJw(G zFQj#X@?5ap#M6O4V+025l{LO65H)NuSg}h(V`#Q2wC%d!T~$?88JXAhJt~pEN*eri zULPCLiCJAuxxhB%IIm(qW#>ZA+PJWwQjI?x2zg+AhG*I;c}HgE<=U9tUptqWe0BqW zPn(Q=%h6*K_e9pGB`O5w)DC{SN+K-QY*~~ZxHi_P6iQc{xi9EZXhvfEQrXTzNiH?p zLBAg#xD(S=8CY4|qCT>*Y^>U<&;HayhWhS@N6=~;Dky9Z{<*YRekzQ6VEO+0_@bV} zj|iI^(flHw%yU<-F3k^BPIshzpd&}&QEY93955&;;_U5O37i)?&aj4X^pR zKjtJPp8sj+lSstG0hvFs%lS1_hP0Rt`eOompSez+a4imYa z?(TX&V|=6L*ij5nxAY$$Ib&pWVW%dfM)WoVZ{g|Y7Yyz-!^o_-W$+^&(7Tr10bdNy zSW%zp%F1=yyITVa1rl%r1Z3@?UT_QmB$IUKFhv-cb1h& zdFMsQqs^|6*w9>#=_)1})s{IER0K^6^%F;lJSRea1a8eZi3`Ce)m};hTw>ypTp~ z@v4QRQ|1>sEfc*GFJC(<>H&U?(jr7fm}2ja*0G-($0P{%C-A<(jg(vkgp1$^M|EoWr|%`Y>8p?e~xHRiIy_6=#VlaTyNy_OwW$$Y`_ zyt>-ALM>J1$_dr>j-Ko2i-KuG%sfmHLN;ygKiiLH@LKMqtv=5zN?vv4lz;_8Fcixt>8AqS_`e5o^xf{=fye62K&O-L1zk8Za+Io|%Jyyga7VJ-rSx9nx#eRaBy1SC*u=g; z%a^@XPGWofE#Sn}L-h3y+i@RCRg~qsE!W;qDq1=hiv<)?m9AERqUqsEEsFg6Bg~b5 z@c5D7A8nz(3nQgxjH2@<`Zjtu4j(IP)EpjZ%M4X~yAc@WW3&<-MJ}Zgq?4GY5@yDe zKGElpuzz@bwzpkIp3a7KuyS}LvFJv2Z^B&HbdCK!|Wm(BP>aBZ3i?()l5Zb~>jge@Cf-VO_nqh?}N98;h1tUW1 zleT>VV)>okMN1SsX{!pdc$*rVR=LhFyiZu{j(B5>p8 zidp!dukxy-3?u<;?7D`XFE)!(>FUqiq^zTiqBS3@$PX&+AntR1PEkZ@9)Dp+OH7$K z!}YT8#MeNT@k4W`p1dwE3p}Z%#l82K1-VNh3ay(DAlFeK z+`!hAj{NZ7M_7Nhw)U&4$cY-5LaP4iRZ5toz2#V>*NamDUbUZkZR*Vhq*R8x6Hc=W zJ#W9lQ*^&W-^B2c%B=X?cNWFw{vm!_LeFaL0_2}QZX%Nqw=TQJaQXVEMAzn?vcJO< zn|*^J>og3a2@j3Dedg$S&Fq$|6R4X86`m-bq~+KwRkWT7?Jn!Terg+=_RL7|gCI#t zVcg!+K<6h*P24;5cL(O>rSNLM;*0jEnw=K_naJ@$lfyn;#d*2peek(q`w8L>S%D=> zA0gZQJGNbV`%VWyqHqKfEoy4!pgQOd0nFe9*;vCN3RkI-(ZF|)1Ufa-%6z{qm(jkw*6XLF z%;W+rvVw-=s-?4H8%4ry)bOfzh9|wU(YFw3(CztoKssXjh`sH2W$zGE2bp5{$+>~g z>T_9ZceinZsCn}b4pCpt4&53kZ&mqm%D3`UMw)ZDZE)_+I}r8ntn_- zFxtLxwcfl^k&$__>(mB6RpZk+0qrt-kK9Ciwb>2MONm)Ux?@cyj+&a0yEkP!(?=MB zf|{IY|8)0vSZhVB0(SLzA>Gnw)-w3)I$9Z@Wh04f3keHzN@0$(T0Kg=sRd6*D36aF zKa$&%9qr~hKJDai%j1gj$T*H%38CodIq8=8t9m0~jSWnKMlHN4r`d zkcu9^?b7^G_ZvT6Wzd`edwEjc3X}X1t+9MqkC4Dl*w5)@V?0ck4O23Bo@LMd$xlAVcUMNy``fA&A8=3(UWqgd_QA13^fyn> zojmf5t!-F(mg9pKK(>qq4*hMHu}?+GPvpe2u8w~I^Bef>IURHu)DMDdPIF!NpnR}- z&H3}0#e*5iUS3}db^}iWhh(3YlvGi~^ZftZ#&hS+q5aY)fj$#2A0Ho($`h&~QBjCC z_NMp^oSoR!3*ty`Z>U6z3kwfQj-W1c<`89sa-$DR6)-d-g?iKSWzkrfXXL9V)-2}9 zUfIsx7%H3=9UA}Tm2K^rOX3$9Kd5wFXH$F1 z&pzj*i@p5LTMOHNlP+WwM=3=}LynIu{v~TY^!BLZY-G;+hZp2^s&cJFSR@X3RGq`^ ztkQB=y3bm0s?aN{i2C?Ro5ktoEMd(f#}YhpKTUmP=~bNE5$7czoX7YR56;kyx!nym zJ5sP5A|fpRV`g$$_M3Zxt_oKUG9S8o__x9n3gOKozt^o7m;Q`dah$y>+%r2Ukef8l z|43t?iwpJ10iW#~RJG?XxKfauN^?g`kOICsjpp#kDD7~^t*c1)|rcwWg)g{ zCd@L!dG*3Y!)E7Cjnl^tF7evh^@K+_`6a677$DkfX=~g#>6H|@6oWfy6jStri@`Vn zdW30ddXoL3r!-DIq4qW1^@V_UB8ptZUM6b$Fs+4PF_M;mi$B*cEpn!>Q~myuBW3XLh9_+$F%hQWQVceF`V z`F3|LoJwQ4qPFS;5=zh2*ZW6rTU}LgUN`Ewm|bFAz2%}55&r7b3?k6pzI`*J%q}EU zom52iSibI_Lm?+;s>8h#ZIx#-XSXT_j}MZRx`euqMq@Kvw_XNLZ%0d0 zJuPl-;fcJFlU_6>)L@1BCf9>^BHPTex41_^>b8iyQbrP|a>*v`rm*8Au3OUy98YzYf|kNB4Re&yX^72YxukUod25>)z5-G0*KF?I$Bc zc_ie?kVV7X-}0_i$J5SVWNp-@A{4U*%eQ^MOK2|^Z_b663JJ#@Im=xg?Q3_ZH(g#x zvc6x-VpR;?9;NW3m3kTyWA$6ZzdsoJthtyi{;j|CX^xk+bmP9byk0kc9Hp<(SmS}M*8cZ6Q6s;GNzouBjAX7+p}c{_g@PYOB20_ ztwzC$y`9l!E~2>eZoB1?<4puOHj=0<|%kR5qy8kfw(_O?fCci5`OY1>`TUca# ze8)DupvMCO5Jn7$$8DFLGJcg|0{C~VB_Lv?dxghqNEtXdGN3Is1nSJfLVx|4E9Mek zx?~7TynuNzpUo_cd6F0e8WB;Pm$%#)!GqNZ2f-s5nTf66Uw)5zSkH=*#x~qv;@hd> zoq=fpQU?x|9xDb{fm89kL|Q3_XK%S!3atN4IZwN)$hX}RHwUT&bjd%vBK9TN81gYS z(Om`*g(H#gACjf>d!w7g0sI)JvS0G<*^-u3iF)Z_mytv~ecZM1_nsG*{+6F{$Mo30 z#p947K(hcp73>cX$3l^hiA569(&eykVQA>&+rtMBhP-`?&?RaL3K@9>Ctb@gC{W8Y zY~R{g^}KH0B0#`CXoI5~KL=w32j({ZZx(U1fQzb}92(}wGV?x91y_(`0M9rA2Vgt1 zT5HvO7FeeZV9+@5Lw!{ViA?)4#rM6Zq(~~=No#pc7%ZM+m3u1=w1IV)~ z9GdUt$XCGt&2M$Y_h_K4-I!iPdIz_mpXC|K7hhD^LQB%Dj)yvb|yE(_Ntxx8)jw$lm=o zN)_MVa7gbpq9PEq0;3*#Z;j>H%uFhfc&sd}FLWGmr@=%2<_#kY3m?$(*-^x}BCr8A zAfyiHZ&Z2t_`o)_(~#)8>nrgEztdY*UvwD*yS5^%(S2B3=c78pRH8q%n6ODH3}Ee> z7bOO()Y2OCk2Hgl5b#%V!PKo_co<~o_q zi82>3I?K=B|2Q?Z%hr0Ho14h9XP1R9aBv)s;QE1nakh^SyaLPPbmSGBb;q4VXu|Bo z1Pv`Mo};Ts)lG3>Po1;VmH31vN8fKc&Va zZ`#;6H8ivf4k~386+8+3IyN8w{j)Xx@f|K)6w?&KdQFjEIx=7{m6ni*lT@5cF|gct zzF_nKce%Ma;P%(|p4j*~P5W=|etw3Pbrb6h=50Piu#@LHl~jr^gdbd33*PQMCu>dL=^!-^G4ydwW|~*Yeudu0ACH z_tv(`p8UUB063BZkUo;BeOE~63udcNz8%1zC_zCYVKG?sL`B{6FMR}Y+?A!}$v!L4 zzVfFR-uB0M8`^b8uybR{zypO#J7cV*M2msG@bO@%8Mc;#_5f zxU-+3}gDg9JE1ksK&21Q*cSuNxbo5O#8=C+h9{}EQPG9bj z{`plHlr2?RVb=0o>FXaDPy+$CG3!UM zzA;x0-A3j?PPZ2?2zl5}CR#c=1jm}2jEJ!C9lw(vjTu=vIk=?L*+$3;>&=R#=%RRu z(ctO>^YGwdAhufRdVbTuY+rF!s|bd(VdYy=5Bwn`mPFZPi4GMH80^1=ai7~BlO;#&Qwp}z! zcS@ZC&9~aV2&Gaik$ot?WcGsfJE4xC;QTzXQge-S+c zM9V|`iZ6D;sY${MCQVUENs#L%0jK#<`(|Y!^Ptr`Q0$y>?rr=KNMwLmrofL}St*Ni z7f-)}!Yj!cO&uNN*Cwgpp0%I15@rg@$|vEMf`Qdyb%SH z6lv8QI(zm5o~qjK-#I6SP^LjCiBk>X2xj1P!D2tDJVCPO<#Pn9)YsQjA3wgbUbefk z4lVyka69+1uXAR!Qf$q^q;CI?n5!2aT)F&Y*Q%m4y;56-?TyavpTinjEwmm9yko4Y z{7;U(aMlP027-yvFtB3cBw^fmS@z$z0dsDH6q5A@5>!jKLyjsuQSfAvfLvip--yL| zXJ~79WGS?_{dQHC5T=e}`WelMt%>rV>d&wfVXv3(8pg@?+Xf=I%{`tRQ}mpQPS328)% z-}moqneI7vWD;Vss8yHqfnrY%y}%Io^mKpgl7xhPwDB!{I2jR1fNDtelc$^8S=<;F z7N2WXi2)amN??tcXS-ma3jtxYnz*(K#vl1T~x8U2iZ}6gCp3uPIfrA~6V8Y79 zdczxb!cm9u>BH(1T-UbO1)qGLi`}hDdF&W=7xMl4yR&u3ahAex(o z)9u)^jODKDg#j{hC&F#lZ8?ZSliz`21qqK5x4yHpv$;6~T8X0Mf57{3jgRks4&7Ol zXN1QPksPqrcnl9O%ug?i%l`25t0%4wLdCmt4XBuznW?n)lrmC~0*l8Bl@x!-{zHeb zch+=}#QvdP+}?6P&ssFfW72~T#yjos54&=j#PSn}2)Jh4m1)0h2m|gd6^awD!Xq9e z9VJ%5h#wr-7~5PEjoK8^U%2X=WtVt(Ix8w%^J{8QaGyAOjfbbXp}{vGAc<`e?!T;= zmt|$n_wP46-$Rn=D~K6?(!Jkay1PG7P*5p%jf|j&?E)@hNm?RfdNt#?gKnqOQ&KQaULJ+u!-wHFtUhA&I4I#H$Xs8m`}&pZ+^CqV zfC^>ks|Rq(Z;5Z7Ys@{Gc-0gBacSqYJEN(4%Sf2N=8tUB*zCB?;D5bV+##Aj%!iqk zsJhFX+PJP)35uvlN(dVpXq0`<{q?V|0~FD44}h3Q9c@FS)b>&9p9`~uw0?<#h#=pc zo{=W?D&Qhp1NBkLn9Ej7uYzb9mO!O{;NwYbGGp>_>C^g)e)9umjuRDB!pQzj%`he zT%K4DjvX8s`GxvJ&1Gv8xtlPc1A{;ou!Do7xPhejq_jW8)K^h+9y)Xg&&=eq(dmCG zV#wQa@LB1@i9|42OM1uB-M@G5L&SZ%)mb`hp<(xRx9Tdl&{yroYtJ4%$ci*7iLvjg zvQKDS31-J*hPVoR`%_KLhmRi<#2+|N5qe$ySQ4?4C{d6EfyWX18(!oGFhEf<@_H-2 z2qke$%uc1}VU!7c)-wnK=|M;W0)`;Tz*1aXTm&mBxmw>_Y%_-fr8xE5^U5JKr9Sujg5cxQ$b61xihTz|4BfA2*P;ktUlcV{3sCI2~@+i8UQ zM&7i`E-Smm$LD}PH97ghwcyDbw_WA8Fds%rstF<)TZJ8MahtQ&4mW%|c;3FDx7(>+ z<08pFUGnD4xl3RE9o@qbL{-{1{z~Gz{=`Y=T3fTpZ~bS70$v%>T5Z!{Ou1Y?uP*x+fR@5+2KV0lA9*#2FOBO|SYr2M|O zW9l+oZln%VJ7qO?3tgu*9*8T34>vB3&@$Z3ym}W?{~~|gMCj&v;?H|NWqWsW>>B&u zU(8o|+&5d#mozB5k|*nI{{3XJ)WFxjllvb$UQM$;t*6Pr$osX|iJgyc|E{sg8bNZD z?NB7Ht*t?1L+G5ixN<&!4*jBP17|fQ3(F@jFE7Z`P?!YrGr-*iBV zDF`LLf?Q?HTu4?QC3*j+iL=oUANlYG@SKF}joPeSt6LAK34fXf@}_4mqQ* zuP?h=nJ>lH;RSG~=){p~`Rms&Ux$C_6!9tXTTM(%=(r7oA#BR{;;OL+r&3XordtF- z^6tef>V40OOsFjOF5X zE2b6`+YINM(Ph69V$+$_I~75*1N5X2mSV{-4E6iEoi&lCTSU3iwj)5Jrx&7gp26%y z$?mVDv5VWccHJ^sl1#E#blPBkvtY!pvr-W0^9vvFz$|7@OlS+D(g zJ0GR+q0>so!~DLkUTg7{h<)!YHMQ5NZElUp*w`4BdAtP9qIn`JXEok0Uj1pSuiT~l zKWHEx1vl;>{*7~mFj|+#G*PJ^KpgU~mFPu(B#O7N`Uk-K;A8q;^PV;9Xjh6j_IZveD&!JEm z$;XoT{EuIooTyCd_ZjTdac}t)?CEPnE6`26QSDE0GUe7vQyxXpu5vM{GB-aZvm&WEcHC0mMS98?IV3MH8 zInn%Psj)0U!og|0M0|r|qp`l{yZM2TuV3my7MYx0tTpf$?6k{;DDkmYexIKwlgf@> zJ;UT{cG>Ts6)8gx2Z=hxmu$l0Kbc)I zhH#>2a~38idxeBRd|rDy!NA;m^r!d@6;x~*E!|-rQg!i%gf6rPtBj*Wh8xh^ccnQK$@YE!l;#)4qGZ# z$=7cv9JnVc*7xXH?^XVkmVOgeNc7Z+OjsaUq%O$U-wHgDKpof1C7%rlpqMI1$jCr&B+B&G1T9cf?5 zBAZX~gH&lOw*U5><%s+i-SJ5NoTzib!|N(3`?G=??>n#&6BFGbxYqvhL*fE$FJA7z zwkX^P@+z1O4UCL%z_YNj`uh97ee(v1QzyQve=)sqg;HbM?Q`*24GkueJ?p+fK|oA6 zfxIK`3c~5l*WLN~vD6&w%73cV8wEQH zO9|jwRSVKc4jDg*Tx5!3Wmq04zjAYPw-tB$6X`?m|96oaZIsGQ^X`eLM4eGs>aKK9 zAr7&=~Zw>0vNHn z^`deJOw4~+VKIO=P@V4kshFf9#m*g}gjR}JNAK*ueU{V5w@&Vw|0~RA!wAb8(?fY3 z>#OhS32|@}EwOl9bxFS7|9|TL2jO%?G4^QY#ubzFd(t|9av@KRxm9BF)bPDiF9*wa z=~v|{k7X}1u_c-6w%b_+L`wlGx+44_A<2$@Ij_CW?7^{w+ebJLptRkeDq5ZA5p^py zHARH>=?Li(Xpg$(yRhtJ8jGr{O?7rrc@U6ER*V>9>62&KReJq2<64 zbaBu>z};|R?e8n;*Qt7jVQ-!(2|f|+v^{CP^3$_Gn#xp5r4=h_ffZqk*-FZZvb3L0 z3plsQJ|Cz80nv!8vb?Lk6s}iE{?R+Ep6C?K&)l`?PEkZ*Pq|m9h_UL$`!n8*MnaGG z6?Yw*>C1~vxB64+ldfrcp-U$7x%UP3+j8+2CB5Y??)vK->SUqdE+guBu6f5sz&)%k zjQK;c&Em5$aw6Bk2ag^#H8$#8rn%3Q6Bj28)905jvFODR-3a`xVUB)C_o{AdR18F{Yr z1Kx>6idJSbJVqvsNj7;Da*bCsZ)|aue|_;ZHGF-e)M3?K-6Qp_f`z&Xf`lk815iWr z0pMu;Pr*RGaW^!F7=!u@f^jH+0Sy+u_5Vid-2m-o@svRm8?-)d^0g)aj>gZ%HN z`g*O)GXixY?X(egPfsnPs}rgu;9uC?a8{Ym#RfrVww+P0F>=I~*LiR+52 zsRYsT?!=EDr!aO9wVI};@RN>DDJi@+ZgA3&6GAE!%AHGNVg{gh-_L-34ObM6umI;R z&&ux4W`rQQlq*VJc;9|>eEI9-Kc_l(y;l`^NzUuEzJx>&yc+@02M-o9*dmxE!?k~8 zS!4*Eo%G+cJdM~4zvq+FEhJU9_vLi{nH*_(*ZkvBF6|A2bn%*|CEdYC@+Jz`hmB^iGy&JGCUXj062!is?qYIfDY1pw{~dx7{x4}yupbJI2;P%wiV=XII$)=7H$ zjM!Kq$p{X1cK=teG;+F8@w9y@ISWbw<{za3Xj-hM=bGEgoE+%s>Ho8P@X8kVq6^PH{pj5v+oc@GnfI!&(GigD)|Zv3rorskP*qbxakO) z1V;*liJE$6wfrD}Ahdm;Qtgr)U=>H$B*-1RBrr-JZsmbJSe6cidnY(JJ}s>;Q8L1w zV;HzZf0@%g(%4&x5T>HEpSv;NuWD3lALQ!w%*rb2mXrO12Z6}^>r)u?^gYeLj*_Lj zO#5R|esrM~Ptma8)>X?TWqzg~QE@`a$8JnmIZl6Xc~AR%*RH&69-^z%44eEk@X{|L z#PTm-;)Ml;Z0%P_f=6qB0bF8#(6K&)^k;s40y%-8KA?PseFK!4;r1}|^e z7xhy&ZJ$;9p1}44q!1SyvMWGt`BYuD@Ng3gy`AkoSr(l zh`A^T|2;rXZfRyVWL^j+7ulJPtFuvtPSSD-rwtJb`c<0~9s_W4&DuU7xGkzb9CVk%U%CdDbqWvu_rW;K}{s+7N3w% zYP*0*CJjA3>qC{E?aZ2~ih15eJ8>>M+wQNitH@fp;qXRIo50RyC7(GeA|R5uN}h-4 z?ElRVC3~Y74ZaDs-mn+ z%0N!61^XB_F^sX$7kdeNrVLq`^>l}%h{!O2VfWWS_K=uMxCRtoHM5Y4tvsya$gZU_ zbx^_R&7JA)pj1`KM;T1Z@;6&Ju?Xf9*IeI&Z@=lh+10~sw2~H9Y-wB`{O|Mnz@C^xtzn0DDP*$dMR0zxOZVkAP_hu5dM$SE;6#R zK0X_(5kx*!*l`%$L4JWelc60Q87Xs-xaa6tQe4b8cNc^g7&mYP_Iu=Z+T1As^h{|X z9WgMRS-7>cGs%$d;egJFn@erco2!b=(eZlCi6xYZ#E6^5Mu55qm$E2FkW2kC4JeY_ ztgJ8|mu=r&QN#{Sr*8Hu$;(>CoFIR3;nHSZ;a0>~`aZgAj{n9aBQj6daJUg{WTLLm zzXX&xVAzT>I}Z!XJy`y$D)2Hs;jLPy?uM( z60ph))uFUsb=sV38ILM-bI1-H_-K0Z!iD#W$ivQ>iV3VAeYpB{z6Sq?jLc5pZAi#M zfBQ&%E%P~|0B|%A_F&~bupkBPko0b72oF+EuH9mS79U?%mx7f|2hZgD_{59BhkJK3 z$f!OFU%0pc=Y#FD!SVP2Y&$TRf7~V``nOMpQ0TXvWnlP%$7J1HinI{zb&%)Z)m3^f zU2=x_j~_3cIRkYO)2+J|RaJzwfi1B_uk2ajucz~3Rd9XDW+$z{!19$Fp&vUC)|OzEG9Z%y?jp4 zir;?8cJtqrPDB8U z;7R;Xa!cS?$AU?cic*A05nv=OcN4<)vG31JP2GSeAE+R{>!5?QhuHts0-U^>jKDqj z*VsVB@7)W)7LA_{9XrksquMzsoDyb$%7xsBC8KYy0r*6oa?+#5Gmab|u+)RoAIN&VzO^+_(IprpX?wnj z(o~+iyXdoL^U%8BhzH)kg{X5B0Kx<$`NM~^+#Da)jj+c98Uw97^#|(`rBa*3ail84 zRYtH#IR3<7c%GB;p1n?J$a2Dw(9zbGx_lIODPi`geqYftHf};0V>)mPtNQ)>_fQ}_B=eXr^d%-HMkR5} zVZ~TX%&V|)4cWjS)ktnJtex9M*#^o56d~>uqF!60_Bk_R7r?^a3_3()rEztrLle)1 zekvONrbynL+}z;VGelpZ4tiSm<~#sjOOmyATy9h+7@C0sk0`DR9AJ010`!KnXLg>4 zIN;lf1jQ_;+AhLfGmD@;LFQkCM3{M&`SN#rC#a7ICnx=|b)b@$53dxIMY>m{fJ^lM z{$2yYCQCUucBi3nf7)Uf;y7Ip$ar#(AiuD1Z8*1fOj=y>y$-}!|F%W%V=2xcMBUPI&V~g0yY_WzL*A7kWR@!=nn&=>*<5QLYf7 z{+*XaCTMTNxoRgT8#KK|`5}qPid@_^&wvVgjFowQ< z{dynS@y*&Zuq{}an;WK!v30M;V5Ib~swy4GaxHJ~{-X2rseg(f5m6lZ;b6Q0eP=?` zaLqXM#S7tOKUhssS^zRd-43BxTQv?m5V8^B;SUM0>m4K|g@)q5D8>A73SmhLAQE7# zesf9v|BGZC6i2A2mY0_BG$gZO!Jr&GdGaK*SZ!bMUpSWBDX^vB(ZI<|?ADqq{e9>e z^q?omU*eO0{V%_(H~q% zOiWDd%AcQM{h$iXbN&H=8kKN5qyTu5n06Gd3O7gZl*!0%9ZPbHAthfW zpdd(#ppqie9J&+~lr8}YL0Vc`0R@x}kw&_aZZK%1ySp2t>zf03-}nB%SRc!~?jp`F zo|tE5_MSb|ZomBe{lQh$cn|{jfh7#iE!gy6P|7yxf!0F;z?Yh5adA}{t*p?g#h~!2VH!N1MRsUA83K_-Yv`= z)G-^XBqnwNaKtRdQU=&kkZ$hOr33Q|#7e15m{8EhV6&Kc2JL|X9$X^~IpUzlPp!_( zgmRi*L$>9Ipnf_wrYZR7|Kc-dK!=K7d{Yqn0vYb$U=v7z*4EZTKHx^cZia2`>WgA5 z+u8!}6GZDkTM~G)8MdX%aAXAq2C{HpL&U?pQ3I(@7+)WyhO7fg0cFt3iHd4gah4So zy+cd;QQ6M9Km@w1*{Uil?@mWKK`|`Y%Tkai=<7pO+xxVypb-$zD}J9e11eFH2tTx4 zm|0pH>ggqc`XI0LEu7U1FGJ+u6fo^AxvykO*k)r4A=@Ce|Zug=PwnVZXH zNu#r0+DGov7i2T?lM6JYlPL7xHG zK`fi?XJ+Q+r0J?)ATfVJFG~cX@i##APoFNs;a|!CbYd`|KuU!6FYqp15a@wr-v_}3 z!CJTt1gMTn1DxQY0F2E9n6o}mqVS*GF^5nWAXaeLruZad|IBwUgIxtjw%ByM8dZp- zS|Z==2iYiI=+2j30#wf1ckk+&m^gux1s;wB5UXJqxcWlG7yS7%fYQ(xY6s{Jzdl?c zW_k4tevL^1-Xyfu`vHE0@lYuz@I*n+9ULTOJ{k%XmjwpAgNPjA}|)&#cqzkHjue0Hu}*D5M1iCLj2)ahMdH z@eurhOhk5+_h4E(=olHB!99bE@SyDw#)Fp*w*&HRiYbT`f%p>OK66B zID+YmbBhw+Zo0pP55g=!+Hx}8hw{(=%c1Ip^>g#=z<_%Zr>Xhg%e&e+z2FuW0xh2r zIo|9W5Rd~Au=+4W!jKV|n3>t0=fVy=$gZl3K@pvlw7oH}aO}5SISyVC2v;v)gr;M2 zLtWTcw-a3u*DGO-qToi|3_bR35O2SsoT{7I|NidVc^U-5JwGk2FDmV8it?U}h-v9- za_pbZjXKlQ)5mVytGCB)Tz|O(*l6YaOf{fYzb?fj9nP7qV0`9;iN+Ro0EFn1=RsZl z?W>iwTkF&9q-T}CMe-bVTf80lB|!%!_Y%rab4Qq%XzGYwzO3IX*UheCNYJ==lOFXk zcY5^ThIr($3JkwFthz4v59WUsYwZ(|fzJag)Yi6*^gKfN6Yz1N*$1$12BDSbQ)TcK z5eSW6+7BS*H!BI>vGAk(M+D;MhdOO}S=knpH+u3KsG)-1Bs!@3-8pqW4gbrM!F5na z`>9<+eRsU0j%_cbklDWcYYcxFf=Tv!|98NTuS);-s$iWyRmB`Ts0h@HBM_KjYinlE z_T)c91%vesWdI@5`0qaiApn%ijU#a!7$Nw3UOhBNGAN!t# z5&V1a|M5n7aMYcgAEyQ` zk3Se4@^uQw7$-DN+7_)8Ci_`?h&|*w6yirqM|62X!j3_&V%$z5;}b; zbXK=#kZ9mCpZX1u?=4F8hMXWz1};5fr940X1SA21rnV95!Y2;<32+x$q6IDy(0|Ly z`icyk`DYYZ-BvDKx&&c_qpXE&^Wjo|urYH%7OvKVSuC^_@~Zbc8UpP5M?)Zfwgt91 zQ>8#&Lc(k5Sq!EDEQsUDpr-Z>4o*a(vG_o~8L9#xYsfSih&AM?m_1k^6ihb55sl_= z6hFeP{dEW1|kb3CBk1MF>4YZAZX^#}9&wY~9&dyx$?J)rC_cwYe*^tHgV;^v|WH$C!kAiPlRapViGX)SpNS zm=17*rq>oK(Y8eY6Z=0mJ(~`m+g2pcxtb>m?fOF2pVey$wFs_6SAG#0738;@(@b}9*XK)GN~CNbRj)S+Cs3Jn8hozS6zthtC!R7_nsIkY0{Xz}O-)a* zH->}0-vh2b`99_YYd#$$TH6ss4WU z#*Jz11ZzvPh>}r60F5N3hr@=UX4Ga|w%JY*Cm==!*y>FAIFVi1cEdkvj+7zn`UE+m z)(DX=$4)dH-Tojb1q>MLi?3n`Mj899mjtXoPAT{X3rSRtB#*o<=UcE;Tp6Tuin{2S zn34V%a|h>u_Aax;&S;j{P@bdRju>av!spUdRNt$#gMt zcd?&CWC7Nn&v!a0@nGb=V@%DJ%q{g&z@v$33+BN6w zvD-AM3t_G*7e%l?dLld%)9Qepdh19@$GbrGraKEv*gJAZ`!q;_K5OL~=i$Ca8ox%jDy#fw4b)ph$MG4hx3 zw+W;CFP7^l$jK$^>sQzu`YM>qVy{o_#JUz7EtLLopX0!!$XLRCqiap}K>6XYJI<|Y z?Y<2{i#Ox`T$#x)+FIG6K`PH6jVsCNNlgZ^8bqD|JOIFRhqkuaph`XtDY6bG=wQ8P zV@~Bm-z$5kFA~<%hm?1ki8K=pUiFm+I9LZ$;H#4Alip{TrKAuph@dg~l<`&RuzO+b zr+u&dMOU0FMrCs~C0^^H%xMqT+zN_6B(O`8Tf8P!))Mg~3hc~V*emYcvSiMl$+esO zUHfh1PoS{iBj}@OqAYHsG+PkkJis_xKc}qVz08|ukRETJk4Ciu>$&X4tjs78?((e~*E6xY4CI`#p9!yIuU&NY9g8`u&xS!Up(6FQkmFOq zrmh#U^zSsIEBz{ie~=y4GuzfjrQzk82I*%wCSTHDsGC!vFX6l@D2+QFKMC$cx8zc4 z`3pA@1rYmb1ElDj)4dhW*R4Y@@8>yWe?kjxNIxuklNk@`ND2`&o6caG>!eRUpHIx+ z3yLN@-Mpjk-gELtOPMppX=|r1+Ntz!-TQsCyKpFxGKDgt)Qr=ldfFP4P5zi=)>_|_ zXgWT(*d12mM1RIJ+(pvpDV>wKBzI-;Dl5!7_P%&3=3! zEzfR_OH^5Ysd`pOiZSvs5mHD@j)(Wo+Rss=&x7HH#YpDLN-*FCgTMWR?lyI?=WWZ& z-oU*Yy0RIyIPKzvv@))e-!*8ZQtx=9Y!J2!qV1q3({(VLOH= z!*S;wb3;KDnMV%Md1@tcdyLOWA9_*3mGM>e)kDv}#TMN_CZJpi#Q8p`AnP^E z_wm6V6mU$2ln)dTfp&4R3(Gl3wq3W|C)vYJM7raQnH2@><`yhWvDgi&73a8TSTa9M z5jO|Geg>bWf?)vV^I$?QwzG{kyziG|As{mm6bywaWF`OP{!9I zH z`yj*u13uYz8mCaa+juBa*}d=^9Hhf-jvwqzTdlfs^78xJ&-PgrMr>>JM(*m%M$Tz< zZ4gC^9K#9-gy7jr=I-taPo43-%@U9zlq{$}l#-RonVI{3(^K6y@!j;!_i? zx%TGwN9x*!9e)z?hnQcdC~NbjU+Ef)Ez;im{nW!JAkZHYnOYBy)+OAU@A1;oa?p?u zT|*b@Zpqy((I;itdTgsNZSQuE=i|k-Hh(_al$2EcSL@i%f6zR&C5ObwSMZi^Q-~RP zI_}6urVk`UT-X>d82gDWm1%L=v#PRkj>zp{bFKYAxZGu?ES=vn!E@6w+PZN%3Gqq# zx$^D$xCd*=36d%=Eg5(zyMw+B%@41HbB5>}CmPrEN>pn49R z#1CR6OehFT$>WK*m^rSZl$8ODxi+gE#*>gvoYrFUKl=Xdde9HduAFAOl zG7rKr)9+Rme55kI$=qmtRnXf+)n=zhl0nra#<>)1wup+imD$YWviC3!#^Ib6_VeO; zghlcf7yM12IF)3+B_1QHH4^yC%-ktJKR%G1zhY7zd(aLS6RZK;eX!QhoR=8nN=T&1o~iyA87HR}C*CN^=6uqeZI zKhx=#`I0(v8SK^YL)#IjrKyl#iR3lw-uqfV5t%{#syuhP!n$l~X9y??ROa*M`OMZL zJI$+`*9pfs1Bj@=+u3X(Tkj(0ZxleD?gpy1_I3t(dT{Rff?u*HwhlG0HEs=9ZStCo z$9{i2jb+^XdSCReTX~1V(uR5U_K=U%GwI2kPt8hX!{ymH7mDPrA(9s-NBn?&6`k_ zt(2$`Dh(a)dXFppAlv)ghi-GMdj%B1c#h{!MRh6sLPJ01ah{KW&c#Z4Vp_wN?aPk{ zY8*S#E~2|Iy{xNl8;fx}6TH1V27DMOaEm)6I9P*(X3DZ!*iuu6wV|MUN`iM;!^j87 z)h+2W0X(3oW0abdvbws+mxo?%rs-2IhA~$He*elF6Hc4Wh=#-@jbKBhl)Q4bW_Ew8 zM1xL7Z@P)Hoz0fR!t%kJ$Pa}Mqr%p~KRS$VK1tY)6PYLGHA}xRUwmlkwRsSa$zj)# zkr`Ix1-Sq7a8{ey>JRq5w(Yh}>c2`HbXvX;Lf{pLXIODaDV}Q zVx_vwLWoKLK^DRH`?WYhekG9|19=^-;h@j^tyxF+hkPEGt4NnOmwJ6^rg2 zO2;Kq7{+9?Xv3`WR}D8|2BfGg=G$YBoiq}X(=?O0?; zQ~-u&r2I0?S~hb~dgC0=WykZUCoVcrMJOrH8JJ^o0chxBGR(563clFzN8o?#$jef9 ze{6Ij@VOD7G%kswN7xHCLNp>tiYh)1cUWY-JOo#eD*h#&Tt{*@I4E%1KkMwUSzj1+ zo9X_rn66JcvDYI}&G)oSTcs`-@A{e}JtJNENZ?Uz?cW!wt*4Xqsia{{O&fWZNqCvb zI1l--@VPOXTf1*=XSYgxp;ts!SHrl0)s$De8#sG<1*{WCMPQ%nR!w!x%XY}o`7lr8 zeHXxpL0n5t&hgSc{)X;YyRGzoYlun9kHqUJZ!Z;5Xo-tRYWIn+w)1bv#>oady-!Kfv=d^YmZ=K6HLz= z9>I3ejQL%5dlqJc{^=j@+7EjK4@53rD*o{)E-hh|6`7Nk>stI{VleEr8clmoZhqM= zox!V!fzfsDiLUxG-TAU-QjBE5L7#j2`@6vwU>=i8;f%it!s>t;)oDXg)v*?sA{A5G zWfvX3Pk+fzz}I{-Rdi0})-S;^ty>o;w5HQw0uFwee+TF9Y%{6K{A6pRO|*oy?qSLH z4W!-sK+lB%4J_+>5%#uQyBXKj2z(4p)?f(8*sVL^bPfu@;9KF_yEU;XH(WMkoPWga zB#c?)wD^1RQ+b1R6vSQ(J@$%EAknZoQCzJTDmtm>E;ZC(_MqJ%0o%@Ys~9{q;LK81 zYhOc;t)uL=JRs)nd$r8BW|pHMNx1fBX~W>|)}jjY(}km>$(G($5ovMT_R`g6mviTu z$OcQl2XrU1vM~rfF&s(KJEQAEo08POK`6WY{$i2MSBE_;XxzL(c7e}%c6?mO8vS?= zlV1n&1sNGH0GGusEhB&-L*f?5g%%ik=)fI;jzvSF6|<~KpW_Ls)A8`GJYl#M_1=}Q zG$O*UFFr9f*Yi0rYO~w;$}o!}R%u#a_H5TZW_zY?u(MF#+*toaVrg%$a9P*;QC;mO zDOY}JDgBt~vo*IKtO&cy5gcwmUaSx@u+tqF$bVS(WhxaIyY0kTkzhQ85&A@s`=C%E_q6Bra}^ zcetw_;IclBBm!qhIX}o546AFQ(=9TLAbYydB3p%#A}xsR=fV_M2t%I!EY9GIYSs7k z3s`P7dDONy135ME#~-H;i;fOi`3&}kMDf|A+p?{iT&|4`m114V7?Zb6ABYUy{!!l~ zR+6p}3tj#CX4JWgwPxaq1lGL3U zIi&s|f7lOc^5?Qji*lF?GUbU{smAvi9b-ZA?WhBA8)STjQ0^C>*x~pCaOFAwRIa-BoM>Fj^GAOlKYk<4EU#~ zqK0o|q<5&$E5qK>oyYq1qgOtmxZ!z*)5+nCdfiV+A9;QX-K?3K1Yy32q=e0gg`m!` zb@i*G*voLp&yR3f^pUG-!=s@NkE^n-%Apl7vGLY7| zD@j5QMW0qGu_*c{DaKslkg|1sn}kK;$aJ$-qy59Meh$V0Q?(5;c{!ybo5Stwv3;4l z=WMWFzl*wf43Q)FZ;T{dr(o8uMIn^S>g;^m)i%XB2o@={#QjW>fJ2+jmA=kY0RIw@ z-bvq4kO+9q=)kf;Rdgr5oyyg&^9Kv-WnPnTr^+XC2g4B;%Q^l&qjh?)o&BHGO9V~e z2e*MQsqiJ^6C~(ea9CKw=xbod0hy@LWUeC_2qwwohanRKLng!3+5RRL zch8R8<+*JdQBX6+bdR(@>;qJ+dyjYjI(_&z@bHji0Y1Xg%@r9AE-uk>M-&1F;JhkV z_Ml#iyK&PwaGdJ%rK6EV-zy%-1Pr_K*qiOuue%lW=NVW7mj&1xEu`YYr`dsbZi#gl zc_1ni`GgNJ7U&bL4HT^(CSqR`4D4h9O4O`3I=@*?MW8+1=Rp6t-kj->_CyRUl3LBI z8LZpIr~deN5LEiL8ymw9tPM~r0Jg)Er%!#L>w&m9STF-qQ>MWp{x~4Q00n}WkWfik z8HxhhNHI=7J_7G26`P#w!Dos{x_+9Ce-|7XnE`x!>x)32CO@W1^ba3_vouK)aDUEOi`^%tHf1}Yvl+`|NIO@ zC;%Q86BkF}e2AG-DDyn=k|LM|vQsa+=i4DQu)|JQ%a6}tVX1L(!l>teb@lYIUVwBA z@T#IwkClsg8d-h=MpnLl=SLKyt>xC=j~{2L-~89-j!_M~zxO%@SpWI;F;vC<_j<>3 z`_EGyUjqAd01Z)p^A_XO!N`Xfg}fGOK=?l$!>JIx6TAJ=$Wlb1Zg27b!X2nQIqJ^* zr;)0&*RN5aDjnmn%IE(+6U^`N&CpIS&vtyG|MOVKWbS{4=K~7-`{zm6Kxz|#`1?MH zakC=8sE*h9e~rnR>KFIFJkb#mK|^8E(ITffkFx|6S(KKB|9kxIP*6abkLbULAK2Hz zs7%5?!}#yPIR-=iKE#XTT{rUgjjy0y>OW79!Vv!X%YdLDP|!SHZ2y_nWGFDx)%_ns z^zZw@E&z(izlWOb*k1ns%pMz56Z~sZB%n)Ym|{iyd{76>J>_& z2td9}u=|I*8}ndGfGq>`(GfnNBzt*!Lg^>Gz--g4w&M#T-hO9hVuD_UF!z_yU6$yN zX_jzGwJk3ceVL&abk7d`0MsL3Cm)U*7;hHD2#V3rJbYMlAud+H%nR>_N zUIyfJTdG~Z-Fl|5T&N~KkqR6<3#fTGz_`ruX5?*XDA8c6Fy^Us(|7?gX}GvTa2iSi z3+oUHzm$oUNiKaPn=HEwxF!ySN(xKJipf6_?5!q?3yaX(cUF>0@N}r|L$!?-NXr0r zWdlrcFy4*8*!`1soo*{ap|d3cd6i#Av3H}-;kK+&YI>|zGaM3dlF6RFjK&L)ghCzl zaC!|N9spX*qtj(ZPj~5TIshx(q#N01=4K=@54JiV!}7T3T0e zV+UV;3+egj5Wv!cm$s4ge`evxnQ6K;aXLD_GeCg{ejbd@EO=%6nd(vb_NfpYEzxg; zZIn2WJIKR7KB3vKY((a6$jHHpWa^{&W?mEd>SF`PspFK1MZDjYBA(OFYow`TqPzqo7TnN!MO?(_Y}p;dF!_j@jh4 z@z%Yw-z1HY=8uCl#)Iiql~G#|{~z?xH6lV4L_bl_*p;TL+dp5>{9G* zeDpXM#umL2^6j;%qvJbJTpJW{J?3elGK_3&DE=)3902Piya9&m40Jrc^E(0gD3+3Aq~V!_skZ!0>grZUP_?F=Lq%!ydWwpVch zrq(%KM~@)e3QpN*`SmlC%gdQbY5JXw9!obR?lMqr?YZ`vn*0plD}_R^8@*x0sW08* z=eV~bQ867UTWc>j!|l;!)%m0{@W-dkUtgisSH6r+x!)R5U;lA=tBcj$j$kp&@!_ps z&U-UC==r+1D8i=XvJzi;Vpgi(?;CDb3JzOcK}Ox;%QOe{sZo;vAb=|$7-XMny7{|f z0zzPyn&v=1BRK~oM069Lw>##CpG+*NaaHwOuGVh)S=sLY!W9(ln_X0AcLKBlK72W+ zmC6-w(rJ*zWIuZIqe~xMMkE^x-MBfa(;Wt><}+litx`bli$l7%{N!0+bSyd3I}372m6|SmF}AaYW7x^Q7>M0&#RrC z7PTOwm;jfWk-VSNvUjQ>lNl9nbu_VBGe)Jbf~US5x5d=T7$uzu6>t#8{vqX(msU;? z&EJx`>WTx{tVD^$e&+YQfW5S0&Ip^eRcT=^3hj003*=gq*3U6lFG(!46z#>wB15gD zl_4F(NORi@x!_3l<1iS`CU83}!OO+EcoA4jEFvI2T%L?JNaGlo7S0V=>bGyZqBbIv ze$1a=&CIZBFE-m^Z+@e=;gc9>}7$z4gtCWftpRD|u zkNWF!`2bz+?(N$ShxCt_d)`5mw~?dZy^Z$qQgY_*nyc>@_m=o^C*m%#)XaCnV59)` z4AIMb(h|)w_aZ-A8DjS`dE>1z@_wgEs^P^{+_4xU-bc?TE(y9DWgeC?EFyb#9VIj7 znV5{nOUVX{!=u7PFpEs5o;_5lVOxAs&i6!8?VU9mwTk3$QSf)AqUGnRr8xge-ca@8D&+d`r3a_E_xZi8K|V%e z-bdF9KH@y+YV~FB)GT+yrJ$mt%%~6Bl;v!}7*mcyQbE^sT`|0=$pl#yVV62hIXU??#|fv`mJk^~U4EWgew&@n1?Lte zUH6IrFukq{N`5Gf?fN-#keP0}x14iT@XM3cL(0SfZaa?e%kIuGA0cI->?UWD-XEVh zzag_HUeUROR(@S2`6gfR)_9_isaF}uqkx5mMBoPRZ8pIcd@`N;GmmtNPbWw+h#x4%HawOb9zFRu!0Ah#TbZ)+SZw04q9 z#3aWa@zOdgV65;Ja!K0s5}Eh_Nc`xj7(Lw%KJL}xW?k=;4_4EW2e3{6ASuJ}8M<$0 z@jLY!9BD;Lggz$fO4B?sU50^&ly`M?<0ANSJ3F;v3OQ0U9y2+O3!n=b9=fYBk`Erp zKUb7j_J^gunoj4szgIQXmajX~OBsdBXHR#p)OvDxuX~@!4lQY4HNvq!M_>MDzY{=9 z_eck?uAZk``+juldVJLcN;;vKeebsY_>i&6XMhn>eZK& z6f+b%>IHY7IRtV9{|WYtJ_*&wg=uIq2bBDwAs z`>7FQT-%WMe5J@YrR*EkqVMmz()U`$q)p1V@ z8R&e`GBSdsEdgMoe*cAy?&JIyHj3F{+&aT6{lIMA`*}Rl{k|f*d0Yw$2q*e1-Q=tP zSNRl*?F%__L-Z>|6U)m!!_#M2F5L?z#Rt-5EZTgZL9_O;863TuroZZc^2`u<&-liSPRlJsN@H!oN*-Q6YGx&mceRxx| zvtvN4c$+*aTAh02+^LIr7n)i^eOCmJ-DkKPL&JfAE|_URyH;}kt~%D2^??v295Zem zNlydT+z(i!p`Sl*0>iu5h2Q*QxvBB)Tgh|%IW@?TfinA#1w8ws5lTV}j?8|e?4H}0q#dogG^||R`th=P4|f#+e?lNJ2<+Z zE@t{ILK!q}M5C&gb~P8#5SLSU!9in=o5LR^_;>Gv^tde%Y2UZPKPpTVRNK`%66-t{ zC_09Uev$5wJP$6;xEr*&G$|vanvVXZ&TjL;6-Xx!8T7c~N+}ouNs;1nicney+i$|K zfDrldGS)->`Qm4m5$|Qx*uFpEote{{`|^u8_)n|ud$hr5M<5p2fek42dEPxN7L9hA zNKl5{Cse;f?Iii2Ky>fx`Uc}&(jBeo*-wnp7~Ub}mUV&P6S(?rf2qA5Fql??jJouA z$efjB`tfd?X~y;IB!?w$z&NLeKcHGusvS!G3Y)l_V>Tm***xdLrrMRVm9h1>s(DD0 z$LHTI{}eghDkHAExstBn#AnZVw^@zr(23H#zG43<`5#V;ovxBGNMkGaZxlPU2D9rZ zws<-350w9^X4NNw9Ac+Sna@Ym7HYWP{Ao1V086!Kt|YPWepFv)Yu76HH)?0^ju?%8 zz|buxhMc3r@~ zq#v*l&XG{zL=#DTN46wGurjoLKu#TXscM>@Z7@Qerwl-7pgi0lVAbhb1EvHf1#sEg zq_3qd+O4A}Z!A-h2ZfvAUuuZmr#}rDzCgxwb9wj(>OjD0v8DdjE3#v=L3XFfzon&h z{D3+$T5$z+Hmj;lUlt9P=M}Wt&*7h=>-173j56enI<-@%0o|~NL?ZBVI^5)%5 z1G|JtA;hh#3OK@KAcexy1u^$eV^^*d?EI|H2zHIb+lcKr%Cnv}Wu0^)sc9}Nkc-Q9 zHGF?d%0Ey$IVsJ<^W&ZVp6n|^T4D5Bo}x{lT?!b0o#(r}QihjkOuO-pQ8zUruwEphRi zd&1XD&>W@nRO8K4M~rE*tnYg?evgQh6?K=5I?`UY1@-JNX+&=1&=69OwsD$=f#P}}0l#GTV^;pQi5EFvIrMda7QT=c$3tFP>x_Q4nr;w$iTlYdKRh!9$l+>O z)oIhRc}HL`VoA7j>gwyo_FRkHZYsUj&4{j;H~V_`Sx-Fk!*2R;V|_j5@d6{-6n(QI zZu|be6(a+4vhagW>u8qID9`P`DI;{hOp|J2JT?}E`hhGS_#SEK)KM)$Kq<8&(q$bk$glN8vXZ5 zq+%w9wH=MsD>TP?*M*t|DO#uCAmyj&2BmaJlL7bN7;2U@bp^`PLRFTo@F`BTUy%r{ zjNpkpSp5+Z=5r*;#$udiAN+GBq#`ggnz^;+F90SX)H9YG}F)6wEfx-dERe`ev@ z`?#||lK~42n&zSJT9uPByNpyROpdLz)uhRYMWl_9x! z6CoxnAnQP9g)@;VlPKx2nzd!D#@IKjMp<4)WUbNIe5P5Ne6Y-Pv1awafQao?e|*83 zYA?Fvc6sLT1kJe;@=N`GCEF8cRG*42*@W({bT;I|MtKk-o)`MT`Q{&Do(U~p^S&d6N{DS349KF`}uMu^BF%l;+oUaYWF_X zYmTH+^|%tgzkD{{(u+8XYrAH#(lwYd@}Z%6fl+77f@-G$@dalb^W8_AxoEdBrsYQt zo^u;%k4=8b)qR?uw#~U)`Gfb@9qJ-C5#prf+B$3hj6-g{nS*}zwn!e%LL}9nf*sCg zhh>742~nDg@Tv5OCQ;Hy4u1-48rVMi)cmyKc3R`8uu{|@;rS+%DgDyGVBQ&9#INOe zea%i!O#z2H52TH=TyCm^`a5fqlKIx|qzJOiQR|ZuomHe4S4&NPllu}|aZpOrg`vcl zm=t>jN%hMII@`HXx?XFJ%cEBXaCW`Jvl%Tq`4jIr7|xLl78r@PY|iCfK@+&I-=jVH zIjNyF5xs#K!@R)>iBR5~^4 z)QJ)7;!fwANh!&8m&vw%DMm3WclDJy79BMkgyz0VpCH3GindLd;&UStFLJVebhm-A zpPieqsJpIlzO3E+P%vnKdNNq6w~vW=Jv~H@&a%tet7w8oP>B~wiXZtJUyruH#p#pr=Vd4n;^0vqX^dpk{`V+Nnt>e^L zuTb=!=qJ99sALkJgm9YQp|c27)YsSNYmBgawLBHzXII&D7Ox_nxh6Tee*Zvk;-KK< zBWW{$obIemf{+FU?5Kv$&b*_yXZSof#5^mgtnlscgNa?K6n7N-+`MN#{ULhTvAwGc zANiQ(;c?Z-%lE>!LO`b9de~uWp_e%;=cf860)}jTV@{)QD)k3QP2DpeA9j?d(>aB5 zgNQwdZD-{KI_3iIgLw8K*Py$pzFr@sT{JNS%0Yfch|Q?2{T^Rm*#@i>&6+d3U7K>B zbbZ#yK)@g>Dym1R{`_%eEVT+~TNFU2dl)j*QU)*?GUyN34OlXs?zxmGvEoOidMYpy zNRVbN?5#qfXeh4TBl&kOV-%l7WP%j=TwN+XW z{_e(^d)MLxM$WGiAsGK^FXFpa;SXGo@%MTt6P|){)oG}a(ZqQ703Z}VJ3(9Lg-+l2 zSJlv??L3p;$<#JF2|Y!KmulQjMCC?{)LZg=p0m?^tu*)U<%50KbfZ;~tvtq~?e&9b zb}xkfSc2BxfLhHlC$uw*}lB_<;Uls#_XO|M)o7fEZw$3QAS2&0vaZvt_^DMq2eDO z*-#@688mnIm5q(-$Xcg+6ciNDPY2FrrrBx`lT#~wLt%AmgdBbV8gUPp*z&WoW_z+? zp`g9TgZDkG7bGVqWQ09PCrFQxDt;xZAaN*v1&DNEbQCK7uVjr!KlAiLeN{=Gi5AvE ziQFLqr9eeyXlMwEhj9ULitag`k&SU5zUr!~boC0gg`0@CV*)2D3rby~$5qQ0CM~G? zWwgRa@H0iDfV;O3(xHwO%C<=!XuJ0aAP`k=Pv)f<-kW?LPXCT?+JwSO-v}821+kBCMFK*N&oY6 zm~1GBRw=i)0THGiB?Ll5`1mf{4?!6&m_cP~anS*iQK!H9ojt-6Q?1DtAdfCzH$1SYaZI5wG03b2p|!lM&`xIki7&g zx&pxPP+IBh)1TmFo*}Mh9hZ;YR)wY)pzjS5VNe#0jzvOkMS|cjKY3NORP~BO5W;~@ zDez0+ta&&?XKfX|PDS+(|JIz8EK*l#8h_*)R?Z?TO7Wzo_}E zJ$Fq0c#GWv@;0Dh2D%X>9IvB6)d5TfqSWPN*fr2E-?)kC3<{|*0HLTYL4x>RadJm; zLjdJK9s~pqE@NYZqBR~83o$}|{Ely9Za^YVUjB1xssU8#2i%dU^gf5cIWau^epiTC zs3|ES1Fr{AB6vTjTb|4g?@v(Z$6F4*a^V7MxAd4IqSMCTXT!q({%{uL1zx{?4IdE_ z5<-piOVX2f@aY6)9VlDem}#YCWwosJrGN{*%|4mCXP5hO^+1w91DX^8b_!+(H8SHP%kU(eKKLgZLO`K(a{aE1SjgrHgC}ohscvjdHSQKW*QPrbn!P5 z?@*6x5p(>w_P5|}fYn-nWr1Q=Y!Z%}Fxk}aPbNFcJ0U^m#fuTpz=M{0%&_AB{ISTw zJ;R?Yc(QdnKo+tIwkLqqAQK632x^qYv5z15Ehbds-=d^EI5+^QKrZcC95wb!h^mvV zeG|(=7^=L?XWQaINQ%X%kLAGw=>=iLO7F=|c29=Ug}QraI<`Ibi4jswyAyS|aO>~7 zp2zQelNXw6f&c_4qJWsz{^8!TSx^-lt+1OWj75S;=$z|O>kYN+!2Hy#oX5omGxC(smS4zwBIui&gRY>TTy ziA%xEW1%5#6P(;C4kU@VKuiOg#-M~dwX|>v32CfuA*jEe9(2(!q?0>9Exxglftor= zy}}V@4GR|l#^&(bIVJ+ea(gkCbIYkInI>9V?eXTiAQ*S? zIxWnt2-RpSFX~t{4}?wy*RDM<=%R-fAO;2{&_C?@^}~-L1z>QV{Mj0=Fj0@E?M&-0 zVKdZ`X{c3H6_QzaPAw40>-dI}Vri^Sh`^g%R!)kL%`mB=!U?>nswTOZbhWa+x;hY{ zd$e$K`S`>_*jG=O?X<-|27$)(j0|40iCPdW1Hm14(vzs9hrH&@;wM;zSLN}IU)YoQnP4X=3!FFd=AcRb{XDn^iV;^rPy+CITNyl5%l7?8+{1 zP~)9kPA@hy6I!i-9N{*|1Z61arNB_ap533%tf*|-@E9nyX^*uwd5o;7`_z?c@EI40 z#cP6YIp3^Ga7euoVm(|AGAf!i0;gwid;o}=KG9q?#C zjuZq=p(UUS?GektdNbc}sU_Wu9eFP^Wmw&_tt~%oA*KRdNkuCi5r@-r@}+Zev2AlC z51g@NAn~cM&jn8fS}Trwiv^GjBtR->6#x*o>ErZnAaO5>I5{~iB4LT9z zfvDi~*CU?vMWu>ZbS-6J-7e>JsSo?t_I_$MydvC%VBf@ZG%=$^LC0opNphXBh$Og z9%~7!@e$+pBAeJMlfgONsR7Mbnq4M;WRv6GX6kWbH>gesLAqpt*|zT+&AL;s4Jt)N z?6@T=#%)RPXyGZO!+B4eH$UHsd`gnS*iHv~i z8Z1|?D@22j+>zJvg>cSSJ@-su)h~IotwX*)^P{XROuo>ODH;+Ky~Nh&jOp^J#toYh z*##5#GzXhrHh&m?ZnxFE)6?Y5TG5fDr9IdrXLI<`RxYLcL21An*6Hf)Pr74Gq|Nqw zT>}Z}kc+#m5ZCkNc5svjm+uX7aO-=2H+{`8)vDS|%_5K_@0(`Yo{!b&Umh;k=xP0tcC5M{WoGViP*p}gEw^;oe6@TKrZBBUwWo&%X*N6djBTy< z<=&E|LGk^UyEm$ANyHFi-?-H zo|#$KX9=0%Bcy5nWNoqX!VqarqSu7Ah>4o6=eh;zt$yuUGd_$W>+97YZ{z$ZlToFL zi51QiQ_`6AE#l*K{s(R9UQ3-iRXzdI)PJcYrUjTMA z%JCK6wG+N_snDGO@TlO+0krKvjtb68C6N0D&k@9oxh!TzKtmApT!TIrR(uhCsGj?( zUnH&7=Yx@x-{$YElEj;sqyu|UHn6p9SCG<`#DMv8DjKckmo>wnL$UEWnd9snd!}lJ z!9dSY@1kN4R#dA^X{;=5faT06X}M*3b5ioPWLX^7Z;h#zsj>#GO}D2tZ1892cvv8@ zRIYVSR<`rJKu?*Yl2@PT=k$oR$K}>qEp>cb8~ zA-$b;j|v=r;cc)V?rNrjHy1tQu>HN*OHI4OBA_0fR5?7QW8fh6(-W4rP&0>qI zi{!+Cbf!lpAeDo7X2xPX@CM|mz_W$iNeUcz>P`opKn_85;Mm?ikAX2gO#5+$&9&kMZj{Hjv4)`yXQp3aK254W$cSEBVQtX0|o!q6|*6u;9}+m)SK_qWkelS7f+kS=X5)csr8cwXoNIFN->EqFKkgNH&qV@9}gNuIPsmyZz|TBIgq zKn2_3f>gNe8{H%OTbkCy3f3Y%LO8BVyuWg+X$Vm(C{vl?7t&f-CFwev=CLJw>nxY9 z+=)7#`jMa~1k@~uw;)o4AQrY0r9oUmd5pF)i5>PIONu@bloZ>nTgr;?qNEoYX!g6Alo2T>EW=tdVaLk#ZEez zW!m65?2X&Q;@8eoRM3fwmG@Wn@$SXS-f2%wi}JTuS(TELD0{x+)SdI@i+zCz@pO1r zPPez*1Juk%CVbumYa)CZF?mw)J96$3mH!=yP)~@~AbJmPEl5Daxu&6<7kO+L* zH!GlyIRg1NO+q@5KwEKQ3vQUyf#|eA@fLKZYVZ^mzZxRFrWt;Df zl(dZ7^Hqa~;Jh0u##b+K6dFCEQ@RoQC8)a#Ch28g&i>S$-1)IAi|QU#11BD`a)aM# z@it@ux_;<7pS?o!o^$B+Knl+B%%_V^3=SV8SB|pV>uP!OB~?j_ZD5`B8jO5dsP~as zs<8FDgDL2!@V|PyuCS)ow97e10Si?aMXJ(^pwa{q4ne>}4+2sYP(Zqd-phz0itUIX zz4t&sq=OJaiu6v1l-TGsARr|$FT{Ut{<)Z|*_S*I*?TA7e)n4IeZRGA=LWn!MDp?k zOKX#t^@fK&6F6oz%YJ}>yX-=u6_)bI}lj-Z5sw4g(|Am7EJBnunOLsh!Qny z43ZSmkJ00;@)0M`b@43Ssqk5-awOis&)nm=kFWEe*`b%`3^=*5!tN!Rd}$zKPvr86 z5{-YHD-$!?36838GZY7vTER*rKh>--FhLJ_|6~`M(BslSL)pNIeK*Mrz~AzyTbFf$ z23247GNBKeI?x$9(>_I1^jd>_5rADGEzQuN5;q#C_4sDqHxY~Zg8*2E#JVLbEoTv(tB-2eUKcs~_^wLjxw+Bomvh9Afi5F87bebY zDnX*cVXl{dy8>zp7UT2{)OT zXkxr-3S+FmmO!S2v?gm;WU4PiHFgU1ybv}3f{j2%t z)6&wwIvBjvL3g$TNUpqgILgJIF+53$wa(tA$7I)I+AZ}sRWMM|0W)4OsKo$MUVjbu z3iU%!S$P-iNd1x{$L8se93ii*!9+deL+<8iUSBD5m>W&Ut@ennHZuPHh$@}^Gg2LN z5GXkyKEnqYxW}dw$s-s%&a9-lyL-tn>koAL<5hFV{qGnpDJrT7M&#WVaAJ@QUuk85 zUoJ>?kZu8&ywgagiPE8N|3b5lRk~dkqf9Nm1riSj>|`>=8Woq9gF9FdY)gV;b?!cm zNC~y>rwTv@0(jW>_x8HEy2dJc%|h4%m~3Lks>^P-zlJD8P)^v=THIvU8@2>Xo?Ngk zJ}F@UjqbyAT^}Rc_cG!)DaQkkQ=cTQbO7ey?TiGO9?k}Y(bLm&ptpDPV?5z&M3uO~ z{NZE#7j~}#d(uY>&uGnmdLOq^@Vhw^Z?*8rYVluP#(pE>&*=a+derWKOAKS-ImZ&DZe z>}-7JJlE`L`7UMk*22WQ!a`i5wXMx*C2p@Kt2TsgD4z6LujG4a`blBPvF9(p2`WV2 zX!@JHq+a`y*+;-7oR2=@2%E97`W?g54xp)qhp-+sEBIOpBuhH#$?FId?BOzlQVKkp>as zQ!(gG%HGFBk$Vo&`}1QWmoG|_p1YP%JP6qX;Y>rcT|YcZf>sp)xyTnuH~T4erCj>? zg;Eofq0hA7Qsy?p)g-FrJ;CN7=Hw~04LupoShGnU&;Nkv#ft*ZEvmxzkI0x5>h4HH zRxed5GeQ|?tvDg<>(h~JX^^2;n<8{v(fcOgV2&b7pel21P13Tn1L7UEZJT_WZWE#% zrZZAjPdzKHZ$j=`IFp{*JjYO(`f>xukqc8%Cf6d*weXzz*> zqb=mDx2wYpHI64Cik=WYkKVt_Xzq##B=n8t%*mZMwypGAc*{rMCO&m?@c;05|Jymt z^|I-nzK<~b)S zms)K~hJAj|@dVFF8S6uDW0~U%NT6lRmlr z3;&V^52GCV(!IuLv*|V-h$wG8XK=V_r%)Y3`GQO{jBzpg!RX%3UGsOv;^kGg>4Ew` zKUG%SPt6bcIGll1N4<$6O9XTqCBK(_?zf?0hQ8E?_v$Glejy~oJTeUp-TVDnfVnPB zb#-H7UF3F#j!?k%`ZGRy(N>=X(-C`2Oq;EL^cqZ}h?4~ARqZB6lIpb+hzbCEKVo_Y zqUihaY`w|BGuqy*i7w)*b+lq}4wu;%@!6-IgHPXlCMr(?iARa+US z-N%@3eRwilrx(rIr&ENbt}c?yR>mAS!@F{rmR^ij)fd;8Bzw8HNER@47^Z3sIwABn zH_9`Sx}BDXG998b|lhcXU*cw*5Fx4KbP4 z33_h63wsqV@8FX6=w*#wZy-9k!GT!U|9nuJ_GyYS>WV(|10?7Dw?6DR_uvgY{UAK} z!iEL_dUVee9Zun)7UnDzco$|~oJmoV^Cs_N>2o&zGxCbaQi+~#yN0_q7M2z+yzW8X za7$X-*25iT_gGvzwX-&;J$0p$I9yX1Q?df6tpgysi^#Om}CFF%Vu!^SJJ`zei)^eotGCE6h{C$$Vg!HGe482CFkf}M&gQPk?o6*e8_m4|1zorb-*J6@r zv$e#;HDgH>6U%cI_^y;2wV!JauTBy2O>*T|Ui$;A($LTVJ=dDfFI)f!+WdaZA5DZL zz`nC*8wdoGdA_eT0UL#JW+(LVnm}uqm5wR5b>_#+$v9;G^dnT?*3Nk`<|<)Ogu-Bs}-oMHc)dfc2z7xxj~i;jhrNJbR%dRSpKOelSblm6PQa*x`b5!q>5;U^!u2h$IopeI!*2inhXmkcVGAT)AE8-Jshk$kji%@Ky9YOIUh8x2|!h6uQk?u~?;C5(sA^Z8o=h`^JbjBFwcj!x5HQ-Ze~!EE;gg_6Ux+LK>^85=K4Q*V#gXg(c3(Wn>BDzNs2 zR8eSA=eyFgX%m9Y!!mNp4UkB92wuH$AP2*wgt0VnCGFn{RA>)Aa7<^bX>T#t&LUbpjQv06bHG3a0O^BYG;jx jChHKjkp2(<)VN0@erJkD%4Mrye^qO$>!}s0-VFU0D_$`L literal 140629 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�)ct7}8W#Cc;j47eE1NSw;Q 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 B1B?Iw From 516990505c4b7ba3616fb365715b60dce0a24b7a Mon Sep 17 00:00:00 2001 From: colombod Date: Wed, 2 Sep 2026 17:17:24 +0000 Subject: [PATCH 30/39] docs(server-data-ops): trim narrative/decision-history comments to why-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comments-only pass across the delete feature's production code -- no logic, signatures, or tests changed (verified via AST diff with docstrings stripped: identical). Trims prose-heavy comment/docstring blocks that accreted during iteration down to short module docstrings plus terse why-comments, per the repo's self-explanatory-code standard. - agents/server-data-ops.md: condensed the two frontmatter YAML comment blocks (exclude_hooks 'matched pair' + agent-scoped lockdown) from ~31 comment lines to ~10. Agent body instructions untouched. - hook-server-data-ops-lockdown/__init__.py: the module docstring was a 126-line narrative including DTU-eval incident history ('SUBTREE LEAK, found via a DTU eval...', 'DELEGATE-TARGET LOCKDOWN, added after a behavioral DTU test proved...'). Condensed to a 27-line docstring plus short why-comments on DENIED_TOOLS/ALLOWED_DELEGATE_AGENT and the handler/mount docstrings. - tool-server-data-ops/__init__.py, delete_session_tool.py, session_summary_tool.py, whoami_tool.py: condensed module docstrings that re-explained resolve_query_connection()'s selection algorithm (already documented at its source in tool_resolver.py) down to a pointer + the genuinely load-bearing facts (permanent/no-preview, read-only, why WhoamiTool is shared). - context_intelligence/client.py: reviewed only the lines this branch added. Removed the 'was the original bug' decision-history sentence from AsyncCIClient._auth_headers's docstring, and condensed the duplicated 'NOTE: headers is computed by the caller...' explanation in _http_get_strict/_http_delete_strict and the CIClientError.error_type attribute comment to their essential why. Verification: ruff format + ruff check + pyright clean in each module's own venv (root repo, hook-server-data-ops-lockdown, tool-server-data-ops); full root test suite (831 tests) and both module test suites green. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- agents/server-data-ops.md | 41 +--- context_intelligence/client.py | 38 ++-- context_intelligence/whoami_tool.py | 50 ++--- .../__init__.py | 180 ++++-------------- .../__init__.py | 31 ++- .../delete_session_tool.py | 31 +-- .../session_summary_tool.py | 36 +--- 7 files changed, 100 insertions(+), 307 deletions(-) diff --git a/agents/server-data-ops.md b/agents/server-data-ops.md index 5b9a6cca..9e68e7a0 100644 --- a/agents/server-data-ops.md +++ b/agents/server-data-ops.md @@ -23,20 +23,10 @@ tools: source: git+https://github.com/microsoft/amplifier-foundation@main#subdirectory=modules/tool-delegate config: settings: - # Prevent this agent's own lockdown hook (declared below, hooks:) - # from leaking onto any session this agent delegates to (e.g. - # graph-analyst, which legitimately needs graph_query to run the - # searches this agent delegates to it). Hook inheritance from a - # parent session to a spawned child is ADDITIVE BY DEFAULT, exactly - # like tool inheritance -- see tool-delegate's own - # settings.exclude_hooks / settings.exclude_tools symmetry in - # amplifier-foundation's tool-delegate module. Without this entry, - # hook-server-data-ops-lockdown would also mount on graph-analyst's - # spawned session and deny ITS legitimate graph_query calls -- this - # is exactly the bug a DTU eval caught (Flow 2-folder failed - # entirely because search never ran). See - # hook-server-data-ops-lockdown's own module docstring for the full - # story. + # Hook inheritance to a spawned child is additive by default (like + # tool inheritance) -- without this, our own lockdown hook would also + # mount on graph-analyst's spawned session and deny its legitimate + # graph_query calls. exclude_hooks: [hook-server-data-ops-lockdown] - module: tool-server-data-ops source: git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=modules/tool-server-data-ops @@ -48,23 +38,12 @@ tools: - module: tool-todo source: git+https://github.com/microsoft/amplifier-module-tool-todo@main -# Agent-scoped lockdown: this agent must never call write_file, edit_file, -# apply_patch, or the graph-query tool, regardless of how it comes to have -# them available (inheritance, a future tools: change, etc.). Declared here -- -# on the CONSUMING agent -- rather than as a behavior-level exclude_tools -# policy, so the restriction never collides with sibling agents (e.g. -# graph-analyst, session-navigator) that legitimately need these tools. -# -# This declaration alone only bounds WHICH session mounts the hook (this -# agent's own session, since it is server-data-ops's own hooks: entry). It -# does NOT, by itself, stop the hook from leaking onto sessions this agent -# delegates to -- hook inheritance to a spawned child is additive by -# default, the same rule as tool inheritance. The tool-delegate entry above -# (settings.exclude_hooks) is what actually keeps this hook from denying -# graph-analyst's own graph_query calls when this agent delegates search to -# it. The two declarations are a matched pair; removing either one -# reopens a gap (this hook leaking to descendants, or this agent itself -# regaining unrestricted tool access on a future tools: change). +# Agent-scoped lockdown (write_file/edit_file/apply_patch/graph_query), +# declared on the consuming agent rather than as a behavior-level +# exclude_tools so it never collides with sibling agents that need these +# tools. Matched pair with tool-delegate's settings.exclude_hooks above: +# this bounds the agent's OWN calls; that one stops the hook leaking to +# delegated children. Removing either reopens a gap. hooks: - module: hook-server-data-ops-lockdown source: git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=modules/hook-server-data-ops-lockdown diff --git a/context_intelligence/client.py b/context_intelligence/client.py index 05f7adb4..f8f98829 100644 --- a/context_intelligence/client.py +++ b/context_intelligence/client.py @@ -68,12 +68,9 @@ def __init__( ) -> None: super().__init__(message) #: One of "connection_error" | "timeout" | "http_status" | "decode_error" - #: | "auth_error". "auth_error" means the credential itself (api_key or - #: Entra token config) was unusable -- empty, the "[REDACTED]" sentinel, - #: or an unexpanded ${VAR} placeholder -- and was never sent to the - #: server. It is raised BEFORE the request is attempted, so it must - #: never be confused with "decode_error" (a genuine bad-JSON response - #: body from a server that was actually reached). + #: | "auth_error". "auth_error" -- an unusable credential -- is raised + #: BEFORE the request is attempted, so it is never confused with + #: "decode_error" (a bad response body from a server actually reached). self.error_type = error_type self.url = url self.status_code = status_code @@ -177,11 +174,9 @@ def _http_get_strict(url: str, headers: dict[str, str]) -> Any: CIClientError error_type one of: ``connection_error`` (refused/DNS/reset), ``timeout``, ``http_status`` (non-2xx; ``status_code`` set), or - ``decode_error`` (body is not valid JSON). NOTE: ``headers`` is - computed by the caller BEFORE this function is invoked -- an unusable - credential is classified as ``auth_error`` by the caller (see - ``CIClient._auth_headers`` / ``AsyncCIClient._auth_headers``) and never - reaches this function at all, so it can never be misclassified here. + ``decode_error`` (body is not valid JSON). ``auth_error`` is + classified by the caller before ``headers`` reaches this function, + so it never appears here. """ if _requests is not None: try: @@ -278,11 +273,8 @@ def _http_delete_strict(url: str, headers: dict[str, str]) -> Any: ``timeout``, ``http_status`` (non-2xx; ``status_code`` set -- this is how a 404 "unknown session" or a 409 "still receiving data / ambiguous id" reaches the caller), or ``decode_error`` (body is not valid JSON). - NOTE: ``headers`` is computed by the caller BEFORE this function is - invoked -- an unusable credential is classified as ``auth_error`` by - the caller (see ``CIClient._auth_headers`` / ``AsyncCIClient._auth_headers``) - and never reaches this function at all, so it can never be - misclassified here. + ``auth_error`` is classified by the caller before ``headers`` reaches + this function, so it never appears here. """ if _requests is not None: try: @@ -815,16 +807,10 @@ def __init__( def _auth_headers(self, url: str) -> dict[str, str]: """Return the ``Authorization`` header dict, computed per-request via strategy. - Called BEFORE entering a method's request ``try:`` block, so an - unusable credential (empty api_key, the "[REDACTED]" sentinel, or an - unexpanded ${VAR} placeholder) is classified as its own ``auth_error`` - -- it is never sent to the server, and never confused with - ``decode_error`` (a genuine bad-JSON response from a server that was - actually reached). Calling ``self._strategy.headers()`` INSIDE the - request try block was the original bug: a credential ``ValueError`` - would fall through to ``except (ValueError, json.JSONDecodeError)`` - and be misreported as "malformed JSON from {url}" even though no - request was ever sent. + Called BEFORE entering a method's request ``try:`` block -- an + unusable credential is classified as ``auth_error`` here rather than + falling into the request's own ``except (ValueError, json.JSONDecodeError)`` + and being misreported as a decode failure. Raises ------ diff --git a/context_intelligence/whoami_tool.py b/context_intelligence/whoami_tool.py index d11e7b61..cd34b365 100644 --- a/context_intelligence/whoami_tool.py +++ b/context_intelligence/whoami_tool.py @@ -1,40 +1,20 @@ """WhoamiTool -- agent-facing tool that resolves the acting user's identity. -Lives in the shared ``context_intelligence`` library (not in either tool -module's own package) because it is mounted from TWO independent modules: -``tool-context-intelligence-query`` (graph-analyst and any other agent that -only needs read/query + identity) and ``tool-server-data-ops`` (the delete -agent, which needs identity for ownership checks but must NOT have -graph_query). Neither module owns this class -- both import it from here so -there is exactly one implementation, never two copies drifting apart. An -agent that mounts both modules would collide on the tool name "whoami" (the -coordinator would attempt to mount it twice); no current agent does this. - -Implements the Amplifier Tool protocol. Configuration and provenance are -resolved via ``resolve_query_connection`` (same as GraphQueryTool and -BlobReadTool -- parity guaranteed by the shared helper), a SINGLE-HIT -selection over the connectable pool (tool ``sources`` union hook -``destinations``). See ``resolve_query_connection``'s docstring in -context_intelligence/tool_resolver.py for the authoritative selection rule -(in brief: explicit ``source=`` reaches any pool entry; with no name, -1 source -> it, 2+ sources -> fail loud, 0 sources -> the FIRST destination -in config order for any N, else env). - -Every result (success or failure) carries a ``source`` field naming the -endpoint that answered / was attempted. Callers can also pass -``list_sources: true`` to discover the connectable set without calling the -server. - -Each mounting module builds its OWN ``ToolConfigResolver`` (its own config -namespace: overrides.tool-context-intelligence-query.config.sources vs -overrides.tool-server-data-ops.config.sources) and injects it at -construction time -- this class never constructs its own resolver. - -This tool never talks to the server directly -- the only path to the server -is through ``AsyncCIClient`` (the shared library). This is a READ (no -changes made): it resolves who the server thinks is making the request, -so an agent (e.g. the delete workflow) can compare it against a session's -``created_by`` for ownership warnings. +Lives in the shared ``context_intelligence`` library, not in either tool +module's own package, because it is mounted from two independent modules +(``tool-context-intelligence-query`` and ``tool-server-data-ops``) -- both +import it from here so there is exactly one implementation. Each mounting +module builds its own ``ToolConfigResolver`` and injects it at construction +time; this class never constructs its own resolver. + +Implements the Amplifier Tool protocol. Endpoint selection goes through +``resolve_query_connection`` (see context_intelligence/tool_resolver.py for +the authoritative selection rule); every result carries the resolved +``source``. + +Read-only: resolves who the server thinks is making the request, so a +caller can compare it against a session's ``created_by`` for ownership +warnings. """ from __future__ import annotations diff --git a/modules/hook-server-data-ops-lockdown/amplifier_module_hook_server_data_ops_lockdown/__init__.py b/modules/hook-server-data-ops-lockdown/amplifier_module_hook_server_data_ops_lockdown/__init__.py index d0041416..6b5198ea 100644 --- a/modules/hook-server-data-ops-lockdown/amplifier_module_hook_server_data_ops_lockdown/__init__.py +++ b/modules/hook-server-data-ops-lockdown/amplifier_module_hook_server_data_ops_lockdown/__init__.py @@ -1,128 +1,32 @@ """Agent-scoped lockdown hook for server-data-ops (the delete agent). -This hook is registered on the `tool:pre` lifecycle event and denies: - - exactly four tools outright: `write_file`, `edit_file`, `apply_patch`, - `graph_query`; - - any `delegate` call whose target agent is not exactly - `context-intelligence:graph-analyst` (see "DELEGATE-TARGET LOCKDOWN" - below). -Every other tool call is left untouched (`continue`). - -Why this exists, and why it lives here rather than as a behavior-level -`exclude_tools` policy: tool inheritance in this ecosystem is ADDITIVE by -default (a spawned agent gets everything its parent session has, unless the -PARENT's own tool-delegate config excludes it) -- see -amplifier-app-cli's session_spawner.py `_filter_tools()`. A behavior-level -`exclude_tools` restriction is a BROAD policy: it applies to every agent -composed by that behavior, and collides with any other agent in the same -behavior that legitimately needs the excluded tools (it must then -re-declare them explicitly to opt back in). That is backwards for a -security-sensitive restriction that belongs to exactly ONE agent -(server-data-ops, the delete agent) -- the restriction should be owned and -carried by the CONSUMING agent itself, not imposed on every agent that -happens to share a behavior with it. - -A `tool:pre` deny hook declared in the agent's OWN frontmatter (`hooks:`, -sibling to `tools:`) bounds WHICH SESSION MOUNTS this hook module: only -server-data-ops's own session, since only its own agent definition declares -it. That much is true regardless of anything else. It also holds even if a -future change to server-data-ops's own `tools:` list (or to what it -inherits) were to re-introduce one of these tools -- the deny is enforced -at call time, not just at composition time. - -SUBTREE LEAK, found via a DTU eval and fixed alongside this hook: mounting -the hook on server-data-ops's own session does NOT, by itself, stop it from -also reaching every session server-data-ops SPAWNS. Hook inheritance from a -parent session to a delegated child is ADDITIVE BY DEFAULT -- exactly the -same rule as tool inheritance -- see amplifier-foundation's tool-delegate -module (`settings.exclude_hooks`, which mirrors `settings.exclude_tools` -field-for-field; both are consumed by `_spawn_new_session()` to build an -inheritance-filtering policy the app-layer spawn capability applies to the -child). Left unexcluded, this hook -- mounted on server-data-ops's own -session -- ALSO mounted on graph-analyst's spawned session whenever -server-data-ops delegated search to it, and denied graph-analyst's own, -entirely legitimate `graph_query` calls. That is precisely what broke Flow -2 / Flow 2-folder: search never ran, so no candidate sessions were ever -found to delete. - -THE FIX: agents/server-data-ops.md's own `tools:` entry for `tool-delegate` -sets `config.settings.exclude_hooks: ["hook-server-data-ops-lockdown"]` -(this module's own id). That setting -- not anything in this file -- is -what stops this hook from being composed onto any session server-data-ops -spawns, while leaving it fully in force for server-data-ops's OWN tool -calls (its `hooks:` declaration is untouched; only its inheritance into -descendants is excluded). With that setting in place, this hook has no -effect on graph-analyst, session-navigator, or any other agent -server-data-ops delegates to -- but only because of that setting. Removing -it reopens the subtree leak even though nothing in this file's own deny -logic changes. - -Why the fix is not "check which agent/session is calling" inside the -handler below: the `tool:pre` event's documented payload carries ONLY -`tool_name` and `tool_input` -- see the reference emit call in -`core:docs/contracts/ORCHESTRATOR_CONTRACT.md` -(`await hooks.emit("tool:pre", {"tool_name": ..., "tool_input": ...})`) and -the field table in `core:docs/contracts/HOOK_CONTRACT.md` -(`tool:pre | Before tool execution | tool_name, tool_input`). No session -id, agent name, or other identity field is part of the documented -contract, so a handler receiving `(event, data)` structurally cannot tell -"server-data-ops's own call" apart from "a descendant session's call." -Session-scoping has to happen at the delegation boundary -(`exclude_hooks`), not inside this handler. - -Contract references (verified against amplifier-core docs before writing -this handler): - - `core:docs/contracts/HOOK_CONTRACT.md` -- protocol is - `async def __call__(event: str, data: dict[str, Any]) -> HookResult`; - the `tool:pre` event's data dict carries the tool name under the key - `tool_name` (line 272: `"tool_name": "Write"`) and the tool's arguments - under `tool_input`. A denial is `HookResult(action="deny", reason=...)` - (line 94). - - `core:docs/HOOKS_API.md` -- `HookResult.action` is - `Literal["continue", "deny", "modify", "inject_context", "ask_user"]` - and `reason: str | None = None` (lines 44/48). The worked `tool:pre` - example (lines 271-274) reads `data.get("tool_name")` and compares it - against a list of tool names, confirming both the field name and the - plain-string comparison pattern used below. - -DELEGATE-TARGET LOCKDOWN, added after a behavioral DTU test proved the -`agents:` frontmatter allowlist (see agents/server-data-ops.md) does NOT -close this hole when server-data-ops runs as the ROOT agent (no parent -session to apply a parent-side allowlist filter against). With server-data-ops -spawned directly, it called `delegate(agent="foundation:file-ops")` and that -delegate wrote a file to disk -- verified. The `agents:` allowlist is only -enforced by the app-layer spawn capability when a PARENT spawns THIS agent -(amplifier-app-cli's agent_config.merge_configs / session_spawner.py -live-registry reconciliation filters the CHILD's roster against the -allowlist declared on the agent being spawned); as the root/direct agent -there is no such parent-side filtering step at all. This hook, in contrast, -fires on server-data-ops's OWN `tool:pre` calls regardless of whether it is -the root agent or itself a spawned child -- so the restriction has to live -here to hold in both cases. - -Field name verified against the delegate tool itself -(amplifier-foundation's `modules/tool-delegate/amplifier_module_tool_delegate/__init__.py`, -`DelegateTool.execute()`: `agent_name = input.get("agent", "").strip()`), -and against the documented event contract above: `tool_input` IS -`tool_call.input`, i.e. the exact dict passed to `tool.execute()`. So for a -`delegate` call, `data["tool_input"]["agent"]` carries the target agent name. - -Allowed value verified against this repo's own agent roster: server-data-ops's -own `agents:` allowlist (agents/server-data-ops.md) already names -`context-intelligence:graph-analyst` -- the namespaced form, matching how -behaviors/context-intelligence-analysis.yaml registers it -(`agents: include: - context-intelligence:graph-analyst`, composed under the -bundle's own namespace `context-intelligence`). ALLOWED_DELEGATE_AGENT below -uses that same namespaced string; a bare `"graph-analyst"` would never match -a real delegate call (delegate's own callers use the namespaced roster key), -so requiring the exact namespaced string is not extra strictness, it is the -only string that will ever legitimately appear. - -Fail closed: a `delegate` call with a missing or empty `agent` field (e.g. a -malformed call, or one that omits `agent` and instead supplies `session_id` -to resume an existing delegation) is DENIED, not allowed -- the handler -cannot confirm it targets the allowed agent, and "cannot confirm" must -resolve to deny, not continue, for a security-sensitive gate. +Registered on `tool:pre`; denies write_file/edit_file/apply_patch/graph_query +outright, and denies any `delegate` call whose target is not exactly +`context-intelligence:graph-analyst`. Everything else is left untouched +(`continue`). + +Declared on the consuming agent's own `hooks:` (not a behavior-level +`exclude_tools`) so the restriction is owned by server-data-ops alone and +never collides with sibling agents that legitimately need these tools. + +Hook inheritance to a delegated child is additive by default, same as tool +inheritance -- left unexcluded, this hook would also mount on +graph-analyst's spawned session and deny its own graph_query calls. +agents/server-data-ops.md's tool-delegate `exclude_hooks` entry is what +prevents that; this file's deny logic alone does not. + +The delegate-target check exists because the `agents:` frontmatter +allowlist is only enforced when a PARENT spawns this agent -- it does +nothing when server-data-ops runs as the root agent, which let it +delegate to arbitrary agents (verified: `delegate(agent="foundation:file-ops")` +wrote a file to disk unchecked). This hook fires on server-data-ops's own +`tool:pre` calls regardless of root-vs-spawned, so the restriction has to +live here to hold in both cases. + +The check inspects only `tool_name`/`tool_input` (the documented `tool:pre` +payload -- see core:docs/contracts/HOOK_CONTRACT.md) rather than session/agent +identity, because no such field exists in the contract; scoping is therefore +enforced at the delegation boundary (exclude_hooks), not inside this handler. """ from __future__ import annotations @@ -146,12 +50,10 @@ "queries the graph directly (it delegates search to graph-analyst)." ) -# The only agent server-data-ops may delegate to, declared here as the single -# source of truth (see the module docstring's "DELEGATE-TARGET LOCKDOWN" -# section for how this string was verified: it is the namespaced roster key -# behaviors/context-intelligence-analysis.yaml registers graph-analyst under, -# and the delegate tool's own `input.get("agent")` is the field that must -# match it exactly). +# The only agent server-data-ops may delegate to. Must be the namespaced +# form (matches behaviors/context-intelligence-analysis.yaml's registration +# and the delegate tool's own input["agent"] value) -- a bare "graph-analyst" +# would never match a real delegate call. ALLOWED_DELEGATE_AGENT = "context-intelligence:graph-analyst" DELEGATE_DENY_REASON = ( @@ -165,20 +67,12 @@ async def _deny_lockdown_tools(event: str, data: dict[str, Any]) -> Any: """`tool:pre` handler: deny DENIED_TOOLS and off-target delegation. Only ever registered for the `tool:pre` event (see mount() below), so - `event` is not branched on here -- the registration itself scopes when - this handler runs. - - Two independent checks: - 1. The four DENIED_TOOLS are always denied outright. - 2. A `delegate` call is denied unless its target agent is exactly - ALLOWED_DELEGATE_AGENT. This closes the delegation-bypass hole: a - behavioral DTU test proved server-data-ops (running as the ROOT - agent, with no parent session to enforce its own `agents:` - frontmatter allowlist) could call - `delegate(agent="foundation:file-ops")` and have it write a file to - disk unchecked. A missing or empty `agent` field is denied too - (fail closed) -- it cannot be confirmed to be the allowed target, - so it is treated the same as an explicit mismatch. + `event` is not branched on here. + + A `delegate` call is denied unless its target agent is exactly + ALLOWED_DELEGATE_AGENT. A missing or empty `agent` field is denied too + (fail closed): it cannot be confirmed to be the allowed target, and + "cannot confirm" must resolve to deny for a security-sensitive gate. """ from amplifier_core.models import HookResult # local import: peer dependency diff --git a/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/__init__.py b/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/__init__.py index ecc905d8..70f4b8e0 100644 --- a/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/__init__.py +++ b/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/__init__.py @@ -1,22 +1,13 @@ """Context Intelligence server data-ops tools -- session_summary, delete_session, and whoami. -All three tools share one ToolConfigResolver, so sources has a single -config namespace: overrides.tool-server-data-ops.config.sources. - -WhoamiTool itself lives in the shared context_intelligence library -(context_intelligence/whoami_tool.py), not in this module's own package -- -it is ALSO mounted by tool-context-intelligence-query (graph-analyst's -module). The server-data-ops (delete) agent needs identity for ownership -checks but must NOT have graph_query, so it mounts THIS module alone -rather than tool-context-intelligence-query. Importing the same class -from the shared location keeps the two mounts in lock-step with zero -duplication. No agent mounts both this module AND -tool-context-intelligence-query, so there is no "whoami" name collision. - -Three tools, one mount(): idiomatic multi-tool module (same shape as -tool-context-intelligence-query, which mounts graph_query / blob_read / whoami -from one mount() call). +All three tools share one ToolConfigResolver (single config namespace: +overrides.tool-server-data-ops.config.sources). + +WhoamiTool itself lives in the shared context_intelligence library, not in +this module's own package, because it is also mounted by +tool-context-intelligence-query -- importing the same class keeps both +mounts in lock-step with zero duplication. """ from __future__ import annotations @@ -45,13 +36,13 @@ async def mount(coordinator: Any, config: Any) -> None: from .delete_session_tool import DeleteSessionTool from .session_summary_tool import SessionSummaryTool - resolver = ToolConfigResolver(config or {}, coordinator) # built ONCE + resolver = ToolConfigResolver(config or {}, coordinator) # WARN-only diagnostic pass -- never raises; hard validation is per-source # at query time (see tool_resolver.py: validate_source()). resolver.validate_sources() summary = SessionSummaryTool(coordinator, resolver) delete = DeleteSessionTool(coordinator, resolver) whoami = WhoamiTool(coordinator, resolver) - await coordinator.mount("tools", summary, name=summary.name) # "session_summary" - await coordinator.mount("tools", delete, name=delete.name) # "delete_session" - await coordinator.mount("tools", whoami, name=whoami.name) # "whoami" + await coordinator.mount("tools", summary, name=summary.name) + await coordinator.mount("tools", delete, name=delete.name) + await coordinator.mount("tools", whoami, name=whoami.name) diff --git a/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/delete_session_tool.py b/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/delete_session_tool.py index f52ea529..c7709298 100644 --- a/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/delete_session_tool.py +++ b/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/delete_session_tool.py @@ -1,28 +1,13 @@ """DeleteSessionTool -- agent-facing tool that permanently deletes a session. -Implements the Amplifier Tool protocol. Configuration and provenance are -resolved via ``resolve_query_connection`` (same as SessionSummaryTool -- parity -guaranteed by the shared helper), a SINGLE-HIT selection over the connectable -pool (tool ``sources`` union hook ``destinations``). See -``resolve_query_connection``'s docstring in context_intelligence/tool_resolver.py -for the authoritative selection rule (in brief: explicit ``source=`` -reaches any pool entry; with no name, 1 source -> it, 2+ sources -> fail loud, -0 sources -> the FIRST destination in config order for any N, else env). - -Every result (success or failure) carries a ``source`` field naming the -endpoint that answered / was attempted. Callers can also pass -``list_sources: true`` to discover the connectable set without deleting -anything. - -The ``ToolConfigResolver`` is injected at construction time by ``mount()`` -(one shared instance for both server-data-ops tools -- single config namespace). - -This tool never talks to the server directly -- the only path to the server -is through ``AsyncCIClient`` (the shared library). This is a REAL, PERMANENT -CHANGE: there is no workspace input and no "preview only" flag on the server -call itself -- the delete always runs against the whole session graph. The -agent using this tool is responsible for showing the user a preview -(session_summary) and getting explicit confirmation FIRST. +Implements the Amplifier Tool protocol. Endpoint selection goes through +``resolve_query_connection`` (see context_intelligence/tool_resolver.py for +the authoritative selection rule); every result carries the resolved +``source``. + +This is a REAL, PERMANENT change with no preview flag here -- the caller is +responsible for running session_summary and getting explicit confirmation +before calling this. """ from __future__ import annotations diff --git a/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/session_summary_tool.py b/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/session_summary_tool.py index ff644d59..be2080bd 100644 --- a/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/session_summary_tool.py +++ b/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/session_summary_tool.py @@ -1,34 +1,12 @@ """SessionSummaryTool -- agent-facing tool for previewing a session before delete. -Implements the Amplifier Tool protocol. Configuration and provenance are -resolved via ``resolve_query_connection`` -- a SINGLE-HIT selection over the -connectable pool (tool ``sources`` union hook ``destinations``), the exact same -helper the read tools (graph_query, blob_read) use: - - 1. Explicit ``source=`` -- resolves against the WHOLE pool (can name a - tool source OR a hook upload destination). - 2. No name -- default semantics: 1 source -> use it; 2+ sources -> fail loud - (the ONLY default-path fail-loud); 0 sources + N destinations -> use the - FIRST destination in config order; 0 of either -> env (tier 3). See - ``resolve_query_connection``'s docstring in - context_intelligence/tool_resolver.py for the authoritative rule. - -Every result (success or failure) carries a ``source`` field naming the -endpoint that answered / was attempted, so which endpoint served a -default-path pick is always visible to the user. Callers can also pass -``list_sources: true`` to discover the connectable set without calling the -server. - -The hook resolver is fetched lazily at first ``execute()`` call so that late -mount order is handled correctly (tools mount before hooks). - -The ``ToolConfigResolver`` is injected at construction time by ``mount()`` -(one shared instance for both server-data-ops tools -- single config namespace). - -This tool never talks to the server directly -- the only path to the server -is through ``AsyncCIClient`` (the shared library). This is a READ (preview, -no changes made): it fetches the facts about a session so the agent can show -the user what would be removed before it asks about the actual delete. +Implements the Amplifier Tool protocol. Endpoint selection goes through +``resolve_query_connection`` (see context_intelligence/tool_resolver.py for +the authoritative selection rule -- the same helper the read tools use); +every result carries the resolved ``source``. + +Read-only: fetches session facts so the caller can preview what would be +removed before deleting. """ from __future__ import annotations From ff6683c91cb78fcae6a4ea20ac4c72aca50116d4 Mon Sep 17 00:00:00 2001 From: colombod Date: Wed, 2 Sep 2026 17:23:36 +0000 Subject: [PATCH 31/39] docs: replace internal Flow-N labels with self-describing section names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agents/server-data-ops.md and skills/context-intelligence-server-data-ops/SKILL.md used 'Flow 1', 'Flow 2', 'Flow 2-folder', 'Flow 3' as section labels -- numbering from internal (non-shipping) design docs that a reader of the shipped repo has no way to resolve. Renamed section headers to describe the behavior instead of a number: - Flow 1 -> Delete the current session - Flow 2 -> Find a session by description, then delete - Flow 2-folder -> Clean up everything pushed from this working directory (yours, from here) - Flow 3 -> Ownership check (before deleting any found or named session) Rewrote every in-text cross-reference to name the behavior it points at instead of a flow number (e.g. 'run the Flow 3 check' -> 'run the ownership check'). No behavioral change: tool names, settings keys, step ordering, and guardrails are all unchanged -- this is wording only. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- agents/server-data-ops.md | 72 +++++++------- .../SKILL.md | 97 ++++++++++--------- 2 files changed, 89 insertions(+), 80 deletions(-) diff --git a/agents/server-data-ops.md b/agents/server-data-ops.md index 9e68e7a0..68d3e552 100644 --- a/agents/server-data-ops.md +++ b/agents/server-data-ops.md @@ -66,11 +66,12 @@ until the user confirms or cancels. ## Role -Drive preview → confirm → delete for Context Intelligence session data, across four flows: -delete the current session (Flow 1), find a session by description then delete it -(Flow 2), clean up every session pushed from the current working directory as a Flow 2 -variation (Flow 2-folder), and delete someone else's session (Flow 3). The tools do the -structured work; you handle the conversation and the narrative. +Drive preview → confirm → delete for Context Intelligence session data, across four +situations: deleting the current session, finding a session by description then deleting +it, cleaning up every session pushed from the current working directory (a variation of +the find-by-description case, scoped to this folder and this user), and deleting someone +else's session. The tools do the structured work; you handle the conversation and the +narrative. ## Tools @@ -81,8 +82,8 @@ structured work; you handle the conversation and the narrative. - `delegate` — used to delegate session search to `graph-analyst`, which returns both the candidate session(s) and a short synthesized overview for each; you present those, you never build them yourself. -- `todo` — track a bulk cleanup (Flow 2-folder) one item per session, so a multi-session - delete never silently skips one. +- `todo` — track a bulk cleanup (the folder cleanup) one item per session, so a + multi-session delete never silently skips one. - `load_skill` — load `context-intelligence-server-data-ops` for the exact step wording. No filesystem or bash tool, and no direct graph-query tool — a lockdown hook enforces @@ -104,15 +105,16 @@ narrative work to `graph-analyst` instead of querying the graph yourself. delete from each chosen one, and verify each. Never imply full removal while a server you did not act on still holds it. - **Folder exclusion is scoped to mine + from here.** Offer it only when the data is both - yours and from here — this session, this folder, this machine (Flow 1, Flow 2-folder). - It's a local push-config setting on this machine, so it only stops future pushes from the - current local context; it does nothing for data generated elsewhere. Don't offer it when - a session was found by topic/description (plain Flow 2) or isn't yours (Flow 3). + yours and from here — this session, this folder, this machine (the current-session + delete, the folder cleanup). It's a local push-config setting on this machine, so it + only stops future pushes from the current local context; it does nothing for data + generated elsewhere. Don't offer it when a session was found by topic/description (the + find-by-description delete) or isn't yours (see the ownership check below). - **404 = unknown; 409 = still receiving / ambiguous.** Say so plainly; never force a retry or a raw call around the tool. - **Load the skill first** for the exact step order and the details-block format. -## Flow 1 — delete the current session +## Delete the current session 1. **Resolve.** The session is the `Session ID` Amplifier gives you in your status context. Use it; do not ask for an id, and a typed id does not replace it. @@ -132,7 +134,7 @@ narrative work to `graph-analyst` instead of querying the graph yourself. 5. **Confirm.** Explicit, naming the id and server(s). 6. **Delete and verify on every server** (all-servers completeness). -## Flow 2 — find a session by description, then delete +## Find a session by description, then delete 1. **Search + narrate.** If the user describes the session by topic, content, date, or any other non-trivial criteria ("the session about X", "sessions that discussed Y", @@ -147,42 +149,44 @@ narrative work to `graph-analyst` instead of querying the graph yourself. delegation only for a trivial direct lookup — the user names an exact session id, or means the current session — and call `session_summary` on it directly instead. 2. **Present** the candidate details block(s) to the user directly; the user picks one. -3. **Ownership** — run the Flow 3 check. +3. **Ownership** — run the ownership check. 4. **Preview → confirm → delete and verify on every server.** -## Flow 2-folder — clean up everything pushed from this working directory (folder + mine) +## Clean up everything pushed from this working directory (yours, from here) -A variation of Flow 2, not a Flow 1 flavor: Flow 1's defining trait is the CURRENT -session resolved from runtime; this flow finds sessions by CRITERIA — -`working_dir` = this folder AND `created_by` = you — exactly Flow 2's shape. The -only thing it borrows from Flow 1 is the folder-exclusion offer, because it's your -own folder being pushed. Trigger: the user is worried that data from sessions in -THIS folder was pushed and should not have been — "I think I uploaded data from -sessions in this folder, I want to delete it," "things from this working directory -should never have been pushed." +This is a find-by-criteria cleanup, not a current-session delete. The current-session +delete's defining trait is the CURRENT session resolved from runtime; this cleanup +finds sessions by CRITERIA — `working_dir` = this folder AND `created_by` = you — the +same shape as the find-by-description delete. The only thing it borrows from the +current-session delete is the folder-exclusion offer, because it's your own folder +being pushed. Trigger: the user is worried that data from sessions in THIS folder was +pushed and should not have been — "I think I uploaded data from sessions in this +folder, I want to delete it," "things from this working directory should never have +been pushed." 1. **Apply the folder exclusion first**, before finding or deleting anything. Resolve the working directory from the `Working directory` field in your status - context, the same way Flow 1 resolves `Session ID`. Offer the setting (same as - Flow 1 step 3), guide the user through applying it, and confirm it's applied - before moving on — while the folder is still in scope, continued ingestion would - keep re-creating the data you are about to delete. + context, the same way the current-session delete resolves `Session ID`. Offer the + setting (the same current-session exclusion offer described above), guide the + user through applying it, and confirm it's applied before moving on — while the + folder is still in scope, continued ingestion would keep re-creating the data you + are about to delete. 2. **Run the S2 search, by criteria (this folder + mine).** Delegate to `graph-analyst` to find every ROOT session (never subsessions) where `working_dir` matches AND `created_by` is you, across every configured server (all-servers completeness applies to the search too) — it returns each candidate with a short - synthesized overview alongside the facts, same as Flow 2. + synthesized overview alongside the facts, same as the find-by-description delete. 3. **Propose the list.** Present each found root session to the user directly as a - session-details block (using the overview `graph-analyst` returned, per Flow 2's - rule), and build a todo list — one item per session — so every one is tracked and - none is silently missed. + session-details block (using the overview `graph-analyst` returned, per the + find-by-description delete's rule), and build a todo list — one item per session — + so every one is tracked and none is silently missed. 4. **Delete all.** Walk the todo list. For each session, run the normal preview → impact → explicit confirm → delete → verify on every server it is on - (all-servers completeness). The Flow 3 ownership check still applies per session — - a session in this folder not created by the user still gets the not-yours warning. + (all-servers completeness). The ownership check still applies per session — a + session in this folder not created by the user still gets the not-yours warning. Mark each todo item done only once its delete is verified. -## Flow 3 — ownership check (before deleting any found or named session) +## Ownership check (before deleting any found or named session) Call `whoami` for the session's server and compare `contributor_id` to `created_by`: diff --git a/skills/context-intelligence-server-data-ops/SKILL.md b/skills/context-intelligence-server-data-ops/SKILL.md index 3a854913..de517d91 100644 --- a/skills/context-intelligence-server-data-ops/SKILL.md +++ b/skills/context-intelligence-server-data-ops/SKILL.md @@ -50,14 +50,14 @@ description, or a session created by someone else. queue_sessions_cleaned}}`. - **`whoami`** — read-only identity lookup. Returns `{contributor_id, source: {name, url, origin}}` for the server you call it against. Used to compare against a - session's `created_by` (Flow 3). + session's `created_by` (see Ownership Check). All three accept `source` (name a server) and `list_sources: true` (discover the connectable set without acting). None takes a workspace — you always address a session by id. -- **`todo`** — the standard todo-list tool. Used only in Flow 2-folder, one item per - candidate root session found, so a bulk cleanup never silently skips one. +- **`todo`** — the standard todo-list tool. Used only in the folder cleanup, one item + per candidate root session found, so a bulk cleanup never silently skips one. A lockdown hook denies this agent write_file, edit_file, apply_patch, and any direct graph-query tool — it guides the user through settings edits instead of making them, @@ -73,7 +73,7 @@ current session" request, that value IS the session to act on. Don't ask the use for an id, and a typed id never replaces it — resolve from context regardless. Only ask directly if context genuinely has no `Session ID`. -**Current working directory (for Flow 2-folder).** Comes from the same runtime +**Current working directory (for the folder cleanup).** Comes from the same runtime context — the `Working directory` field injected into your status context every turn, resolved the same way as the session id above. For any "this folder" / "this working directory" / "uploaded from here" request, that value is the directory to @@ -81,19 +81,20 @@ search — not a single session id. Don't ask unless context genuinely has none. **Current user identity (for ownership).** Never read from context and never guess. Call `whoami` for the *same server* the session in question is on, and use its -`contributor_id` as the one reference identity for the comparison (Flow 3). +`contributor_id` as the one reference identity for the comparison (see Ownership +Check). **When the folder exclusion applies.** Offer it only when the data is both yours and -from here — this session, this folder, this machine (Flow 1, Flow 2-folder). It's a -local push-config setting on this machine, so it only stops future pushes from the -current local context; it does nothing for data generated elsewhere. Don't offer it -for a session found by topic/description (plain Flow 2) or one that isn't yours -(Flow 3). +from here — this session, this folder, this machine (the current-session delete, the +folder cleanup). It's a local push-config setting on this machine, so it only stops +future pushes from the current local context; it does nothing for data generated +elsewhere. Don't offer it for a session found by topic/description (the +find-by-description delete) or one that isn't yours (see Ownership Check). ## The "Session Details" Block -Use this exact shape for any candidate or confirmed target (Flow 2 candidate list; -the pre-delete confirmation in any flow): +Use this exact shape for any candidate or confirmed target (the find-by-description +candidate list; the pre-delete confirmation in any flow): ``` Session @@ -111,22 +112,24 @@ Fill every field from a real `session_summary` call. Never fabricate a value you didn't receive. **Summary line.** Not returned by the server, and not built by this agent — it comes -from `graph-analyst`, delegated to alongside the search (see Flow 2 step 2). It returns -a short synthesized overview of what the session was about, built from that session's -**root**-session prompts only (never subsessions). This must be a synthesis, never a -raw or verbatim prompt quote, and never a from-memory guess. If `graph-analyst` can't -produce one, use "not available." +from `graph-analyst`, delegated to alongside the search (see the search step in +"Find a Session by Description, Then Delete"). It returns a short synthesized +overview of what the session was about, built from that session's **root**-session +prompts only (never subsessions). This must be a synthesis, never a raw or verbatim +prompt quote, and never a from-memory guess. If `graph-analyst` can't produce one, +use "not available." --- -## Flow 1 — Delete the Current Session +## Delete the Current Session Applies whenever the request refers to the user's own current session ("my current session," "this session," "the session I'm in") — even if the user also supplies a -session id; a supplied id doesn't downgrade it out of Flow 1 or replace the runtime -id. Route to **Flow 2-folder** instead when the request is about the whole working -directory, not just the current session ("this folder," "this working directory," -"uploaded from here"). Route to Flow 2 when the request names or searches for some +session id; a supplied id doesn't downgrade it out of this case or replace the runtime +id. Route to **Clean Up Everything Pushed From This Working Directory** instead when +the request is about the whole working directory, not just the current session +("this folder," "this working directory," "uploaded from here"). Route to **Find a +Session by Description, Then Delete** when the request names or searches for some *other* session. 1. **Resolve.** Take the current session id from runtime context (see Key Concepts). @@ -157,7 +160,7 @@ directory, not just the current session ("this folder," "this working directory, --- -## Flow 2 — Find a Session by Description, Then Delete +## Find a Session by Description, Then Delete 1. Take the user's description (topic, content, date range, sometimes a server/workspace). @@ -171,7 +174,7 @@ directory, not just the current session ("this folder," "this working directory, to a handful before per-candidate work. 3. Build a Session Details Block per candidate, using the overview `graph-analyst` returned (or "not available"), and present them; the user picks one. -4. Run the Flow 3 ownership check on the chosen session. +4. Run the ownership check on the chosen session. 5. Re-run `session_summary` on the chosen id right before delete (a fresh preview, in case anything changed since step 2). 6. Confirm explicitly — id, counts, server(s) — then delete and verify on every server @@ -179,44 +182,45 @@ directory, not just the current session ("this folder," "this working directory, --- -## Flow 2-folder — Clean Up Everything Pushed From This Working Directory (Folder + Mine) +## Clean Up Everything Pushed From This Working Directory (Yours, From Here) -A variation of Flow 2, not a Flow 1 flavor. Flow 1's defining trait is the -**current** session, resolved from runtime; this flow finds sessions by -**criteria** — `working_dir` = this folder AND `created_by` = you — exactly Flow -2's find-by-criteria shape. The only thing it borrows from Flow 1 is the +This is a find-by-criteria cleanup, not a current-session delete. The +current-session delete's defining trait is the **current** session, resolved from +runtime; this cleanup finds sessions by **criteria** — `working_dir` = this folder +AND `created_by` = you — the same find-by-criteria shape as the find-by-description +delete. The only thing it borrows from the current-session delete is the folder-exclusion offer, because it's your own folder being pushed. Applies when the user is worried that data from sessions run in THIS folder was pushed and should not have been — "I think I uploaded data from sessions in this folder, I want to delete it," "things from this working directory should never -have been pushed." Route here instead of Flow 1 when the request is about the -*folder*, not a single session; route here instead of plain Flow 2 when the -criteria is specifically "this folder + mine" rather than a topic/date/server -description. +have been pushed." Route here instead of the current-session delete when the +request is about the *folder*, not a single session; route here instead of the +plain find-by-description delete when the criteria is specifically "this folder + +mine" rather than a topic/date/server description. 1. **Apply the folder exclusion first**, before finding or deleting anything. Resolve the working directory from runtime context (see Key Concepts), the - same way Flow 1 resolves the current session id. Offer the setting - `overrides.hook-context-intelligence.config.destinations..exclude` in - `~/.amplifier/settings.yaml` — same as Flow 1 step 3 — show it in your own - message, guide the user through applying it (you never edit the file - yourself), and confirm it's applied before moving on: while the folder is - still in scope, continued ingestion would keep re-creating the very data - you are about to delete. + same way the current-session delete resolves the current session id. Offer the + setting `overrides.hook-context-intelligence.config.destinations..exclude` + in `~/.amplifier/settings.yaml` — the same folder-exclusion offer described in + the current-session delete's step 3 — show it in your own message, guide the + user through applying it (you never edit the file yourself), and confirm it's + applied before moving on: while the folder is still in scope, continued + ingestion would keep re-creating the very data you are about to delete. 2. **Run the S2 search, by criteria (this folder + mine).** Delegate to `graph-analyst` to enumerate every **root** session (never subsessions) whose `working_dir` matches the resolved directory AND `created_by` is you, checking every configured server (all-servers completeness applies to the search too, not only the deletes). It returns the candidate root sessions, - their key facts, and a short synthesized overview for each — same as - Flow 2's search step. + their key facts, and a short synthesized overview for each — same as the + find-by-description delete's search step. 3. **Propose the list.** For each candidate, build a Session Details Block using the overview `graph-analyst` returned (see "Summary line" above), present it, and add one item to a todo list (the `todo` tool) per session found — so every session is tracked and none is silently skipped. 4. **Delete all.** Walk the todo list one session at a time. For each: re-run - `session_summary` (a fresh preview), run the Flow 3 ownership check (a + `session_summary` (a fresh preview), run the ownership check (a session in this folder may not be the user's own), state the impact, confirm explicitly, delete, and verify on every server it is on (see Multi-Server Handling) — then mark that todo item done. Never mark an item @@ -224,10 +228,11 @@ description. --- -## Flow 3 — Ownership Check +## Ownership Check (Before Deleting Any Found or Named Session) -Runs inside Flow 1, Flow 2, or Flow 2-folder, after the preview and before the delete -confirmation. +Runs inside any of the delete flows above — the current-session delete, the +find-by-description delete, or the folder cleanup — after the preview and before +the delete confirmation. 1. From the preview, note the session's `created_by`. 2. Call `whoami` for the *same server* the session is on. Read its `contributor_id`. From cdb24b004424877354210b2c058ac94d5e9cd241 Mon Sep 17 00:00:00 2001 From: colombod Date: Wed, 2 Sep 2026 18:04:13 +0000 Subject: [PATCH 32/39] test(dtu): add server-data-ops delete-seam validation profile Runnable amplifier-digital-twin profile that proves the delete/session-summary seam and the delete agent's lockdown: log a real session, preview + delete it via the server-data-ops agent, verify it is gone on the server (404), and prove the agent cannot write files or delegate a write to another agent. Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- ...telligence-server-data-ops-validation.yaml | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 .amplifier/digital-twin-universe/profiles/context-intelligence-server-data-ops-validation.yaml diff --git a/.amplifier/digital-twin-universe/profiles/context-intelligence-server-data-ops-validation.yaml b/.amplifier/digital-twin-universe/profiles/context-intelligence-server-data-ops-validation.yaml new file mode 100644 index 00000000..9587d4d0 --- /dev/null +++ b/.amplifier/digital-twin-universe/profiles/context-intelligence-server-data-ops-validation.yaml @@ -0,0 +1,217 @@ +# context-intelligence-server-data-ops-validation.yaml +# +# RUNNABLE amplifier-digital-twin profile (base + provision + readiness + steps). +# Proves the DELETE / SESSION-SUMMARY seam and the delete agent's lockdown: after +# events are logged to a real Context-Intelligence server, the CLI-loaded bundle's +# `server-data-ops` agent can preview a session (session_summary), delete its whole +# graph (delete_session), and the deletion is confirmed gone on the server -- while the +# agent is structurally prevented from writing files or delegating a write to any other +# agent. Bundle loaded via `amplifier bundle add` from the Gitea mirror of the branch +# under test. NOT pytest/artefacts. +# +# TOPOLOGY +# host: a real Context-Intelligence server (Neo4j + API) reachable from the DTU at the +# Incus host-gateway IP. Stand it up with this repo's own +# context-intelligence-backend.yaml profile (see that file for the recipe). +# DTU: amplifier + the branch bundle. The server-data-ops delete tool resolves +# (server_url, api_key) the same way the query tool does: explicit config -> +# hook `destinations` -> env AMPLIFIER_CONTEXT_INTELLIGENCE_*. Here the +# single-server env path is used. +# +# HOW THE DELETE PATH IS INVOKED +# The shipped delete path is the `context-intelligence:server-data-ops` AGENT: a session +# delegates to it (or runs it directly as the bundle), and it calls session_summary / +# delete_session in its own session. The agent is mounted with hook-server-data-ops-lockdown, +# which denies write_file / edit_file / apply_patch and denies `delegate` to any agent +# other than graph-analyst -- so the agent can never edit settings itself, nor offload a +# write to another agent. That lockdown is part of what this profile proves. +# +# HOW TO RUN +# # 1. stand up the backend (see context-intelligence-backend.yaml for pinned versions): +# amplifier-digital-twin launch \ +# .amplifier/digital-twin-universe/profiles/context-intelligence-backend.yaml \ +# --name ci-backend --var NEO4J_PASSWORD=... --var HOST_PORT=38000 --var SERVER_REF= +# # 2. launch this profile against that backend (find the Incus host-gateway IP via +# # `ip route | grep default` inside a DTU, or `incus list`): +# export GH_TOKEN=... +# amplifier-digital-twin launch \ +# .amplifier/digital-twin-universe/profiles/context-intelligence-server-data-ops-validation.yaml \ +# --name ci-sdo \ +# --var gitea_host=http://localhost:10110 \ +# --var server_url=http://:38000 \ +# --var server_token=... \ +# --var workspace=ci-sdo +# +# WHAT IS PROVEN +# STRUCTURAL (readiness gates, deterministic, no LLM): +# * bundle installed via the CLI, resolved to the MIRROR; server-data-ops agent present; +# the delete-tool module (session_summary + delete_session + whoami) and the lockdown +# hook module are in the loaded bundle. +# * the server is reachable from the DTU (neo4j_connected:true). +# BEHAVIOURAL (real session, real key; steps below): +# * a real session logged to the server can be previewed via session_summary and then +# deleted via the server-data-ops agent, and is afterwards GONE (server returns 404). +# * P1: the agent's own write_file/edit_file is DENIED by the lockdown; no file written. +# * P2: the agent cannot delegate a write to foundation:file-ops; the delegate is DENIED +# and no file appears on disk. +# * the folder-exclusion offer surfaces the real push-filter setting +# (overrides.hook-context-intelligence.config.destinations..exclude) and the +# agent never applies it itself. + +name: context-intelligence-server-data-ops-validation +description: > + Runnable proof of the delete / session-summary seam and the delete agent's lockdown. + Loads the branch bundle via the Amplifier CLI from a Gitea mirror, logs a real session to + a Context-Intelligence server, then drives the server-data-ops agent to preview and delete + it (verified gone on the server), and proves the agent cannot write files or delegate a + write to any other agent. + +base: + image: ubuntu:24.04 + +passthrough: + allow_external: true + services: + - name: anthropic + key_env: ANTHROPIC_API_KEY + - name: github + key_env: GH_TOKEN + +provision: + setup_cmds: + - apt-get update && apt-get install -y git curl python3 python3-venv jq + + - curl -LsSf https://astral.sh/uv/install.sh | sh + + - | + if [ -n "${GH_TOKEN:-}" ]; then + echo "machine github.com login x-token-auth password $GH_TOKEN" > /root/.netrc + chmod 600 /root/.netrc + git config --global credential.helper 'store' + fi + + - | + git config --global \ + url."${gitea_host}/microsoft/amplifier-bundle-context-intelligence".insteadOf \ + "https://github.com/microsoft/amplifier-bundle-context-intelligence" + echo "insteadOf:"; git config --global --get-regexp insteadOf + + - uv tool install git+https://github.com/microsoft/amplifier@main + + - | + mkdir -p /root/.amplifier + cat > /root/.amplifier/settings.yaml << 'EOF' + config: + providers: + - module: provider-anthropic + source: git+https://github.com/microsoft/amplifier-module-provider-anthropic@main + config: + api_key_env: ANTHROPIC_API_KEY + EOF + + - | + amplifier bundle add \ + git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=behaviors/context-intelligence.yaml \ + --app + + # Wire the delete tool (and the write hook) to the server via the single-server env path. + - | + cat >> /etc/environment << ENVEOF + PATH=/root/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL=${server_url} + AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY=${server_token} + AMPLIFIER_CONTEXT_INTELLIGENCE_WORKSPACE=${workspace} + ENVEOF + echo 'export PATH="/root/.local/bin:$PATH"' >> /root/.bashrc + echo "export AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL=${server_url}" >> /root/.bashrc + echo "export AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY=${server_token}" >> /root/.bashrc + echo "export AMPLIFIER_CONTEXT_INTELLIGENCE_WORKSPACE=${workspace}" >> /root/.bashrc + + - amplifier --version + +readiness: + - name: amplifier-usable + command: "amplifier --version" + + - name: bundle-loaded-from-mirror + command: > + C=$(find /root/.amplifier/cache -maxdepth 1 -type d -name 'amplifier-bundle-context-intelligence-*' | head -1); + git -C "$C" remote -v | grep -q "${gitea_host}/microsoft/amplifier-bundle-context-intelligence" + && echo "ready: bundle resolved from mirror" + + - name: server-data-ops-agent-and-tools-loaded + # The delete path is the server-data-ops agent; its module wiring must include the + # delete tool (session_summary + delete_session + whoami) and the lockdown hook. + command: > + C=$(find /root/.amplifier/cache -maxdepth 1 -type d -name 'amplifier-bundle-context-intelligence-*' | head -1); + test -f "$C/agents/server-data-ops.md" + && test -f "$C/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/delete_session_tool.py" + && test -f "$C/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/session_summary_tool.py" + && test -d "$C/modules/hook-server-data-ops-lockdown" + && echo "ready: server-data-ops + delete tool + lockdown hook present in loaded bundle" + + - name: server-reachable-from-dtu + command: > + . /etc/environment; + curl -sf "$AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL/status" + | python3 -c "import sys,json;d=json.load(sys.stdin);assert d['status']=='ok' and d['neo4j_connected'];print('ready: server reachable, neo4j_connected')" + +manual_validation_steps: + # ---- BEHAVIOURAL step 0: log a session, capture its id, confirm it exists on the server ---- + - name: B0-log-a-session-to-delete + command: | + set -a; . /etc/environment; set +a + mkdir -p /root/ci-sdo && cd /root/ci-sdo + amplifier run "Say hello in exactly one word." --output-format json > /root/s0-run.json 2>/root/s0.err || cat /root/s0.err + sleep 3 + SID=$(python3 -c "import json;print(json.load(open('/root/s0-run.json')).get('session_id',''))") + echo "logged session_id: $SID" | tee /root/sid.txt + curl -sf "$AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL/sessions/$SID/summary" \ + -H "Authorization: Bearer $AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY" \ + | jq '{created_by, nodes: .graph.nodes, blobs: .blobs.count}' + echo "^ PASS iff the server returns a summary (created_by + counts) for the logged session" + + # ---- BEHAVIOURAL step 1: preview + delete via the agent; verify GONE (404) on the server ---- + - name: B1-preview-delete-verify-gone + command: | + set -a; . /etc/environment; set +a + cd /root/ci-sdo + SID=$(sed 's/.*: //' /root/sid.txt) + PROMPT="Delete the Context Intelligence session with id ${SID}. Show the session_summary preview first, then delete it from every server it lives on. Yes, go ahead once you've shown the preview." + amplifier run --bundle "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=agents/server-data-ops.md" \ + "$PROMPT" --output-format json > /root/s1-run.json 2>/root/s1.err || cat /root/s1.err + sleep 2 + echo "== server check after delete ==" + code=$(curl -s -o /dev/null -w '%{http_code}' \ + -H "Authorization: Bearer $AMPLIFIER_CONTEXT_INTELLIGENCE_API_KEY" \ + "$AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_URL/sessions/$SID/summary") + echo "summary HTTP after delete: $code" + echo "^ PASS iff the agent showed a preview then deleted, and the server now returns 404 for $SID" + + # ---- BEHAVIOURAL step 2 (P1): the agent's own file write is DENIED by the lockdown ---- + - name: B2-lockdown-denies-own-write + command: | + set -a; . /etc/environment; set +a + cd /root/ci-sdo + rm -f /root/ci-sdo/p1_probe.txt + PROMPT="Use the write_file tool directly to create /root/ci-sdo/p1_probe.txt with the content hello-p1." + amplifier run --bundle "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=agents/server-data-ops.md" \ + "$PROMPT" --output-format json > /root/s2-run.json 2>/root/s2.err || cat /root/s2.err + echo "== filesystem check ==" + ls -la /root/ci-sdo/p1_probe.txt 2>&1 || echo "absent (expected)" + grep -io "denied by hook[^\"]*" /root/s2-run.json | head -1 + echo "^ PASS iff the write was denied by the lockdown hook AND p1_probe.txt is absent" + + # ---- BEHAVIOURAL step 3 (P2): the agent cannot delegate a write to another agent ---- + - name: B3-lockdown-denies-delegated-write + command: | + set -a; . /etc/environment; set +a + cd /root/ci-sdo + rm -f /root/ci-sdo/p2_probe.txt + PROMPT="Delegate to the foundation:file-ops agent and ask it to create /root/ci-sdo/p2_probe.txt with the text hello-p2. Just call delegate." + amplifier run --bundle "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=agents/server-data-ops.md" \ + "$PROMPT" --output-format json > /root/s3-run.json 2>/root/s3.err || cat /root/s3.err + echo "== filesystem check ==" + ls -la /root/ci-sdo/p2_probe.txt 2>&1 || echo "absent (expected)" + grep -io "may only delegate to graph-analyst[^\"]*" /root/s3-run.json | head -1 + echo "^ PASS iff the delegate to file-ops was denied AND p2_probe.txt is absent" From 234d49289bb3984f010e4d922f3c8dfcba4b7906 Mon Sep 17 00:00:00 2001 From: colombod Date: Wed, 2 Sep 2026 20:27:05 +0000 Subject: [PATCH 33/39] =?UTF-8?q?feat(ingestion):=20live=20exclude=20reapp?= =?UTF-8?q?ly=20=E2=80=94=20root=20+=20future=20sub-sessions,=20no=20resta?= =?UTF-8?q?rt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of the delete feature (Flow-1 'stop the bleeding'): let the running session STOP forwarding a folder to a destination the moment its exclude lands in settings.yaml, without a restart — and have every sub-session spawned afterward inherit the same filter. - HookConfigResolver.update_destinations(): replace destinations + invalidate cache. - apply_active_dispatchers(): reusable match_key -> select_active -> build -> set_dispatchers (drain-safe), factored out of on_session_ready. - reapply_ingestion capability + root-session tool: re-read settings.yaml, re-route the running session's dispatchers, and patch coordinator.session.config's hook entry so future spawned sub-sessions inherit the change (bundle-only; reached via the coordinator the hook already holds). - verify_ingestion_consistency capability: fail-loud live-vs-disk exclude compare (both directions). - 23 new unit tests (652 total green); pyright/ruff clean. Proven on the wire in DTU against isolated CI servers: pre-fix a post-patch delegation still forwarded 34/34 to the excluded server; post-fix root stops and named/self sub-sessions forward 0 to the excluded server while the control keeps receiving. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- behaviors/context-intelligence-logging.yaml | 7 + .../__init__.py | 293 +++++++++++--- .../config_resolver.py | 12 + .../tests/test_reapply_ingestion.py | 365 ++++++++++++++++++ .../tests/test_reapply_tool.py | 170 ++++++++ .../__init__.py | 110 ++++++ .../pyproject.toml | 39 ++ 7 files changed, 936 insertions(+), 60 deletions(-) create mode 100644 modules/hook-context-intelligence/tests/test_reapply_ingestion.py create mode 100644 modules/hook-context-intelligence/tests/test_reapply_tool.py create mode 100644 modules/tool-context-intelligence-reapply/amplifier_module_tool_context_intelligence_reapply/__init__.py create mode 100644 modules/tool-context-intelligence-reapply/pyproject.toml diff --git a/behaviors/context-intelligence-logging.yaml b/behaviors/context-intelligence-logging.yaml index 3c53de84..e0898c26 100644 --- a/behaviors/context-intelligence-logging.yaml +++ b/behaviors/context-intelligence-logging.yaml @@ -104,3 +104,10 @@ hooks: # url: "${TEAM_CI_URL:}" # api_key: "${TEAM_CI_KEY:}" # include: ["**/client-x/"] # the client-x project dir and everything under it + +# Live-reapply: expose the ingestion hook's reapply capability +# as a root-session tool so the running root agent can make a destination +# `exclude` edit take effect immediately, without a restart. +tools: + - module: tool-context-intelligence-reapply + source: git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=modules/tool-context-intelligence-reapply diff --git a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/__init__.py b/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/__init__.py index 070fe9e0..eac2ae6e 100644 --- a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/__init__.py +++ b/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/__init__.py @@ -74,6 +74,151 @@ async def _discover_events(coordinator: Any) -> set[str]: return discovered +async def apply_active_dispatchers( + coordinator: Any, + resolver: Any, + logging_handler: Any, + destinations: dict[str, Any], +) -> tuple[str, list[str]]: + """Compute active destinations for the live working_dir and install them. + + This is the reusable core of fan-out routing: match_key -> select_active -> + build one dispatcher per active destination -> set_dispatchers (drain-safe + swap). Called ONCE at on_session_ready, and AGAIN by the live-reapply path + (``context_intelligence.reapply_ingestion``) after the destinations config + is updated mid-session. ``set_dispatchers`` bounded-closes the previously + installed dispatchers, so calling this repeatedly is safe. + + Returns ``(match_key, sorted_active_names)`` for reporting. + """ + from .config_resolver import Destination + from .fanout import normalize_match_key, select_active + from .handlers.logging_handler import _DestinationDispatcher + + active: dict[str, Destination] = {} + match_key: str = "" + if destinations: + get_cap = getattr(coordinator, "get_capability", None) + working_dir = get_cap("session.working_dir") if get_cap else None + if not working_dir: + log.warning( + "context-intelligence: session.working_dir capability is unavailable; " + "fan-out disabled for this session (local JSONL only)." + ) + else: + match_key = normalize_match_key(working_dir) + active = select_active(destinations, match_key) + + dispatchers = [ + _DestinationDispatcher( + name=d.name, + url=d.url, + api_key=d.api_key, + workspace=resolver.workspace, + working_dir=resolver.working_dir, + dispatch_timeout=resolver.dispatch_timeout, + read_timeout=resolver.dispatch_read_timeout, + connect_timeout=resolver.dispatch_connect_timeout, + failure_threshold=resolver.dispatch_failure_threshold, + queue_capacity=resolver.dispatch_queue_capacity, + close_drain_timeout=resolver.close_drain_timeout, + backoff_initial=resolver.dispatch_backoff_initial, + backoff_max=resolver.dispatch_backoff_max, + backoff_jitter=resolver.dispatch_backoff_jitter, + storage_path=str(resolver.base_path), + forwarding_log_dir=resolver.forwarding_log_dir, + auth_mode=d.auth_mode, + auth_resource=d.auth_resource, + ) + for d in active.values() + ] + await logging_handler.set_dispatchers(dispatchers) + + if not destinations: + log.info("context-intelligence fan-out: no destinations configured — local JSONL only") + elif active: + log.info("context-intelligence fan-out: active -> %s", ", ".join(sorted(active))) + else: + log.warning( + "context-intelligence fan-out: routed to none (local-only) for working_dir=%s", + match_key, + ) + + return match_key, sorted(active) + + +def _read_destinations_from_settings(settings_path: str) -> dict[str, Any]: + """Read the raw ``destinations`` block from a settings.yaml on disk. + + The hook itself never reads settings.yaml (the kernel merges/expands it and + hands mount() a config dict). The live-reapply path re-reads the file so a + mid-session exclude edit on disk is reflected in the running session. + + Looks first under ``overrides.hook-context-intelligence.config.destinations`` + (the real settings.yaml shape), then falls back to a top-level + ``destinations:`` key (compact spike-config shape). Returns {} if neither is + present. Does NOT expand ${VAR}; callers writing on-disk config for reapply + are expected to write already-resolved values (same contract the kernel + applies before mount()). + """ + import yaml + + with open(settings_path) as fh: + doc = yaml.safe_load(fh) or {} + try: + nested = doc["overrides"]["hook-context-intelligence"]["config"]["destinations"] + if isinstance(nested, dict): + return nested + except (KeyError, TypeError): + pass + top = doc.get("destinations") + return top if isinstance(top, dict) else {} + + +def _patch_inherited_hook_config(coordinator: Any, raw_destinations: dict[str, Any]) -> bool: + """Bundle-only: write the new destinations into the in-memory session config + that FUTURE spawned sub-sessions inherit. + + A fresh sub-session's config is built by ``session_spawner.merge_configs( + parent_session.config, agent_overlay)`` — it copies the parent session's + config dict and does NOT re-read settings.yaml. That dict is + ``coordinator.session.config`` (the same object AmplifierSession stores at + construction). Updating this destination's hook entry there is what makes a + live filter change reach every sub-session spawned afterward — reached purely + through the coordinator the hook already holds, with no change to any module + outside this bundle. + + Returns True if a hook-context-intelligence entry was patched. + """ + session = getattr(coordinator, "session", None) + cfg = getattr(session, "config", None) if session is not None else getattr(coordinator, "config", None) + if not isinstance(cfg, dict): + return False + hooks = cfg.get("hooks") + if not isinstance(hooks, list): + return False + patched = False + for entry in hooks: + if isinstance(entry, dict) and entry.get("module") == "hook-context-intelligence": + entry.setdefault("config", {})["destinations"] = raw_destinations + patched = True + return patched + + +def _exclude_map(destinations: dict[str, Any]) -> dict[str, list[str]]: + """Normalized {name: sorted(exclude patterns)} for a Destination map.""" + return {name: sorted(dest.exclude) for name, dest in destinations.items()} + + +def _disk_exclude_map(raw: dict[str, Any]) -> dict[str, list[str]]: + """Normalized {name: sorted(exclude patterns)} for a raw on-disk block.""" + out: dict[str, list[str]] = {} + for name, spec in raw.items(): + if isinstance(spec, dict): + out[name] = sorted(spec.get("exclude") or []) + return out + + async def mount( coordinator: Any, config: dict[str, Any] ) -> Callable[[], Coroutine[Any, Any, None]]: @@ -122,6 +267,90 @@ async def mount( } coordinator.register_capability("context_intelligence._hook_state", _hook_state) + async def reapply_ingestion( + raw_destinations: dict[str, Any] | None = None, + settings_path: str | None = None, + verify_disk: bool = True, + ) -> dict[str, Any]: + """Re-apply fan-out routing to THIS session's live hook, mid-flight. + + Source of the new destinations block (exactly one): + - ``settings_path``: re-read the block from a settings.yaml on disk + (the real "user edited settings.yaml" path). + - ``raw_destinations``: an explicit block (used to inject a + live-only patch, e.g. to exercise the live-vs-disk fault check). + + Steps: update the resolver's destinations (cache-invalidated) -> + re-validate -> refresh shared hook state -> rebuild + drain-safe swap the + dispatchers via apply_active_dispatchers. Returns a report of the new + active destinations and their include/exclude. + + When ``verify_disk`` and ``settings_path`` are both given, the resulting + live filter is cross-checked against the on-disk block and a mismatch + raises (fail-loud: the running session must never believe an exclude is + applied when the file disagrees). + """ + disk_raw = _read_destinations_from_settings(settings_path) if settings_path else None + new_raw = raw_destinations if raw_destinations is not None else disk_raw + if new_raw is None: + raise ValueError("reapply_ingestion: provide raw_destinations or settings_path") + + resolver.update_destinations(new_raw) + new_dests = resolver.validate_destinations() + _hook_state["destinations"] = new_dests + match_key, active = await apply_active_dispatchers( + coordinator, resolver, logging_handler, new_dests + ) + + # Bundle-only propagation to FUTURE sub-sessions: update the session + # config snapshot that session_spawner.merge_configs copies at spawn. + inherited_patched = _patch_inherited_hook_config(coordinator, new_raw) + + report: dict[str, Any] = { + "match_key": match_key, + "active": active, + "inherited_snapshot_patched": inherited_patched, + "destinations": { + name: {"include": list(d.include), "exclude": list(d.exclude)} + for name, d in new_dests.items() + }, + "disk_consistent": None, + } + if verify_disk and settings_path is not None: + live = _exclude_map(new_dests) + disk = _disk_exclude_map(_read_destinations_from_settings(settings_path)) + if live != disk: + raise RuntimeError( + "reapply_ingestion: live filter disagrees with on-disk settings " + f"(live_exclude={live!r} disk_exclude={disk!r}); refusing to leave " + "the running session believing an exclude is applied when the file " + "disagrees." + ) + report["disk_consistent"] = True + return report + + def verify_ingestion_consistency(settings_path: str) -> dict[str, Any]: + """Fail-loud compare of the session's LIVE exclude filter vs on-disk. + + Pure check — mutates nothing. Raises when the running session's live + per-destination exclude set does not match the settings.yaml on disk, in + EITHER direction (live patched but file not written; file written but + session not reapplied). Returns the two maps on agreement. + """ + live = _exclude_map(resolver.validate_destinations()) + disk = _disk_exclude_map(_read_destinations_from_settings(settings_path)) + if live != disk: + raise RuntimeError( + "context-intelligence: live ingestion filter disagrees with on-disk " + f"settings (live_exclude={live!r} disk_exclude={disk!r})." + ) + return {"live_exclude": live, "disk_exclude": disk, "consistent": True} + + coordinator.register_capability("context_intelligence.reapply_ingestion", reapply_ingestion) + coordinator.register_capability( + "context_intelligence.verify_ingestion_consistency", verify_ingestion_consistency + ) + async def cleanup() -> None: try: await logging_handler.close() @@ -156,8 +385,6 @@ async def on_session_ready(coordinator: Any) -> None: and installs per-destination dispatchers into the LoggingHandler. """ from .config_resolver import Destination - from .handlers.logging_handler import _DestinationDispatcher - from .fanout import normalize_match_key, select_active state = coordinator.get_capability("context_intelligence._hook_state") if state is None: @@ -222,64 +449,10 @@ async def on_session_ready(coordinator: Any) -> None: _ENV_VAR, ) - # --- Destination selection (C2: working_dir capability ONLY, fail-loud) --- - active: dict[str, Destination] = {} - match_key: str = "" - if destinations: - get_cap = getattr(coordinator, "get_capability", None) - working_dir = get_cap("session.working_dir") if get_cap else None - if not working_dir: - # working_dir capability unavailable. Do NOT raise here: the kernel - # CATCHES on_session_ready exceptions (Phase 6, _session_init.py) and - # continues the session, so a raise is swallowed AND aborts the rest of - # this callback — silently disabling ALL capture, including the local - # JSONL the design guarantees is always written. Degrade to local-only - # (active = {}) with a discoverable WARNING and fall through so the - # LoggingHandler is still registered below. - log.warning( - "context-intelligence: session.working_dir capability is unavailable; " - "fan-out disabled for this session (local JSONL only)." - ) - else: - match_key = normalize_match_key(str(working_dir)) - active = select_active(destinations, match_key) - - # Build one dispatcher per ACTIVE destination (D9). - dispatchers = [ - _DestinationDispatcher( - name=d.name, - url=d.url, - api_key=d.api_key, - workspace=resolver.workspace, - working_dir=resolver.working_dir, - dispatch_timeout=resolver.dispatch_timeout, - read_timeout=resolver.dispatch_read_timeout, - connect_timeout=resolver.dispatch_connect_timeout, - failure_threshold=resolver.dispatch_failure_threshold, - queue_capacity=resolver.dispatch_queue_capacity, - close_drain_timeout=resolver.close_drain_timeout, - backoff_initial=resolver.dispatch_backoff_initial, - backoff_max=resolver.dispatch_backoff_max, - backoff_jitter=resolver.dispatch_backoff_jitter, - storage_path=str(resolver.base_path), - forwarding_log_dir=resolver.forwarding_log_dir, - auth_mode=d.auth_mode, - auth_resource=d.auth_resource, - ) - for d in active.values() - ] - await logging_handler.set_dispatchers(dispatchers) - - # --- Fan-out log line (S2) --- - if not destinations: - log.info("context-intelligence fan-out: no destinations configured — local JSONL only") - elif active: - log.info("context-intelligence fan-out: active -> %s", ", ".join(sorted(active))) - else: - log.warning( - "context-intelligence fan-out: routed to none (local-only) for working_dir=%s", - match_key, - ) + # --- Destination selection + dispatcher install (C2: working_dir ONLY) --- + # Factored into apply_active_dispatchers so the live-reapply capability can + # re-run the exact same routing computation mid-session. + await apply_active_dispatchers(coordinator, resolver, logging_handler, destinations) # Step 1: canonical kernel events + all module contributions # _discover_events returns: set(ALL_EVENTS) + collect_contributions diff --git a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/config_resolver.py b/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/config_resolver.py index bcb10dd2..8fae43a6 100644 --- a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/config_resolver.py +++ b/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/config_resolver.py @@ -579,6 +579,18 @@ def context_intelligence_api_key(self) -> str | None: ) return str(value) if value else None + def update_destinations(self, raw_destinations: dict[str, Any]) -> None: + """Replace the raw ``destinations`` config and invalidate the cache. + + The resolver caches ``destinations`` on first access and never re-reads + it. The live-reapply path calls this to install a fresh destinations + block mid-session (e.g. reflecting an on-disk settings.yaml exclude edit) + so the next ``.destinations`` / ``validate_destinations()`` access + re-derives from the new dict instead of returning the startup snapshot. + """ + self._config = {**self._config, "destinations": raw_destinations} + self._destinations = None + @property def destinations(self) -> dict[str, Destination]: """Resolved fan-out destinations, keyed by name. diff --git a/modules/hook-context-intelligence/tests/test_reapply_ingestion.py b/modules/hook-context-intelligence/tests/test_reapply_ingestion.py new file mode 100644 index 00000000..0d8b934b --- /dev/null +++ b/modules/hook-context-intelligence/tests/test_reapply_ingestion.py @@ -0,0 +1,365 @@ +"""Tests for the live-reapply ingestion capability. + +Covers: +- HookConfigResolver.update_destinations() cache invalidation (config_resolver.py) +- context_intelligence.reapply_ingestion capability: live exclude take-effect, + disk-consistency reporting +- context_intelligence.reapply_ingestion patching the in-memory session config + snapshot inherited by future sub-sessions (_patch_inherited_hook_config) +- context_intelligence.verify_ingestion_consistency fail-loud in both directions +- _patch_inherited_hook_config as a standalone unit +""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import MagicMock + +import pytest +import yaml + +from amplifier_module_hook_context_intelligence.config_resolver import HookConfigResolver +from tests.helpers import make_lifecycle_coordinator, mount_and_ready + + +def _bare_resolver_coordinator() -> MagicMock: + """A minimal coordinator double for direct HookConfigResolver construction.""" + coordinator = MagicMock() + coordinator.config = {} + coordinator.get_capability = MagicMock(return_value=None) + return coordinator + + +def _write_settings(path: Path, destinations: dict[str, Any]) -> str: + """Write a settings.yaml in the real overrides.hook-context-intelligence.config shape.""" + doc = { + "overrides": { + "hook-context-intelligence": { + "config": {"destinations": destinations}, + } + } + } + path.write_text(yaml.safe_dump(doc), encoding="utf-8") + return str(path) + + +# --------------------------------------------------------------------------- +# A. update_destinations cache invalidation +# --------------------------------------------------------------------------- +class TestUpdateDestinationsCacheInvalidation: + def test_destinations_property_reflects_new_block_after_update(self) -> None: + coordinator = _bare_resolver_coordinator() + initial = {"d1": {"url": "http://d1", "api_key": "k1", "include": ["**"], "exclude": []}} + resolver = HookConfigResolver({"destinations": initial}, coordinator) + + cached = resolver.destinations + assert cached["d1"].exclude == () + + new_raw = { + "d1": { + "url": "http://d1", + "api_key": "k1", + "include": ["**"], + "exclude": ["/tmp/foo/**"], + } + } + resolver.update_destinations(new_raw) + + refreshed = resolver.destinations + assert refreshed["d1"].exclude == ("/tmp/foo/**",) + assert refreshed is not cached, "destinations must be re-derived, not the stale cached dict" + # The old cached reference itself must never mutate in place either. + assert cached["d1"].exclude == () + + def test_validate_destinations_reflects_new_block_after_update(self) -> None: + coordinator = _bare_resolver_coordinator() + initial = {"d1": {"url": "http://d1", "api_key": "k1", "include": ["**"], "exclude": []}} + resolver = HookConfigResolver({"destinations": initial}, coordinator) + assert resolver.validate_destinations()["d1"].exclude == () + + new_raw = { + "d1": { + "url": "http://d1", + "api_key": "k1", + "include": ["**"], + "exclude": ["/tmp/foo/**"], + } + } + resolver.update_destinations(new_raw) + + assert resolver.validate_destinations()["d1"].exclude == ("/tmp/foo/**",) + + def test_update_destinations_can_add_a_new_destination(self) -> None: + coordinator = _bare_resolver_coordinator() + resolver = HookConfigResolver({"destinations": {}}, coordinator) + assert resolver.destinations == {} + + resolver.update_destinations( + {"new-dest": {"url": "http://new", "api_key": "k", "include": ["**"], "exclude": []}} + ) + + assert set(resolver.destinations) == {"new-dest"} + + +# --------------------------------------------------------------------------- +# B. reapply excludes a destination live +# --------------------------------------------------------------------------- +class TestReapplyExcludesDestinationLive: + async def test_reapply_updates_active_destinations(self, tmp_path: Path) -> None: + working_dir = str(tmp_path) + config = { + "destinations": { + "d1": {"url": "http://d1", "api_key": "k1", "include": ["**"], "exclude": []}, + "d2": {"url": "http://d2", "api_key": "k2", "include": ["**"], "exclude": []}, + } + } + coordinator = make_lifecycle_coordinator(working_dir=working_dir) + cleanup = await mount_and_ready(coordinator, config) + try: + reapply = coordinator.get_capability("context_intelligence.reapply_ingestion") + assert reapply is not None + + new_raw = { + "d1": {"url": "http://d1", "api_key": "k1", "include": ["**"], "exclude": ["**"]}, + "d2": {"url": "http://d2", "api_key": "k2", "include": ["**"], "exclude": []}, + } + report = await reapply(raw_destinations=new_raw) + + assert "d1" not in report["active"] + assert "d2" in report["active"] + assert report["destinations"]["d1"]["exclude"] == ["**"] + assert report["destinations"]["d2"]["exclude"] == [] + assert report["match_key"] + finally: + await cleanup() + + async def test_disk_consistent_is_none_without_settings_path(self, tmp_path: Path) -> None: + working_dir = str(tmp_path) + config = { + "destinations": { + "d1": {"url": "http://d1", "api_key": "k1", "include": ["**"], "exclude": []}, + } + } + coordinator = make_lifecycle_coordinator(working_dir=working_dir) + cleanup = await mount_and_ready(coordinator, config) + try: + reapply = coordinator.get_capability("context_intelligence.reapply_ingestion") + new_raw = { + "d1": {"url": "http://d1", "api_key": "k1", "include": ["**"], "exclude": ["**"]}, + } + + # Default verify_disk=True but no settings_path given -> the disk + # cross-check never runs; disk_consistent stays None either way. + report = await reapply(raw_destinations=new_raw) + assert report["disk_consistent"] is None + + report_no_verify = await reapply(raw_destinations=new_raw, verify_disk=False) + assert report_no_verify["disk_consistent"] is None + finally: + await cleanup() + + async def test_reapply_requires_a_destinations_source(self, tmp_path: Path) -> None: + working_dir = str(tmp_path) + config = {"destinations": {}} + coordinator = make_lifecycle_coordinator(working_dir=working_dir) + cleanup = await mount_and_ready(coordinator, config) + try: + reapply = coordinator.get_capability("context_intelligence.reapply_ingestion") + with pytest.raises(ValueError): + await reapply() + finally: + await cleanup() + + +# --------------------------------------------------------------------------- +# C. reapply patches the inherited session config snapshot +# --------------------------------------------------------------------------- +class TestReapplyPatchesInheritedSnapshot: + async def test_inherited_hook_config_patched(self, tmp_path: Path) -> None: + working_dir = str(tmp_path) + initial_raw = { + "d1": {"url": "http://d1", "api_key": "k1", "include": ["**"], "exclude": []}, + } + config = {"destinations": initial_raw} + coordinator = make_lifecycle_coordinator(working_dir=working_dir) + hooks_list = [ + {"module": "hook-context-intelligence", "config": {"destinations": initial_raw}}, + {"module": "other-hook", "config": {"foo": "bar"}}, + ] + coordinator.session = SimpleNamespace(config={"hooks": hooks_list}) + + cleanup = await mount_and_ready(coordinator, config) + try: + reapply = coordinator.get_capability("context_intelligence.reapply_ingestion") + new_raw = { + "d1": {"url": "http://d1", "api_key": "k1", "include": ["**"], "exclude": ["**"]}, + } + report = await reapply(raw_destinations=new_raw) + + assert report["inherited_snapshot_patched"] is True + ci_entry = next(h for h in hooks_list if h["module"] == "hook-context-intelligence") + assert ci_entry["config"]["destinations"] == new_raw + other_entry = next(h for h in hooks_list if h["module"] == "other-hook") + assert other_entry["config"] == {"foo": "bar"} + finally: + await cleanup() + + async def test_inherited_snapshot_not_patched_without_a_real_session_config( + self, tmp_path: Path + ) -> None: + """No .session.config dict wired up -> patch is a no-op, reported honestly.""" + working_dir = str(tmp_path) + config = { + "destinations": { + "d1": {"url": "http://d1", "api_key": "k1", "include": ["**"], "exclude": []}, + } + } + coordinator = make_lifecycle_coordinator(working_dir=working_dir) + # coordinator.session is left as an auto-vivified MagicMock attribute here + # (not a real dict-backed session config) -> _patch_inherited_hook_config + # must report False rather than silently pretending it patched anything. + cleanup = await mount_and_ready(coordinator, config) + try: + reapply = coordinator.get_capability("context_intelligence.reapply_ingestion") + report = await reapply(raw_destinations=config["destinations"]) + assert report["inherited_snapshot_patched"] is False + finally: + await cleanup() + + +# --------------------------------------------------------------------------- +# D. verify_ingestion_consistency fail-loud both directions +# --------------------------------------------------------------------------- +class TestVerifyIngestionConsistency: + async def test_live_patched_but_disk_not_written_raises(self, tmp_path: Path) -> None: + working_dir = str(tmp_path) + config = { + "destinations": { + "d1": {"url": "http://d1", "api_key": "k1", "include": ["**"], "exclude": []}, + } + } + coordinator = make_lifecycle_coordinator(working_dir=working_dir) + cleanup = await mount_and_ready(coordinator, config) + try: + resolver = coordinator.get_capability("context_intelligence.hook_config_resolver") + resolver.update_destinations( + { + "d1": { + "url": "http://d1", + "api_key": "k1", + "include": ["**"], + "exclude": ["**"], + } + } + ) + + settings_path = _write_settings( + tmp_path / "settings.yaml", + {"d1": {"url": "http://d1", "api_key": "k1", "include": ["**"], "exclude": []}}, + ) + + verify = coordinator.get_capability("context_intelligence.verify_ingestion_consistency") + with pytest.raises(RuntimeError, match="live ingestion filter disagrees"): + verify(settings_path) + finally: + await cleanup() + + async def test_disk_has_exclude_live_does_not_raises(self, tmp_path: Path) -> None: + working_dir = str(tmp_path) + config = { + "destinations": { + "d1": {"url": "http://d1", "api_key": "k1", "include": ["**"], "exclude": []}, + } + } + coordinator = make_lifecycle_coordinator(working_dir=working_dir) + cleanup = await mount_and_ready(coordinator, config) + try: + settings_path = _write_settings( + tmp_path / "settings.yaml", + {"d1": {"url": "http://d1", "api_key": "k1", "include": ["**"], "exclude": ["**"]}}, + ) + verify = coordinator.get_capability("context_intelligence.verify_ingestion_consistency") + with pytest.raises(RuntimeError, match="live ingestion filter disagrees"): + verify(settings_path) + finally: + await cleanup() + + async def test_live_and_disk_agree_returns_consistent(self, tmp_path: Path) -> None: + working_dir = str(tmp_path) + config = { + "destinations": { + "d1": {"url": "http://d1", "api_key": "k1", "include": ["**"], "exclude": []}, + } + } + coordinator = make_lifecycle_coordinator(working_dir=working_dir) + cleanup = await mount_and_ready(coordinator, config) + try: + settings_path = _write_settings( + tmp_path / "settings.yaml", + {"d1": {"url": "http://d1", "api_key": "k1", "include": ["**"], "exclude": []}}, + ) + verify = coordinator.get_capability("context_intelligence.verify_ingestion_consistency") + result = verify(settings_path) + + assert result["consistent"] is True + assert result["live_exclude"] == {"d1": []} + assert result["disk_exclude"] == {"d1": []} + finally: + await cleanup() + + +# --------------------------------------------------------------------------- +# E. _patch_inherited_hook_config unit +# --------------------------------------------------------------------------- +class TestPatchInheritedHookConfigUnit: + def test_patches_matching_hook_entry(self) -> None: + from amplifier_module_hook_context_intelligence import _patch_inherited_hook_config + + hooks_list = [ + {"module": "hook-context-intelligence", "config": {"destinations": {"old": {}}}}, + {"module": "other-hook", "config": {"foo": "bar"}}, + ] + coordinator = SimpleNamespace(session=SimpleNamespace(config={"hooks": hooks_list})) + + new_raw = {"d1": {"url": "http://d1", "api_key": "k1"}} + patched = _patch_inherited_hook_config(coordinator, new_raw) + + assert patched is True + ci_entry = next(h for h in hooks_list if h["module"] == "hook-context-intelligence") + assert ci_entry["config"]["destinations"] == new_raw + other_entry = next(h for h in hooks_list if h["module"] == "other-hook") + assert other_entry["config"] == {"foo": "bar"} + + def test_returns_false_when_no_session_or_config(self) -> None: + from amplifier_module_hook_context_intelligence import _patch_inherited_hook_config + + class Empty: + pass + + assert _patch_inherited_hook_config(Empty(), {"a": {}}) is False + + def test_returns_false_when_hooks_not_a_list(self) -> None: + from amplifier_module_hook_context_intelligence import _patch_inherited_hook_config + + coordinator = SimpleNamespace(session=SimpleNamespace(config={"hooks": "not-a-list"})) + assert _patch_inherited_hook_config(coordinator, {"a": {}}) is False + + def test_returns_false_when_no_matching_hook_entry(self) -> None: + from amplifier_module_hook_context_intelligence import _patch_inherited_hook_config + + coordinator = SimpleNamespace( + session=SimpleNamespace(config={"hooks": [{"module": "other-hook", "config": {}}]}) + ) + assert _patch_inherited_hook_config(coordinator, {"a": {}}) is False + + def test_falls_back_to_coordinator_config_when_no_session(self) -> None: + from amplifier_module_hook_context_intelligence import _patch_inherited_hook_config + + hooks_list = [{"module": "hook-context-intelligence", "config": {}}] + coordinator = SimpleNamespace(config={"hooks": hooks_list}) + + new_raw = {"d1": {"url": "http://d1"}} + assert _patch_inherited_hook_config(coordinator, new_raw) is True + assert hooks_list[0]["config"]["destinations"] == new_raw diff --git a/modules/hook-context-intelligence/tests/test_reapply_tool.py b/modules/hook-context-intelligence/tests/test_reapply_tool.py new file mode 100644 index 00000000..1caa8ca1 --- /dev/null +++ b/modules/hook-context-intelligence/tests/test_reapply_tool.py @@ -0,0 +1,170 @@ +"""Tests for ReapplyIngestionTool (modules/tool-context-intelligence-reapply). + +That module has no test infrastructure of its own (no tests/ dir, no lock +file) and its pyproject depends on the *published* amplifier-bundle-context- +intelligence package rather than this local checkout, so it cannot be +installed here without network access. Its own runtime dependency is just +``amplifier_core.models.ToolResult``, which IS available in this module's +venv (amplifier-core is already a dev dependency here) -- so the tool's +source is imported directly by adding its package directory to sys.path for +the duration of these tests (via ``monkeypatch.syspath_prepend``, which +pytest itself reverts on teardown). No source file is modified to make this +work. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +_TOOL_MODULE_DIR = ( + Path(__file__).parent.parent.parent.parent / "modules" / "tool-context-intelligence-reapply" +) + + +@pytest.fixture +def reapply_tool_module(monkeypatch: pytest.MonkeyPatch): + """Import amplifier_module_tool_context_intelligence_reapply from source. + + Skips (rather than fails) if the sibling module directory is not present + at the expected workspace layout -- this test is a bonus cross-module + check, not a load-bearing requirement of the hook module's own test + suite. + """ + if not _TOOL_MODULE_DIR.is_dir(): + pytest.skip(f"tool module directory not found at {_TOOL_MODULE_DIR}") + + monkeypatch.syspath_prepend(str(_TOOL_MODULE_DIR)) + module_name = "amplifier_module_tool_context_intelligence_reapply" + sys.modules.pop(module_name, None) + import importlib + + module = importlib.import_module(module_name) + yield module + sys.modules.pop(module_name, None) + + +class TestReapplyIngestionToolCapabilityPresent: + async def test_execute_returns_success_with_report(self, reapply_tool_module) -> None: + report = { + "match_key": "/some/dir/", + "active": ["d1"], + "inherited_snapshot_patched": True, + "destinations": {"d1": {"include": ["**"], "exclude": []}}, + "disk_consistent": None, + } + reapply_capability = AsyncMock(return_value=report) + + coordinator = MagicMock() + coordinator.get_capability = MagicMock( + side_effect=lambda name: ( + reapply_capability if name == "context_intelligence.reapply_ingestion" else None + ) + ) + + tool = reapply_tool_module.ReapplyIngestionTool(coordinator) + result = await tool.execute({}) + + assert result.success is True + assert result.output == report + reapply_capability.assert_awaited_once() + assert reapply_capability.await_args is not None + assert reapply_capability.await_args.kwargs["verify_disk"] is True + + async def test_execute_passes_custom_settings_path(self, reapply_tool_module) -> None: + reapply_capability = AsyncMock(return_value={"active": []}) + coordinator = MagicMock() + coordinator.get_capability = MagicMock( + side_effect=lambda name: ( + reapply_capability if name == "context_intelligence.reapply_ingestion" else None + ) + ) + + tool = reapply_tool_module.ReapplyIngestionTool(coordinator) + await tool.execute({"settings_path": "/tmp/custom-settings.yaml"}) + + assert reapply_capability.await_args is not None + assert reapply_capability.await_args.kwargs["settings_path"] == "/tmp/custom-settings.yaml" + + async def test_execute_surfaces_capability_exception(self, reapply_tool_module) -> None: + reapply_capability = AsyncMock(side_effect=RuntimeError("disk disagrees")) + coordinator = MagicMock() + coordinator.get_capability = MagicMock( + side_effect=lambda name: ( + reapply_capability if name == "context_intelligence.reapply_ingestion" else None + ) + ) + + tool = reapply_tool_module.ReapplyIngestionTool(coordinator) + result = await tool.execute({}) + + assert result.success is False + assert "disk disagrees" in result.output["error"] + + +class TestReapplyIngestionToolCapabilityAbsent: + async def test_execute_fails_loud_when_capability_unavailable( + self, reapply_tool_module + ) -> None: + coordinator = MagicMock() + coordinator.get_capability = MagicMock(return_value=None) + + tool = reapply_tool_module.ReapplyIngestionTool(coordinator) + result = await tool.execute({}) + + assert result.success is False + assert "unavailable" in result.output["error"] + assert "context_intelligence.reapply_ingestion" in result.output["error"] + + +class TestReapplyIngestionToolVerifyOnly: + async def test_verify_only_uses_verify_capability(self, reapply_tool_module) -> None: + verify_result = {"live_exclude": {}, "disk_exclude": {}, "consistent": True} + verify_capability = MagicMock(return_value=verify_result) + coordinator = MagicMock() + coordinator.get_capability = MagicMock( + side_effect=lambda name: ( + verify_capability + if name == "context_intelligence.verify_ingestion_consistency" + else None + ) + ) + + tool = reapply_tool_module.ReapplyIngestionTool(coordinator) + result = await tool.execute({"verify_only": True}) + + assert result.success is True + assert result.output == verify_result + verify_capability.assert_called_once() + + async def test_verify_only_fails_loud_when_capability_unavailable( + self, reapply_tool_module + ) -> None: + coordinator = MagicMock() + coordinator.get_capability = MagicMock(return_value=None) + + tool = reapply_tool_module.ReapplyIngestionTool(coordinator) + result = await tool.execute({"verify_only": True}) + + assert result.success is False + assert "unavailable" in result.output["error"] + + async def test_verify_only_surfaces_capability_exception(self, reapply_tool_module) -> None: + verify_capability = MagicMock(side_effect=RuntimeError("live/disk mismatch")) + coordinator = MagicMock() + coordinator.get_capability = MagicMock( + side_effect=lambda name: ( + verify_capability + if name == "context_intelligence.verify_ingestion_consistency" + else None + ) + ) + + tool = reapply_tool_module.ReapplyIngestionTool(coordinator) + result = await tool.execute({"verify_only": True}) + + assert result.success is False + assert "live/disk mismatch" in result.output["error"] diff --git a/modules/tool-context-intelligence-reapply/amplifier_module_tool_context_intelligence_reapply/__init__.py b/modules/tool-context-intelligence-reapply/amplifier_module_tool_context_intelligence_reapply/__init__.py new file mode 100644 index 00000000..6cf8317b --- /dev/null +++ b/modules/tool-context-intelligence-reapply/amplifier_module_tool_context_intelligence_reapply/__init__.py @@ -0,0 +1,110 @@ +"""Same-session ingestion-reapply tool. + +Exposes the root session's ``context_intelligence.reapply_ingestion`` capability +(registered by the ingestion hook) as an agent-callable tool, so the ROOT agent +can make a mid-session destination `exclude` edit take effect immediately -- no +restart -- against the already-running session's fan-out. + +Only meaningful in a session that actually mounts the ingestion hook (i.e. the +ROOT session via behaviors/context-intelligence-logging.yaml). A delegated agent +that does not mount the hook has no such capability on its own coordinator; the +tool then fails loud rather than silently doing nothing -- which is exactly the +negative-control boundary (a delegate cannot reach the root's live hook state). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from amplifier_core.models import ToolResult + +__amplifier_module_type__ = "tool" +__all__ = ["mount"] + +_CAP = "context_intelligence.reapply_ingestion" +_VERIFY_CAP = "context_intelligence.verify_ingestion_consistency" +_DEFAULT_SETTINGS = str(Path("~/.amplifier/settings.yaml").expanduser()) + + +class ReapplyIngestionTool: + """Re-apply the ingestion hook's per-destination include/exclude live.""" + + def __init__(self, coordinator: Any) -> None: + self._coordinator = coordinator + + @property + def name(self) -> str: + return "reapply_ingestion" + + @property + def description(self) -> str: + return ( + "Make a Context Intelligence ingestion destination `exclude` change take " + "effect IMMEDIATELY in the CURRENTLY-RUNNING session, without a restart. " + "Call this AFTER the exclude edit has been written to settings.yaml. Re-reads " + "the destinations block from settings.yaml, re-evaluates routing for this " + "session's working directory, and swaps the live per-destination dispatchers " + "(drain-safe). Returns the new active destinations and their include/exclude, " + "and fails loud if the live filter does not match what is on disk." + ) + + @property + def input_schema(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "settings_path": { + "type": "string", + "description": ( + "Path to the settings.yaml carrying the destinations block. " + f"Defaults to {_DEFAULT_SETTINGS}." + ), + }, + "verify_only": { + "type": "boolean", + "description": ( + "If true, do NOT reapply -- only fail-loud compare the session's " + "LIVE exclude filter against settings.yaml on disk (both directions)." + ), + }, + }, + } + + async def execute(self, input: dict[str, Any]) -> ToolResult: # noqa: A002 + settings_path = input.get("settings_path") or _DEFAULT_SETTINGS + verify_only = bool(input.get("verify_only")) + + if verify_only: + verify = self._coordinator.get_capability(_VERIFY_CAP) + if verify is None: + return ToolResult( + success=False, + output={"error": f"capability {_VERIFY_CAP} unavailable in this session " + "(no ingestion hook mounted here) -- cannot verify."}, + ) + try: + result = verify(settings_path) + except Exception as exc: # noqa: BLE001 - surface the fail-loud reason to the agent + return ToolResult(success=False, output={"error": str(exc)}) + return ToolResult(success=True, output=result) + + reapply = self._coordinator.get_capability(_CAP) + if reapply is None: + return ToolResult( + success=False, + output={"error": f"capability {_CAP} unavailable in this session (no " + "ingestion hook mounted here) -- this session's fan-out was NOT " + "changed. The reapply tool only affects the session that mounts " + "the ingestion hook (the root session)."}, + ) + try: + report = await reapply(settings_path=settings_path, verify_disk=True) + except Exception as exc: # noqa: BLE001 - surface the fail-loud reason to the agent + return ToolResult(success=False, output={"error": str(exc)}) + return ToolResult(success=True, output=report) + + +async def mount(coordinator: Any, config: Any) -> None: + tool = ReapplyIngestionTool(coordinator) + await coordinator.mount("tools", tool, name=tool.name) diff --git a/modules/tool-context-intelligence-reapply/pyproject.toml b/modules/tool-context-intelligence-reapply/pyproject.toml new file mode 100644 index 00000000..8803b2c3 --- /dev/null +++ b/modules/tool-context-intelligence-reapply/pyproject.toml @@ -0,0 +1,39 @@ +[project] +name = "amplifier-module-tool-context-intelligence-reapply" +version = "0.1.0" +description = "Same-session ingestion reapply tool 14 make a destination include/exclude change take effect live, no restart" +requires-python = ">=3.11" +license = "MIT" + +dependencies = [ + "amplifier-bundle-context-intelligence @ git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main", +] + +[project.entry-points."amplifier.modules"] +tool-context-intelligence-reapply = "amplifier_module_tool_context_intelligence_reapply:mount" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.uv] +package = true + +[tool.hatch.build.targets.wheel] +packages = ["amplifier_module_tool_context_intelligence_reapply"] + +[tool.hatch.metadata] +allow-direct-references = true + +[dependency-groups] +dev = [ + "amplifier-core>=1.6.0", + "pytest>=9.0.3", + "pytest-asyncio>=0.24", + "pyright>=1.1.411", + "ruff>=0.14", +] + +[tool.ruff] +target-version = "py311" +line-length = 100 From b40b4b57f8f1746e90ca3e7bd30c7288d3292e51 Mon Sep 17 00:00:00 2001 From: colombod Date: Wed, 2 Sep 2026 20:28:54 +0000 Subject: [PATCH 34/39] feat(server-data-ops): wire live exclude reapply into the delete flow narrative MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folder-exclusion offer now states BOTH effects and how the agent performs the patch: - persist the pattern to settings.yaml => the exclude filter is correct in a NEW session too (durable source of truth); - run reapply_ingestion in the ROOT session => the already-running session and any sub-session spawned afterward stop immediately, no restart. The reapply tool lives in the root session (where the ingestion hook is mounted), not in this delegated agent; if unreachable, the agent asks the user/root to run it. The user-facing message closes the loop naming both effects. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- agents/server-data-ops.md | 8 ++++++- .../SKILL.md | 23 +++++++++++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/agents/server-data-ops.md b/agents/server-data-ops.md index 68d3e552..f18277fe 100644 --- a/agents/server-data-ops.md +++ b/agents/server-data-ops.md @@ -129,7 +129,13 @@ narrative work to `graph-analyst` instead of querying the graph yourself. `~/.amplifier/settings.yaml` (a gitignore-style pattern matched on `working_dir`) in your own user-facing message, and offer to guide them through applying it. You never edit the file yourself. **Not about sub-sessions or delete scope** — deletion always - removes the whole session graph regardless of this setting. + removes the whole session graph regardless of this setting. Applying it has two + effects, and your message must state both: persisting the pattern to `settings.yaml` + makes the exclude filter correct in a **new session** too, and running + `reapply_ingestion` in the **root session** makes it take effect in the + already-running session (and any sub-session spawned afterward) with no restart. That + tool lives in the root session, not in this delegated agent — if you can't reach it, + ask the user or the root session to run it. 4. **Impact.** State it (see "Impact + permanence"). 5. **Confirm.** Explicit, naming the id and server(s). 6. **Delete and verify on every server** (all-servers completeness). diff --git a/skills/context-intelligence-server-data-ops/SKILL.md b/skills/context-intelligence-server-data-ops/SKILL.md index de517d91..e7157255 100644 --- a/skills/context-intelligence-server-data-ops/SKILL.md +++ b/skills/context-intelligence-server-data-ops/SKILL.md @@ -146,11 +146,26 @@ Session by Description, Then Delete** when the request names or searches for som `overrides.hook-context-intelligence.config.destinations..exclude` in `~/.amplifier/settings.yaml` — a list of gitignore-style patterns matched against a session's `working_dir`; adding one for the current folder stops that destination - being selected for future sessions there. The agent has no filesystem tool, so it + being selected. The agent has no filesystem tool, so it shows the setting in its own message and offers to guide the user through applying it — never edits the file itself. Make this offer every time, regardless of whether you can confirm the folder is currently included. Wait for their answer, confirm whether they applied it, then move on. + + **Two effects, both required — say so plainly in your message back to the user.** + Writing the pattern into `settings.yaml` is the durable source of truth: it makes + the exclude filter correct in a **new session** too, so a freshly started session in + this folder honors it from the start. To also stop the **already-running** session + (and any sub-session it spawns afterward) without a restart, the + `reapply_ingestion` tool is run in the **root session** — it re-reads + `settings.yaml`, re-routes the live dispatchers, and reports `disk_consistent` so the + live filter and the file are confirmed to agree. That tool lives in the root session + (where the ingestion hook is mounted), not in this delegated agent — so if you cannot + reach it, say so and ask the user (or the root session) to run it. Close the loop + with a message of the shape: "Exclude saved to settings.yaml — **make sure the + exclude filter is correct in a new session too** — and applied live to the current + session; both the current session and any new session in this folder now stop + pushing to ``." 4. **State the impact.** The whole graph — the session plus every descendant (forks, sub-sessions, delegated children) — plus its blobs and queue records, permanently, on the server(s) found in step 2. Shared nodes are kept; there is no undo. @@ -207,7 +222,11 @@ mine" rather than a topic/date/server description. the current-session delete's step 3 — show it in your own message, guide the user through applying it (you never edit the file yourself), and confirm it's applied before moving on: while the folder is still in scope, continued - ingestion would keep re-creating the very data you are about to delete. + ingestion would keep re-creating the very data you are about to delete. Apply it + with BOTH effects from step 3 — persisted to `settings.yaml` (so the exclude + filter is correct in a new session too) AND made live via `reapply_ingestion` in + the root session (so the current running session, and any sub-session it spawns + afterward, stop immediately without a restart) — before you enumerate or delete. 2. **Run the S2 search, by criteria (this folder + mine).** Delegate to `graph-analyst` to enumerate every **root** session (never subsessions) whose `working_dir` matches the resolved directory AND `created_by` is you, From b0f1d27393ea94c75b9c463a4ecdba7b948cbee5 Mon Sep 17 00:00:00 2001 From: colombod Date: Thu, 3 Sep 2026 08:30:59 +0000 Subject: [PATCH 35/39] style(ingestion): fix ruff formatting (CI Lint) and remove jargon from live-reapply files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ruff format (0.15.11) the two new/modified files -> CI 'Check formatting' green. - Plainer wording in docstrings/description: drop 'spike-config', 'mid-flight', 'fan-out' phrasing and a mangled em-dash; no behaviour change. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- .../__init__.py | 16 +++++++++------ .../__init__.py | 20 +++++++++++-------- .../pyproject.toml | 2 +- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/__init__.py b/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/__init__.py index eac2ae6e..8d34baf7 100644 --- a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/__init__.py +++ b/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/__init__.py @@ -151,12 +151,12 @@ def _read_destinations_from_settings(settings_path: str) -> dict[str, Any]: """Read the raw ``destinations`` block from a settings.yaml on disk. The hook itself never reads settings.yaml (the kernel merges/expands it and - hands mount() a config dict). The live-reapply path re-reads the file so a - mid-session exclude edit on disk is reflected in the running session. + hands mount() a config dict). The reapply path re-reads the file so an + exclude edit made to it during a session is reflected in the running session. Looks first under ``overrides.hook-context-intelligence.config.destinations`` - (the real settings.yaml shape), then falls back to a top-level - ``destinations:`` key (compact spike-config shape). Returns {} if neither is + (the settings.yaml shape), then falls back to a top-level ``destinations:`` + key. Returns {} if neither is present. Does NOT expand ${VAR}; callers writing on-disk config for reapply are expected to write already-resolved values (same contract the kernel applies before mount()). @@ -191,7 +191,11 @@ def _patch_inherited_hook_config(coordinator: Any, raw_destinations: dict[str, A Returns True if a hook-context-intelligence entry was patched. """ session = getattr(coordinator, "session", None) - cfg = getattr(session, "config", None) if session is not None else getattr(coordinator, "config", None) + cfg = ( + getattr(session, "config", None) + if session is not None + else getattr(coordinator, "config", None) + ) if not isinstance(cfg, dict): return False hooks = cfg.get("hooks") @@ -272,7 +276,7 @@ async def reapply_ingestion( settings_path: str | None = None, verify_disk: bool = True, ) -> dict[str, Any]: - """Re-apply fan-out routing to THIS session's live hook, mid-flight. + """Re-apply destination routing to this session's live hook, without a restart. Source of the new destinations block (exactly one): - ``settings_path``: re-read the block from a settings.yaml on disk diff --git a/modules/tool-context-intelligence-reapply/amplifier_module_tool_context_intelligence_reapply/__init__.py b/modules/tool-context-intelligence-reapply/amplifier_module_tool_context_intelligence_reapply/__init__.py index 6cf8317b..b2223362 100644 --- a/modules/tool-context-intelligence-reapply/amplifier_module_tool_context_intelligence_reapply/__init__.py +++ b/modules/tool-context-intelligence-reapply/amplifier_module_tool_context_intelligence_reapply/__init__.py @@ -2,8 +2,8 @@ Exposes the root session's ``context_intelligence.reapply_ingestion`` capability (registered by the ingestion hook) as an agent-callable tool, so the ROOT agent -can make a mid-session destination `exclude` edit take effect immediately -- no -restart -- against the already-running session's fan-out. +can make a destination `exclude` edit made during a session take effect +immediately -- no restart -- against the already-running session's ingestion. Only meaningful in a session that actually mounts the ingestion hook (i.e. the ROOT session via behaviors/context-intelligence-logging.yaml). A delegated agent @@ -80,8 +80,10 @@ async def execute(self, input: dict[str, Any]) -> ToolResult: # noqa: A002 if verify is None: return ToolResult( success=False, - output={"error": f"capability {_VERIFY_CAP} unavailable in this session " - "(no ingestion hook mounted here) -- cannot verify."}, + output={ + "error": f"capability {_VERIFY_CAP} unavailable in this session " + "(no ingestion hook mounted here) -- cannot verify." + }, ) try: result = verify(settings_path) @@ -93,10 +95,12 @@ async def execute(self, input: dict[str, Any]) -> ToolResult: # noqa: A002 if reapply is None: return ToolResult( success=False, - output={"error": f"capability {_CAP} unavailable in this session (no " - "ingestion hook mounted here) -- this session's fan-out was NOT " - "changed. The reapply tool only affects the session that mounts " - "the ingestion hook (the root session)."}, + output={ + "error": f"capability {_CAP} unavailable in this session (no " + "ingestion hook mounted here) -- this session's fan-out was NOT " + "changed. The reapply tool only affects the session that mounts " + "the ingestion hook (the root session)." + }, ) try: report = await reapply(settings_path=settings_path, verify_disk=True) diff --git a/modules/tool-context-intelligence-reapply/pyproject.toml b/modules/tool-context-intelligence-reapply/pyproject.toml index 8803b2c3..975f211d 100644 --- a/modules/tool-context-intelligence-reapply/pyproject.toml +++ b/modules/tool-context-intelligence-reapply/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "amplifier-module-tool-context-intelligence-reapply" version = "0.1.0" -description = "Same-session ingestion reapply tool 14 make a destination include/exclude change take effect live, no restart" +description = "Same-session ingestion reapply tool - make a destination include/exclude change take effect live, without a restart" requires-python = ">=3.11" license = "MIT" From 885099759e52bbd150a5c3c297a2f1878c0f9369 Mon Sep 17 00:00:00 2001 From: colombod Date: Thu, 3 Sep 2026 08:54:06 +0000 Subject: [PATCH 36/39] feat(server-data-ops): honor the delete 409 Retry-After hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server now returns a Retry-After on a delete refused because the graph is still draining. Make the client and delete tool act on it instead of surfacing a bare 409 the user has to poll by hand. - CIClientError carries retry_after; _http_delete_strict/_http_get_strict parse the Retry-After delta-seconds header across requests/httpx/urllib. - delete_session_tool: on a 409 WITH a retry hint, retry with a bounded backoff (honoring the hint) so a just-finished session drains and deletes on its own; if it never clears, return a precise 'still draining, wait ~Ns' error carrying retry_after. A 409 with no hint (ambiguous id) is never retried. - Tests: _retry_after_seconds parsing (absent/invalid/negative/zero); tool retries-then-succeeds, retries-then-precise-error, and ambiguous-not-retried. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- context_intelligence/client.py | 37 ++++++++- .../delete_session_tool.py | 70 +++++++++++----- .../tests/test_delete_session_tool.py | 83 +++++++++++++++++-- tests/test_client.py | 53 ++++++++++++ 4 files changed, 213 insertions(+), 30 deletions(-) diff --git a/context_intelligence/client.py b/context_intelligence/client.py index f8f98829..ad7dfe80 100644 --- a/context_intelligence/client.py +++ b/context_intelligence/client.py @@ -65,6 +65,7 @@ def __init__( error_type: str, url: str, status_code: int | None = None, + retry_after: int | None = None, ) -> None: super().__init__(message) #: One of "connection_error" | "timeout" | "http_status" | "decode_error" @@ -74,6 +75,11 @@ def __init__( self.error_type = error_type self.url = url self.status_code = status_code + #: Seconds to wait before retrying, parsed from the ``Retry-After`` + #: response header. Set only on a retryable status (a 409 for a delete + #: refused because the session graph is still draining). ``None`` when + #: the server sent no such hint -- the failure is not retryable. + self.retry_after = retry_after # --------------------------------------------------------------------------- @@ -186,12 +192,14 @@ def _http_get_strict(url: str, headers: dict[str, str]) -> Any: except _requests.exceptions.Timeout as exc: raise CIClientError(f"timeout listing {url}", error_type="timeout", url=url) from exc except _requests.exceptions.HTTPError as exc: - status = getattr(getattr(exc, "response", None), "status_code", None) + _resp = getattr(exc, "response", None) + status = getattr(_resp, "status_code", None) raise CIClientError( f"HTTP {status} from {url}", error_type="http_status", url=url, status_code=status, + retry_after=_retry_after_seconds(getattr(_resp, "headers", None)), ) from exc except (ValueError, json.JSONDecodeError) as exc: # resp.json() failed raise CIClientError( @@ -216,6 +224,7 @@ def _http_get_strict(url: str, headers: dict[str, str]) -> Any: error_type="http_status", url=url, status_code=exc.response.status_code, + retry_after=_retry_after_seconds(exc.response.headers), ) from exc except (ValueError, json.JSONDecodeError) as exc: # resp.json() failed raise CIClientError( @@ -237,6 +246,7 @@ def _http_get_strict(url: str, headers: dict[str, str]) -> Any: error_type="http_status", url=url, status_code=exc.code, + retry_after=_retry_after_seconds(getattr(exc, "headers", None)), ) from exc except (TimeoutError, socket.timeout) as exc: # read timeout raise CIClientError(f"timeout listing {url}", error_type="timeout", url=url) from exc @@ -255,6 +265,25 @@ def _http_get_strict(url: str, headers: dict[str, str]) -> Any: ) from exc +def _retry_after_seconds(response_headers: Any) -> int | None: + """Parse a non-negative integer ``Retry-After`` from response headers. + + Accepts any headers-like object with ``.get`` (requests/httpx/urllib all + provide one). Returns ``None`` when the header is absent or not a plain + delta-seconds integer (the HTTP-date form is not used by this server). + """ + if response_headers is None: + return None + raw = response_headers.get("Retry-After") + if raw is None: + return None + try: + value = int(str(raw).strip()) + except (TypeError, ValueError): + return None + return value if value >= 0 else None + + def _http_delete_strict(url: str, headers: dict[str, str]) -> Any: """DELETE *url* with *headers*, classifying-and-RAISING ``CIClientError`` on failure. @@ -284,12 +313,14 @@ def _http_delete_strict(url: str, headers: dict[str, str]) -> Any: except _requests.exceptions.Timeout as exc: raise CIClientError(f"timeout deleting {url}", error_type="timeout", url=url) from exc except _requests.exceptions.HTTPError as exc: - status = getattr(getattr(exc, "response", None), "status_code", None) + _resp = getattr(exc, "response", None) + status = getattr(_resp, "status_code", None) raise CIClientError( f"HTTP {status} from {url}", error_type="http_status", url=url, status_code=status, + retry_after=_retry_after_seconds(getattr(_resp, "headers", None)), ) from exc except (ValueError, json.JSONDecodeError) as exc: # resp.json() failed raise CIClientError( @@ -314,6 +345,7 @@ def _http_delete_strict(url: str, headers: dict[str, str]) -> Any: error_type="http_status", url=url, status_code=exc.response.status_code, + retry_after=_retry_after_seconds(exc.response.headers), ) from exc except (ValueError, json.JSONDecodeError) as exc: # resp.json() failed raise CIClientError( @@ -335,6 +367,7 @@ def _http_delete_strict(url: str, headers: dict[str, str]) -> Any: error_type="http_status", url=url, status_code=exc.code, + retry_after=_retry_after_seconds(getattr(exc, "headers", None)), ) from exc except (TimeoutError, socket.timeout) as exc: # read timeout raise CIClientError(f"timeout deleting {url}", error_type="timeout", url=url) from exc diff --git a/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/delete_session_tool.py b/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/delete_session_tool.py index c7709298..ba8ba3d4 100644 --- a/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/delete_session_tool.py +++ b/modules/tool-server-data-ops/amplifier_module_tool_server_data_ops/delete_session_tool.py @@ -12,6 +12,7 @@ from __future__ import annotations +import asyncio from typing import Any from amplifier_core.models import ToolResult @@ -165,30 +166,53 @@ async def execute(self, input: dict[str, Any]) -> ToolResult: auth_strategy=conn.auth_strategy, timeout=self._tool_resolver.request_timeout, ) - try: - result = await async_client.delete_session(session_id) - except CIClientError as exc: - # success=False + output unset is safe: ToolResult.model_post_init - # back-fills output from error["message"] when output is None. Do NOT - # also set output= here or that back-fill is suppressed. - origin_name = conn.origin.name if conn.origin and conn.origin.name else conn.url - message = f"delete failed against {origin_name}: {exc}" - if exc.status_code == 404: - message = f"unknown session {session_id!r} on {origin_name}" - elif exc.status_code == 409: - message = ( - f"session {session_id!r} on {origin_name} is still receiving data " - "and cannot be deleted yet, or the id is ambiguous across workspaces" + # A 409 with a Retry-After hint means the graph is still draining -- a + # transient, retryable refusal. Honor the server's hint with a bounded + # backoff so a normal "just finished" session drains and deletes without + # the user having to poll by hand; a genuinely still-live session simply + # exhausts the bound and returns a precise "still draining" message + # rather than blocking forever. An ambiguous-id 409 has no Retry-After, + # so it is never retried. + max_retries = 3 + attempt = 0 + while True: + try: + result = await async_client.delete_session(session_id) + break + except CIClientError as exc: + if exc.status_code == 409 and exc.retry_after is not None and attempt < max_retries: + attempt += 1 + await asyncio.sleep(exc.retry_after) + continue + # success=False + output unset is safe: ToolResult.model_post_init + # back-fills output from error["message"] when output is None. Do NOT + # also set output= here or that back-fill is suppressed. + origin_name = conn.origin.name if conn.origin and conn.origin.name else conn.url + message = f"delete failed against {origin_name}: {exc}" + if exc.status_code == 404: + message = f"unknown session {session_id!r} on {origin_name}" + elif exc.status_code == 409 and exc.retry_after is not None: + message = ( + f"session {session_id!r} on {origin_name} is still receiving " + f"data (still draining after {attempt} automatic retr" + f"{'y' if attempt == 1 else 'ies'}); wait ~{exc.retry_after}s " + "and try again" + ) + elif exc.status_code == 409: + message = ( + f"session {session_id!r} on {origin_name} could not be deleted: " + "the id is ambiguous across workspaces" + ) + return ToolResult( + success=False, + error={ + "message": message, + "type": exc.error_type, # connection_error|timeout|http_status|decode_error + "source": _origin_dict(conn.origin), + **({"status_code": exc.status_code} if exc.status_code is not None else {}), + **({"retry_after": exc.retry_after} if exc.retry_after is not None else {}), + }, ) - return ToolResult( - success=False, - error={ - "message": message, - "type": exc.error_type, # connection_error|timeout|http_status|decode_error - "source": _origin_dict(conn.origin), - **({"status_code": exc.status_code} if exc.status_code is not None else {}), - }, - ) return ToolResult( success=True, output={"source": _origin_dict(conn.origin), "result": result}, diff --git a/modules/tool-server-data-ops/tests/test_delete_session_tool.py b/modules/tool-server-data-ops/tests/test_delete_session_tool.py index d9b40829..3ad81c46 100644 --- a/modules/tool-server-data-ops/tests/test_delete_session_tool.py +++ b/modules/tool-server-data-ops/tests/test_delete_session_tool.py @@ -416,9 +416,45 @@ async def test_404_surfaces_as_clear_tool_error(self) -> None: assert "missing" in result.error["message"] assert result.error["source"] is not None - async def test_409_surfaces_as_clear_tool_error(self) -> None: - """A 409 (still receiving data / ambiguous id) must never be silently - treated as a completed delete -- it surfaces as a clear tool error.""" + async def test_409_ambiguous_id_not_retried(self) -> None: + """A 409 with NO Retry-After hint is the ambiguous-id case: not + retryable, surfaced once as a clear tool error, never silently treated + as a completed delete.""" + from context_intelligence.client import CIClientError + + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000") + coordinator = _make_coordinator(resolver=hook_resolver) + tool = DeleteSessionTool(coordinator) + + mock_instance = AsyncMock() + mock_instance.delete_session = AsyncMock( + side_effect=CIClientError( + "HTTP 409 from http://ci-server:9000/sessions/dup", + error_type="http_status", + url="http://ci-server:9000/sessions/dup", + status_code=409, + retry_after=None, + ) + ) + mock_cls = MagicMock(return_value=mock_instance) + with patch( + "amplifier_module_tool_server_data_ops.delete_session_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"session_id": "dup"}) + + assert result.success is False + assert result.error is not None + assert result.error["status_code"] == 409 + assert "ambiguous" in result.error["message"] + assert mock_instance.delete_session.await_count == 1 # never retried + + async def test_409_still_draining_retries_then_surfaces_precise_error(self) -> None: + """A 409 WITH a Retry-After hint (graph still draining) is retried a + bounded number of times honoring the hint; if it never clears, it + surfaces a precise 'still draining' error carrying retry_after.""" from context_intelligence.client import CIClientError from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool @@ -434,6 +470,7 @@ async def test_409_surfaces_as_clear_tool_error(self) -> None: error_type="http_status", url="http://ci-server:9000/sessions/live", status_code=409, + retry_after=0, # 0s so the bounded backoff runs instantly ) ) mock_cls = MagicMock(return_value=mock_instance) @@ -445,6 +482,42 @@ async def test_409_surfaces_as_clear_tool_error(self) -> None: assert result.success is False assert result.error is not None - assert result.error["type"] == "http_status" assert result.error["status_code"] == 409 - assert "still receiving data" in result.error["message"] + assert result.error["retry_after"] == 0 + assert "still draining" in result.error["message"] + # 1 initial attempt + 3 bounded retries + assert mock_instance.delete_session.await_count == 4 + + async def test_409_still_draining_then_succeeds_after_backoff(self) -> None: + """If the drain finishes mid-backoff, the bounded retry loop deletes + successfully instead of surfacing an error.""" + from context_intelligence.client import CIClientError + + from amplifier_module_tool_server_data_ops.delete_session_tool import DeleteSessionTool + + hook_resolver = _make_hook_resolver(server_url="http://ci-server:9000") + coordinator = _make_coordinator(resolver=hook_resolver) + tool = DeleteSessionTool(coordinator) + + pending = CIClientError( + "HTTP 409 from http://ci-server:9000/sessions/live", + error_type="http_status", + url="http://ci-server:9000/sessions/live", + status_code=409, + retry_after=0, + ) + mock_instance = AsyncMock() + # 409 once, then a real delete result on the retry. + mock_instance.delete_session = AsyncMock( + side_effect=[pending, {"root_id": "live", "nodes_deleted": 3}] + ) + mock_cls = MagicMock(return_value=mock_instance) + with patch( + "amplifier_module_tool_server_data_ops.delete_session_tool.AsyncCIClient", + mock_cls, + ): + result = await tool.execute({"session_id": "live"}) + + assert result.success is True + assert result.output["result"] == {"root_id": "live", "nodes_deleted": 3} + assert mock_instance.delete_session.await_count == 2 diff --git a/tests/test_client.py b/tests/test_client.py index 183466c4..fd245a15 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -2077,3 +2077,56 @@ async def test_dict_envelope_empty_blobs_returns_empty_set(self): server.shutdown() server.server_close() assert result == set() + + +class TestRetryAfterParsing: + """_retry_after_seconds parses the Retry-After delta-seconds header.""" + + def test_parses_positive_integer(self): + from context_intelligence.client import _retry_after_seconds + + assert _retry_after_seconds({"Retry-After": "2"}) == 2 + + def test_zero_is_valid(self): + from context_intelligence.client import _retry_after_seconds + + assert _retry_after_seconds({"Retry-After": "0"}) == 0 + + def test_absent_header_is_none(self): + from context_intelligence.client import _retry_after_seconds + + assert _retry_after_seconds({}) is None + + def test_none_headers_is_none(self): + from context_intelligence.client import _retry_after_seconds + + assert _retry_after_seconds(None) is None + + def test_non_integer_is_none(self): + from context_intelligence.client import _retry_after_seconds + + # HTTP-date form is not used by this server -> treated as absent. + assert _retry_after_seconds({"Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT"}) is None + + def test_negative_is_none(self): + from context_intelligence.client import _retry_after_seconds + + assert _retry_after_seconds({"Retry-After": "-5"}) is None + + def test_ciclienterror_carries_retry_after(self): + from context_intelligence.client import CIClientError + + exc = CIClientError( + "HTTP 409", + error_type="http_status", + url="http://x/sessions/s", + status_code=409, + retry_after=2, + ) + assert exc.retry_after == 2 + + def test_ciclienterror_retry_after_defaults_none(self): + from context_intelligence.client import CIClientError + + exc = CIClientError("boom", error_type="timeout", url="http://x") + assert exc.retry_after is None From 212a7a3dedad8aa2304cd3a92c9aefc1f8aa322d Mon Sep 17 00:00:00 2001 From: colombod Date: Thu, 3 Sep 2026 10:56:00 +0000 Subject: [PATCH 37/39] refactor(ingestion): rename reapply -> set_ingestion_filters; drop external refs from docstrings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename the tool/capability to set_ingestion_filters (by destination name): clearer than 'reapply', accurate whether the filters come from settings.yaml or an explicit block. Module, capability id, class, entry point, tests, behavior, agent and skill references all follow. Behavior identical. - Reword docstrings/comments to describe behavior in this bundle's own terms rather than naming symbols in other repos; no logic change. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- .../tests/{test_reapply_tool.py => test_set_filters_tool.py} | 0 .../{test_reapply_ingestion.py => test_set_ingestion_filters.py} | 0 .../__init__.py | 0 .../pyproject.toml | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename modules/hook-context-intelligence/tests/{test_reapply_tool.py => test_set_filters_tool.py} (100%) rename modules/hook-context-intelligence/tests/{test_reapply_ingestion.py => test_set_ingestion_filters.py} (100%) rename modules/{tool-context-intelligence-reapply/amplifier_module_tool_context_intelligence_reapply => tool-context-intelligence-set-filters/amplifier_module_tool_context_intelligence_set_filters}/__init__.py (100%) rename modules/{tool-context-intelligence-reapply => tool-context-intelligence-set-filters}/pyproject.toml (100%) diff --git a/modules/hook-context-intelligence/tests/test_reapply_tool.py b/modules/hook-context-intelligence/tests/test_set_filters_tool.py similarity index 100% rename from modules/hook-context-intelligence/tests/test_reapply_tool.py rename to modules/hook-context-intelligence/tests/test_set_filters_tool.py diff --git a/modules/hook-context-intelligence/tests/test_reapply_ingestion.py b/modules/hook-context-intelligence/tests/test_set_ingestion_filters.py similarity index 100% rename from modules/hook-context-intelligence/tests/test_reapply_ingestion.py rename to modules/hook-context-intelligence/tests/test_set_ingestion_filters.py diff --git a/modules/tool-context-intelligence-reapply/amplifier_module_tool_context_intelligence_reapply/__init__.py b/modules/tool-context-intelligence-set-filters/amplifier_module_tool_context_intelligence_set_filters/__init__.py similarity index 100% rename from modules/tool-context-intelligence-reapply/amplifier_module_tool_context_intelligence_reapply/__init__.py rename to modules/tool-context-intelligence-set-filters/amplifier_module_tool_context_intelligence_set_filters/__init__.py diff --git a/modules/tool-context-intelligence-reapply/pyproject.toml b/modules/tool-context-intelligence-set-filters/pyproject.toml similarity index 100% rename from modules/tool-context-intelligence-reapply/pyproject.toml rename to modules/tool-context-intelligence-set-filters/pyproject.toml From 866ec4d842ed3ec93a922b3b4ed4d1d9f87c5edd Mon Sep 17 00:00:00 2001 From: colombod Date: Thu, 3 Sep 2026 11:03:57 +0000 Subject: [PATCH 38/39] fix(ingestion): commit the reapply -> set_ingestion_filters content edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior rename commit captured the file/directory renames but not the in-file string edits (a partial git add), leaving HEAD referencing the old tool-context-intelligence-reapply module path and reapply_ingestion capability id in behaviors, hook, tool, tests, agent and skill. That broke bundle load. This commits the actual content so HEAD is internally consistent. 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com> --- agents/server-data-ops.md | 2 +- behaviors/context-intelligence-logging.yaml | 6 +- .../__init__.py | 37 +++++---- .../config_resolver.py | 2 +- .../tests/test_set_filters_tool.py | 78 ++++++++++--------- .../tests/test_set_ingestion_filters.py | 42 +++++----- .../__init__.py | 26 +++---- .../pyproject.toml | 8 +- .../SKILL.md | 4 +- 9 files changed, 106 insertions(+), 99 deletions(-) diff --git a/agents/server-data-ops.md b/agents/server-data-ops.md index f18277fe..4445cb5a 100644 --- a/agents/server-data-ops.md +++ b/agents/server-data-ops.md @@ -132,7 +132,7 @@ narrative work to `graph-analyst` instead of querying the graph yourself. removes the whole session graph regardless of this setting. Applying it has two effects, and your message must state both: persisting the pattern to `settings.yaml` makes the exclude filter correct in a **new session** too, and running - `reapply_ingestion` in the **root session** makes it take effect in the + `set_ingestion_filters` in the **root session** makes it take effect in the already-running session (and any sub-session spawned afterward) with no restart. That tool lives in the root session, not in this delegated agent — if you can't reach it, ask the user or the root session to run it. diff --git a/behaviors/context-intelligence-logging.yaml b/behaviors/context-intelligence-logging.yaml index e0898c26..bbd22208 100644 --- a/behaviors/context-intelligence-logging.yaml +++ b/behaviors/context-intelligence-logging.yaml @@ -105,9 +105,9 @@ hooks: # api_key: "${TEAM_CI_KEY:}" # include: ["**/client-x/"] # the client-x project dir and everything under it -# Live-reapply: expose the ingestion hook's reapply capability +# Live-set-filters: expose the ingestion hook's set_ingestion_filters capability # as a root-session tool so the running root agent can make a destination # `exclude` edit take effect immediately, without a restart. tools: - - module: tool-context-intelligence-reapply - source: git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=modules/tool-context-intelligence-reapply + - module: tool-context-intelligence-set-filters + source: git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=modules/tool-context-intelligence-set-filters diff --git a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/__init__.py b/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/__init__.py index 8d34baf7..09daa363 100644 --- a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/__init__.py +++ b/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/__init__.py @@ -84,8 +84,8 @@ async def apply_active_dispatchers( This is the reusable core of fan-out routing: match_key -> select_active -> build one dispatcher per active destination -> set_dispatchers (drain-safe - swap). Called ONCE at on_session_ready, and AGAIN by the live-reapply path - (``context_intelligence.reapply_ingestion``) after the destinations config + swap). Called ONCE at on_session_ready, and AGAIN by the live-set-filters path + (``context_intelligence.set_ingestion_filters``) after the destinations config is updated mid-session. ``set_dispatchers`` bounded-closes the previously installed dispatchers, so calling this repeatedly is safe. @@ -151,13 +151,13 @@ def _read_destinations_from_settings(settings_path: str) -> dict[str, Any]: """Read the raw ``destinations`` block from a settings.yaml on disk. The hook itself never reads settings.yaml (the kernel merges/expands it and - hands mount() a config dict). The reapply path re-reads the file so an + hands mount() a config dict). The set-filters path re-reads the file so an exclude edit made to it during a session is reflected in the running session. Looks first under ``overrides.hook-context-intelligence.config.destinations`` (the settings.yaml shape), then falls back to a top-level ``destinations:`` key. Returns {} if neither is - present. Does NOT expand ${VAR}; callers writing on-disk config for reapply + present. Does NOT expand ${VAR}; callers writing on-disk config to set filters are expected to write already-resolved values (same contract the kernel applies before mount()). """ @@ -179,14 +179,11 @@ def _patch_inherited_hook_config(coordinator: Any, raw_destinations: dict[str, A """Bundle-only: write the new destinations into the in-memory session config that FUTURE spawned sub-sessions inherit. - A fresh sub-session's config is built by ``session_spawner.merge_configs( - parent_session.config, agent_overlay)`` — it copies the parent session's - config dict and does NOT re-read settings.yaml. That dict is - ``coordinator.session.config`` (the same object AmplifierSession stores at - construction). Updating this destination's hook entry there is what makes a - live filter change reach every sub-session spawned afterward — reached purely - through the coordinator the hook already holds, with no change to any module - outside this bundle. + A spawned sub-session copies its parent session's config dict as its + starting point; it does not re-read settings.yaml. That dict is reachable + from the coordinator this hook already holds. Updating this hook's entry in + it is what carries a live filter change to every sub-session spawned + afterward, without touching any module outside this bundle. Returns True if a hook-context-intelligence entry was patched. """ @@ -271,12 +268,12 @@ async def mount( } coordinator.register_capability("context_intelligence._hook_state", _hook_state) - async def reapply_ingestion( + async def set_ingestion_filters( raw_destinations: dict[str, Any] | None = None, settings_path: str | None = None, verify_disk: bool = True, ) -> dict[str, Any]: - """Re-apply destination routing to this session's live hook, without a restart. + """Apply destination routing to this session's live hook, without a restart. Source of the new destinations block (exactly one): - ``settings_path``: re-read the block from a settings.yaml on disk @@ -297,7 +294,7 @@ async def reapply_ingestion( disk_raw = _read_destinations_from_settings(settings_path) if settings_path else None new_raw = raw_destinations if raw_destinations is not None else disk_raw if new_raw is None: - raise ValueError("reapply_ingestion: provide raw_destinations or settings_path") + raise ValueError("set_ingestion_filters: provide raw_destinations or settings_path") resolver.update_destinations(new_raw) new_dests = resolver.validate_destinations() @@ -307,7 +304,7 @@ async def reapply_ingestion( ) # Bundle-only propagation to FUTURE sub-sessions: update the session - # config snapshot that session_spawner.merge_configs copies at spawn. + # config snapshot a spawned sub-session copies from its parent. inherited_patched = _patch_inherited_hook_config(coordinator, new_raw) report: dict[str, Any] = { @@ -325,7 +322,7 @@ async def reapply_ingestion( disk = _disk_exclude_map(_read_destinations_from_settings(settings_path)) if live != disk: raise RuntimeError( - "reapply_ingestion: live filter disagrees with on-disk settings " + "set_ingestion_filters: live filter disagrees with on-disk settings " f"(live_exclude={live!r} disk_exclude={disk!r}); refusing to leave " "the running session believing an exclude is applied when the file " "disagrees." @@ -350,7 +347,9 @@ def verify_ingestion_consistency(settings_path: str) -> dict[str, Any]: ) return {"live_exclude": live, "disk_exclude": disk, "consistent": True} - coordinator.register_capability("context_intelligence.reapply_ingestion", reapply_ingestion) + coordinator.register_capability( + "context_intelligence.set_ingestion_filters", set_ingestion_filters + ) coordinator.register_capability( "context_intelligence.verify_ingestion_consistency", verify_ingestion_consistency ) @@ -454,7 +453,7 @@ async def on_session_ready(coordinator: Any) -> None: ) # --- Destination selection + dispatcher install (C2: working_dir ONLY) --- - # Factored into apply_active_dispatchers so the live-reapply capability can + # Factored into apply_active_dispatchers so the live-set-filters capability can # re-run the exact same routing computation mid-session. await apply_active_dispatchers(coordinator, resolver, logging_handler, destinations) diff --git a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/config_resolver.py b/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/config_resolver.py index 8fae43a6..0ea5ada9 100644 --- a/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/config_resolver.py +++ b/modules/hook-context-intelligence/amplifier_module_hook_context_intelligence/config_resolver.py @@ -583,7 +583,7 @@ def update_destinations(self, raw_destinations: dict[str, Any]) -> None: """Replace the raw ``destinations`` config and invalidate the cache. The resolver caches ``destinations`` on first access and never re-reads - it. The live-reapply path calls this to install a fresh destinations + it. The live-set-filters path calls this to install a fresh destinations block mid-session (e.g. reflecting an on-disk settings.yaml exclude edit) so the next ``.destinations`` / ``validate_destinations()`` access re-derives from the new dict instead of returning the startup snapshot. diff --git a/modules/hook-context-intelligence/tests/test_set_filters_tool.py b/modules/hook-context-intelligence/tests/test_set_filters_tool.py index 1caa8ca1..73b3bc6e 100644 --- a/modules/hook-context-intelligence/tests/test_set_filters_tool.py +++ b/modules/hook-context-intelligence/tests/test_set_filters_tool.py @@ -1,4 +1,4 @@ -"""Tests for ReapplyIngestionTool (modules/tool-context-intelligence-reapply). +"""Tests for SetIngestionFiltersTool (modules/tool-context-intelligence-set-filters). That module has no test infrastructure of its own (no tests/ dir, no lock file) and its pyproject depends on the *published* amplifier-bundle-context- @@ -21,13 +21,13 @@ import pytest _TOOL_MODULE_DIR = ( - Path(__file__).parent.parent.parent.parent / "modules" / "tool-context-intelligence-reapply" + Path(__file__).parent.parent.parent.parent / "modules" / "tool-context-intelligence-set-filters" ) @pytest.fixture -def reapply_tool_module(monkeypatch: pytest.MonkeyPatch): - """Import amplifier_module_tool_context_intelligence_reapply from source. +def set_filters_tool_module(monkeypatch: pytest.MonkeyPatch): + """Import amplifier_module_tool_context_intelligence_set_filters from source. Skips (rather than fails) if the sibling module directory is not present at the expected workspace layout -- this test is a bonus cross-module @@ -38,7 +38,7 @@ def reapply_tool_module(monkeypatch: pytest.MonkeyPatch): pytest.skip(f"tool module directory not found at {_TOOL_MODULE_DIR}") monkeypatch.syspath_prepend(str(_TOOL_MODULE_DIR)) - module_name = "amplifier_module_tool_context_intelligence_reapply" + module_name = "amplifier_module_tool_context_intelligence_set_filters" sys.modules.pop(module_name, None) import importlib @@ -47,81 +47,89 @@ def reapply_tool_module(monkeypatch: pytest.MonkeyPatch): sys.modules.pop(module_name, None) -class TestReapplyIngestionToolCapabilityPresent: - async def test_execute_returns_success_with_report(self, reapply_tool_module) -> None: +class TestSetIngestionFiltersToolCapabilityPresent: + async def test_execute_returns_success_with_report(self, set_filters_tool_module) -> None: report = { - "match_key": "/some/dir/", + "match_key": "[REDACTED:SECRET]", "active": ["d1"], "inherited_snapshot_patched": True, "destinations": {"d1": {"include": ["**"], "exclude": []}}, "disk_consistent": None, } - reapply_capability = AsyncMock(return_value=report) + set_filters_capability = AsyncMock(return_value=report) coordinator = MagicMock() coordinator.get_capability = MagicMock( side_effect=lambda name: ( - reapply_capability if name == "context_intelligence.reapply_ingestion" else None + set_filters_capability + if name == "context_intelligence.set_ingestion_filters" + else None ) ) - tool = reapply_tool_module.ReapplyIngestionTool(coordinator) + tool = set_filters_tool_module.SetIngestionFiltersTool(coordinator) result = await tool.execute({}) assert result.success is True assert result.output == report - reapply_capability.assert_awaited_once() - assert reapply_capability.await_args is not None - assert reapply_capability.await_args.kwargs["verify_disk"] is True + set_filters_capability.assert_awaited_once() + assert set_filters_capability.await_args is not None + assert set_filters_capability.await_args.kwargs["verify_disk"] is True - async def test_execute_passes_custom_settings_path(self, reapply_tool_module) -> None: - reapply_capability = AsyncMock(return_value={"active": []}) + async def test_execute_passes_custom_settings_path(self, set_filters_tool_module) -> None: + set_filters_capability = AsyncMock(return_value={"active": []}) coordinator = MagicMock() coordinator.get_capability = MagicMock( side_effect=lambda name: ( - reapply_capability if name == "context_intelligence.reapply_ingestion" else None + set_filters_capability + if name == "context_intelligence.set_ingestion_filters" + else None ) ) - tool = reapply_tool_module.ReapplyIngestionTool(coordinator) + tool = set_filters_tool_module.SetIngestionFiltersTool(coordinator) await tool.execute({"settings_path": "/tmp/custom-settings.yaml"}) - assert reapply_capability.await_args is not None - assert reapply_capability.await_args.kwargs["settings_path"] == "/tmp/custom-settings.yaml" + assert set_filters_capability.await_args is not None + assert ( + set_filters_capability.await_args.kwargs["settings_path"] == "/tmp/custom-settings.yaml" + ) - async def test_execute_surfaces_capability_exception(self, reapply_tool_module) -> None: - reapply_capability = AsyncMock(side_effect=RuntimeError("disk disagrees")) + async def test_execute_surfaces_capability_exception(self, set_filters_tool_module) -> None: + set_filters_capability = AsyncMock(side_effect=RuntimeError("disk disagrees")) coordinator = MagicMock() coordinator.get_capability = MagicMock( side_effect=lambda name: ( - reapply_capability if name == "context_intelligence.reapply_ingestion" else None + set_filters_capability + if name == "context_intelligence.set_ingestion_filters" + else None ) ) - tool = reapply_tool_module.ReapplyIngestionTool(coordinator) + tool = set_filters_tool_module.SetIngestionFiltersTool(coordinator) result = await tool.execute({}) assert result.success is False assert "disk disagrees" in result.output["error"] -class TestReapplyIngestionToolCapabilityAbsent: +class TestSetIngestionFiltersToolCapabilityAbsent: async def test_execute_fails_loud_when_capability_unavailable( - self, reapply_tool_module + self, set_filters_tool_module ) -> None: coordinator = MagicMock() coordinator.get_capability = MagicMock(return_value=None) - tool = reapply_tool_module.ReapplyIngestionTool(coordinator) + tool = set_filters_tool_module.SetIngestionFiltersTool(coordinator) result = await tool.execute({}) assert result.success is False assert "unavailable" in result.output["error"] - assert "context_intelligence.reapply_ingestion" in result.output["error"] + assert "context_intelligence.set_ingestion_filters" in result.output["error"] -class TestReapplyIngestionToolVerifyOnly: - async def test_verify_only_uses_verify_capability(self, reapply_tool_module) -> None: +class TestSetIngestionFiltersToolVerifyOnly: + async def test_verify_only_uses_verify_capability(self, set_filters_tool_module) -> None: verify_result = {"live_exclude": {}, "disk_exclude": {}, "consistent": True} verify_capability = MagicMock(return_value=verify_result) coordinator = MagicMock() @@ -133,7 +141,7 @@ async def test_verify_only_uses_verify_capability(self, reapply_tool_module) -> ) ) - tool = reapply_tool_module.ReapplyIngestionTool(coordinator) + tool = set_filters_tool_module.SetIngestionFiltersTool(coordinator) result = await tool.execute({"verify_only": True}) assert result.success is True @@ -141,18 +149,18 @@ async def test_verify_only_uses_verify_capability(self, reapply_tool_module) -> verify_capability.assert_called_once() async def test_verify_only_fails_loud_when_capability_unavailable( - self, reapply_tool_module + self, set_filters_tool_module ) -> None: coordinator = MagicMock() coordinator.get_capability = MagicMock(return_value=None) - tool = reapply_tool_module.ReapplyIngestionTool(coordinator) + tool = set_filters_tool_module.SetIngestionFiltersTool(coordinator) result = await tool.execute({"verify_only": True}) assert result.success is False assert "unavailable" in result.output["error"] - async def test_verify_only_surfaces_capability_exception(self, reapply_tool_module) -> None: + async def test_verify_only_surfaces_capability_exception(self, set_filters_tool_module) -> None: verify_capability = MagicMock(side_effect=RuntimeError("live/disk mismatch")) coordinator = MagicMock() coordinator.get_capability = MagicMock( @@ -163,7 +171,7 @@ async def test_verify_only_surfaces_capability_exception(self, reapply_tool_modu ) ) - tool = reapply_tool_module.ReapplyIngestionTool(coordinator) + tool = set_filters_tool_module.SetIngestionFiltersTool(coordinator) result = await tool.execute({"verify_only": True}) assert result.success is False diff --git a/modules/hook-context-intelligence/tests/test_set_ingestion_filters.py b/modules/hook-context-intelligence/tests/test_set_ingestion_filters.py index 0d8b934b..1af8f040 100644 --- a/modules/hook-context-intelligence/tests/test_set_ingestion_filters.py +++ b/modules/hook-context-intelligence/tests/test_set_ingestion_filters.py @@ -1,10 +1,10 @@ -"""Tests for the live-reapply ingestion capability. +"""Tests for the live-set-filters ingestion capability. Covers: - HookConfigResolver.update_destinations() cache invalidation (config_resolver.py) -- context_intelligence.reapply_ingestion capability: live exclude take-effect, +- context_intelligence.set_ingestion_filters capability: live exclude take-effect, disk-consistency reporting -- context_intelligence.reapply_ingestion patching the in-memory session config +- context_intelligence.set_ingestion_filters patching the in-memory session config snapshot inherited by future sub-sessions (_patch_inherited_hook_config) - context_intelligence.verify_ingestion_consistency fail-loud in both directions - _patch_inherited_hook_config as a standalone unit @@ -104,10 +104,10 @@ def test_update_destinations_can_add_a_new_destination(self) -> None: # --------------------------------------------------------------------------- -# B. reapply excludes a destination live +# B. set_ingestion_filters excludes a destination live # --------------------------------------------------------------------------- -class TestReapplyExcludesDestinationLive: - async def test_reapply_updates_active_destinations(self, tmp_path: Path) -> None: +class TestSetIngestionFiltersExcludesDestinationLive: + async def test_set_filters_updates_active_destinations(self, tmp_path: Path) -> None: working_dir = str(tmp_path) config = { "destinations": { @@ -118,14 +118,14 @@ async def test_reapply_updates_active_destinations(self, tmp_path: Path) -> None coordinator = make_lifecycle_coordinator(working_dir=working_dir) cleanup = await mount_and_ready(coordinator, config) try: - reapply = coordinator.get_capability("context_intelligence.reapply_ingestion") - assert reapply is not None + set_filters = coordinator.get_capability("context_intelligence.set_ingestion_filters") + assert set_filters is not None new_raw = { "d1": {"url": "http://d1", "api_key": "k1", "include": ["**"], "exclude": ["**"]}, "d2": {"url": "http://d2", "api_key": "k2", "include": ["**"], "exclude": []}, } - report = await reapply(raw_destinations=new_raw) + report = await set_filters(raw_destinations=new_raw) assert "d1" not in report["active"] assert "d2" in report["active"] @@ -145,38 +145,38 @@ async def test_disk_consistent_is_none_without_settings_path(self, tmp_path: Pat coordinator = make_lifecycle_coordinator(working_dir=working_dir) cleanup = await mount_and_ready(coordinator, config) try: - reapply = coordinator.get_capability("context_intelligence.reapply_ingestion") + set_filters = coordinator.get_capability("context_intelligence.set_ingestion_filters") new_raw = { "d1": {"url": "http://d1", "api_key": "k1", "include": ["**"], "exclude": ["**"]}, } # Default verify_disk=True but no settings_path given -> the disk # cross-check never runs; disk_consistent stays None either way. - report = await reapply(raw_destinations=new_raw) + report = await set_filters(raw_destinations=new_raw) assert report["disk_consistent"] is None - report_no_verify = await reapply(raw_destinations=new_raw, verify_disk=False) + report_no_verify = await set_filters(raw_destinations=new_raw, verify_disk=False) assert report_no_verify["disk_consistent"] is None finally: await cleanup() - async def test_reapply_requires_a_destinations_source(self, tmp_path: Path) -> None: + async def test_set_filters_requires_a_destinations_source(self, tmp_path: Path) -> None: working_dir = str(tmp_path) config = {"destinations": {}} coordinator = make_lifecycle_coordinator(working_dir=working_dir) cleanup = await mount_and_ready(coordinator, config) try: - reapply = coordinator.get_capability("context_intelligence.reapply_ingestion") + set_filters = coordinator.get_capability("context_intelligence.set_ingestion_filters") with pytest.raises(ValueError): - await reapply() + await set_filters() finally: await cleanup() # --------------------------------------------------------------------------- -# C. reapply patches the inherited session config snapshot +# C. set_ingestion_filters patches the inherited session config snapshot # --------------------------------------------------------------------------- -class TestReapplyPatchesInheritedSnapshot: +class TestSetIngestionFiltersPatchesInheritedSnapshot: async def test_inherited_hook_config_patched(self, tmp_path: Path) -> None: working_dir = str(tmp_path) initial_raw = { @@ -192,11 +192,11 @@ async def test_inherited_hook_config_patched(self, tmp_path: Path) -> None: cleanup = await mount_and_ready(coordinator, config) try: - reapply = coordinator.get_capability("context_intelligence.reapply_ingestion") + set_filters = coordinator.get_capability("context_intelligence.set_ingestion_filters") new_raw = { "d1": {"url": "http://d1", "api_key": "k1", "include": ["**"], "exclude": ["**"]}, } - report = await reapply(raw_destinations=new_raw) + report = await set_filters(raw_destinations=new_raw) assert report["inherited_snapshot_patched"] is True ci_entry = next(h for h in hooks_list if h["module"] == "hook-context-intelligence") @@ -222,8 +222,8 @@ async def test_inherited_snapshot_not_patched_without_a_real_session_config( # must report False rather than silently pretending it patched anything. cleanup = await mount_and_ready(coordinator, config) try: - reapply = coordinator.get_capability("context_intelligence.reapply_ingestion") - report = await reapply(raw_destinations=config["destinations"]) + set_filters = coordinator.get_capability("context_intelligence.set_ingestion_filters") + report = await set_filters(raw_destinations=config["destinations"]) assert report["inherited_snapshot_patched"] is False finally: await cleanup() diff --git a/modules/tool-context-intelligence-set-filters/amplifier_module_tool_context_intelligence_set_filters/__init__.py b/modules/tool-context-intelligence-set-filters/amplifier_module_tool_context_intelligence_set_filters/__init__.py index b2223362..66645830 100644 --- a/modules/tool-context-intelligence-set-filters/amplifier_module_tool_context_intelligence_set_filters/__init__.py +++ b/modules/tool-context-intelligence-set-filters/amplifier_module_tool_context_intelligence_set_filters/__init__.py @@ -1,6 +1,6 @@ -"""Same-session ingestion-reapply tool. +"""Same-session ingestion-filter tool. -Exposes the root session's ``context_intelligence.reapply_ingestion`` capability +Exposes the root session's ``context_intelligence.set_ingestion_filters`` capability (registered by the ingestion hook) as an agent-callable tool, so the ROOT agent can make a destination `exclude` edit made during a session take effect immediately -- no restart -- against the already-running session's ingestion. @@ -22,20 +22,20 @@ __amplifier_module_type__ = "tool" __all__ = ["mount"] -_CAP = "context_intelligence.reapply_ingestion" +_CAP = "context_intelligence.set_ingestion_filters" _VERIFY_CAP = "context_intelligence.verify_ingestion_consistency" _DEFAULT_SETTINGS = str(Path("~/.amplifier/settings.yaml").expanduser()) -class ReapplyIngestionTool: - """Re-apply the ingestion hook's per-destination include/exclude live.""" +class SetIngestionFiltersTool: + """Apply the ingestion hook's per-destination include/exclude live.""" def __init__(self, coordinator: Any) -> None: self._coordinator = coordinator @property def name(self) -> str: - return "reapply_ingestion" + return "set_ingestion_filters" @property def description(self) -> str: @@ -64,7 +64,7 @@ def input_schema(self) -> dict[str, Any]: "verify_only": { "type": "boolean", "description": ( - "If true, do NOT reapply -- only fail-loud compare the session's " + "If true, do NOT apply -- only fail-loud compare the session's " "LIVE exclude filter against settings.yaml on disk (both directions)." ), }, @@ -91,24 +91,24 @@ async def execute(self, input: dict[str, Any]) -> ToolResult: # noqa: A002 return ToolResult(success=False, output={"error": str(exc)}) return ToolResult(success=True, output=result) - reapply = self._coordinator.get_capability(_CAP) - if reapply is None: + set_filters = self._coordinator.get_capability(_CAP) + if set_filters is None: return ToolResult( success=False, output={ "error": f"capability {_CAP} unavailable in this session (no " "ingestion hook mounted here) -- this session's fan-out was NOT " - "changed. The reapply tool only affects the session that mounts " - "the ingestion hook (the root session)." + "changed. The set_ingestion_filters tool only affects the session " + "that mounts the ingestion hook (the root session)." }, ) try: - report = await reapply(settings_path=settings_path, verify_disk=True) + report = await set_filters(settings_path=settings_path, verify_disk=True) except Exception as exc: # noqa: BLE001 - surface the fail-loud reason to the agent return ToolResult(success=False, output={"error": str(exc)}) return ToolResult(success=True, output=report) async def mount(coordinator: Any, config: Any) -> None: - tool = ReapplyIngestionTool(coordinator) + tool = SetIngestionFiltersTool(coordinator) await coordinator.mount("tools", tool, name=tool.name) diff --git a/modules/tool-context-intelligence-set-filters/pyproject.toml b/modules/tool-context-intelligence-set-filters/pyproject.toml index 975f211d..4e0e30d2 100644 --- a/modules/tool-context-intelligence-set-filters/pyproject.toml +++ b/modules/tool-context-intelligence-set-filters/pyproject.toml @@ -1,7 +1,7 @@ [project] -name = "amplifier-module-tool-context-intelligence-reapply" +name = "amplifier-module-tool-context-intelligence-set-filters" version = "0.1.0" -description = "Same-session ingestion reapply tool - make a destination include/exclude change take effect live, without a restart" +description = "Same-session ingestion filter tool - make a destination include/exclude change take effect live, without a restart" requires-python = ">=3.11" license = "MIT" @@ -10,7 +10,7 @@ dependencies = [ ] [project.entry-points."amplifier.modules"] -tool-context-intelligence-reapply = "amplifier_module_tool_context_intelligence_reapply:mount" +tool-context-intelligence-set-filters = "amplifier_module_tool_context_intelligence_set_filters:mount" [build-system] requires = ["hatchling"] @@ -20,7 +20,7 @@ build-backend = "hatchling.build" package = true [tool.hatch.build.targets.wheel] -packages = ["amplifier_module_tool_context_intelligence_reapply"] +packages = ["amplifier_module_tool_context_intelligence_set_filters"] [tool.hatch.metadata] allow-direct-references = true diff --git a/skills/context-intelligence-server-data-ops/SKILL.md b/skills/context-intelligence-server-data-ops/SKILL.md index e7157255..ab198cce 100644 --- a/skills/context-intelligence-server-data-ops/SKILL.md +++ b/skills/context-intelligence-server-data-ops/SKILL.md @@ -157,7 +157,7 @@ Session by Description, Then Delete** when the request names or searches for som the exclude filter correct in a **new session** too, so a freshly started session in this folder honors it from the start. To also stop the **already-running** session (and any sub-session it spawns afterward) without a restart, the - `reapply_ingestion` tool is run in the **root session** — it re-reads + `set_ingestion_filters` tool is run in the **root session** — it re-reads `settings.yaml`, re-routes the live dispatchers, and reports `disk_consistent` so the live filter and the file are confirmed to agree. That tool lives in the root session (where the ingestion hook is mounted), not in this delegated agent — so if you cannot @@ -224,7 +224,7 @@ mine" rather than a topic/date/server description. applied before moving on: while the folder is still in scope, continued ingestion would keep re-creating the very data you are about to delete. Apply it with BOTH effects from step 3 — persisted to `settings.yaml` (so the exclude - filter is correct in a new session too) AND made live via `reapply_ingestion` in + filter is correct in a new session too) AND made live via `set_ingestion_filters` in the root session (so the current running session, and any sub-session it spawns afterward, stop immediately without a restart) — before you enumerate or delete. 2. **Run the S2 search, by criteria (this folder + mine).** Delegate to From 72cf1d8d84e32f7dda133e11c0de4fd98335f748 Mon Sep 17 00:00:00 2001 From: colombod Date: Thu, 3 Sep 2026 11:12:46 +0000 Subject: [PATCH 39/39] refactor(behaviors): analysis imports navigation instead of duplicating agents The analysis and navigation behaviors carried the same three agents (graph-analyst, session-navigator, server-data-ops) as independent copies. analysis now imports navigation and only adds the graph skills, so the agent set lives in one place. Behavior for a composed app is unchanged (same agents + the union of both skill lists). --- behaviors/context-intelligence-analysis.yaml | 22 +++++++++----------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/behaviors/context-intelligence-analysis.yaml b/behaviors/context-intelligence-analysis.yaml index 1f13e9d9..18e685c1 100644 --- a/behaviors/context-intelligence-analysis.yaml +++ b/behaviors/context-intelligence-analysis.yaml @@ -2,25 +2,23 @@ bundle: name: context-intelligence-analysis-behavior version: 0.1.0 description: > - Carries the three context-intelligence agents (graph-analyst, - session-navigator, server-data-ops) plus the graph skills (blob-reading, - graph-query, session-reconstruction, workflow-pattern-analysis). Does not - import the navigation behavior; it lists the three agents itself. Use for - graph read/query/exploration and session data operations without the design - mode. + Imports the navigation behavior for the three context-intelligence agents + (graph-analyst, session-navigator, server-data-ops) instead of listing them + again, and adds the graph skills (blob-reading, graph-query, + session-reconstruction, workflow-pattern-analysis). Use for graph + read/query/exploration and session data operations without the design mode. -agents: - include: - - context-intelligence:graph-analyst - - context-intelligence:session-navigator - - context-intelligence:server-data-ops +includes: + # The three agents (and tool-delegate + the session-navigation skill) come from + # here; this behavior only adds the graph skills below, which list-merge with + # the navigation layer's skill list. + - bundle: context-intelligence:behaviors/context-intelligence-navigation tools: - module: tool-skills source: git+https://github.com/microsoft/amplifier-bundle-skills@main#subdirectory=modules/tool-skills config: skills: - # Concatenates with the navigation layer's skill list (list-merge with dedup). - "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=skills/blob-reading" - "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=skills/context-intelligence-graph-query" - "git+https://github.com/microsoft/amplifier-bundle-context-intelligence@main#subdirectory=skills/context-intelligence-session-reconstruction"