contexory-sdk 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
contexory/__init__.py ADDED
@@ -0,0 +1,67 @@
1
+ from .apply_tools import AppliedTools, apply_tools
2
+ from .client import (
3
+ DEFAULT_BASE_URL,
4
+ DEFAULT_LABEL,
5
+ Contexory,
6
+ FetchResponse,
7
+ ResolvedContext,
8
+ default_fetch,
9
+ )
10
+ from .errors import ContexoryResolveError, ContexorySignatureError
11
+ from .instrument import (
12
+ AGENT_VERSION_ATTRIBUTE,
13
+ RELEASE_BAGGAGE_KEY,
14
+ VENDOR_ATTRIBUTES,
15
+ VENDORS,
16
+ Instrumentation,
17
+ instrument,
18
+ open_inference_metadata,
19
+ open_llmetry_association_properties,
20
+ openai_agents_trace_metadata,
21
+ )
22
+ from .mcp_upstream import (
23
+ canonical_json,
24
+ clamp_ttl,
25
+ description_hash,
26
+ hash_mcp_tools,
27
+ mcp_tools_hash,
28
+ report_upstream_hashes,
29
+ schema_hash,
30
+ watch_mcp_tools,
31
+ )
32
+ from .release import normalize_release
33
+ from .types import ResolvedTool, ResolvePayload, Upstream
34
+
35
+ __all__ = [
36
+ "AGENT_VERSION_ATTRIBUTE",
37
+ "DEFAULT_BASE_URL",
38
+ "DEFAULT_LABEL",
39
+ "RELEASE_BAGGAGE_KEY",
40
+ "VENDORS",
41
+ "VENDOR_ATTRIBUTES",
42
+ "AppliedTools",
43
+ "Contexory",
44
+ "ContexoryResolveError",
45
+ "ContexorySignatureError",
46
+ "FetchResponse",
47
+ "Instrumentation",
48
+ "ResolvePayload",
49
+ "ResolvedContext",
50
+ "ResolvedTool",
51
+ "Upstream",
52
+ "canonical_json",
53
+ "clamp_ttl",
54
+ "description_hash",
55
+ "hash_mcp_tools",
56
+ "mcp_tools_hash",
57
+ "report_upstream_hashes",
58
+ "schema_hash",
59
+ "watch_mcp_tools",
60
+ "apply_tools",
61
+ "default_fetch",
62
+ "instrument",
63
+ "normalize_release",
64
+ "open_inference_metadata",
65
+ "open_llmetry_association_properties",
66
+ "openai_agents_trace_metadata",
67
+ ]
File without changes
@@ -0,0 +1,58 @@
1
+ """Claude Agent SDK: an in-process MCP proxy that re-exposes external tools with released descriptions.
2
+
3
+ Claude Code's built-in tools and external MCP servers describe themselves; their
4
+ descriptions cannot be rewritten in flight. The proxy registers each upstream tool again
5
+ under an in-process MCP server with the released description and forwards the call, so
6
+ the model reads Contexory's text. Name the proxy server so the model sees
7
+ `mcp__<server>__<tool>` and disallow the upstream originals with `disallowed_tools`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Awaitable, Callable, Iterable, Mapping
13
+ from dataclasses import dataclass
14
+ from typing import Any
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class UpstreamTool:
19
+ name: str
20
+ description: str
21
+ input_schema: Any
22
+ #: Forwards one call: `await call(args)` returns the MCP result dict (`{"content": [...]}`).
23
+ call: Callable[[Mapping[str, Any]], Awaitable[dict[str, Any]]]
24
+
25
+
26
+ def proxied_tools(context: Any, upstream: Iterable[UpstreamTool], *, include_unmatched: bool = True) -> list[Any]:
27
+ """`UpstreamTool`s with the released description applied.
28
+
29
+ A tool with no released description is kept as-is unless `include_unmatched=False`.
30
+ """
31
+ tools = list(upstream)
32
+ applied = context.apply_tools({t.name: {"name": t.name, "description": t.description} for t in tools})
33
+ out: list[UpstreamTool] = []
34
+ released = {t.name for t in context.tools}
35
+ for tool in tools:
36
+ if tool.name not in released and not include_unmatched:
37
+ continue
38
+ description = applied[tool.name]["description"]
39
+ out.append(UpstreamTool(tool.name, description, tool.input_schema, tool.call))
40
+ return out
41
+
42
+
43
+ def create_proxy_server(
44
+ context: Any, upstream: Iterable[UpstreamTool], *, name: str = "contexory", include_unmatched: bool = True
45
+ ) -> Any:
46
+ """An SDK MCP server config for `ClaudeAgentOptions(mcp_servers={name: server})`."""
47
+ try:
48
+ from claude_agent_sdk import create_sdk_mcp_server, tool
49
+ except ImportError as error:
50
+ raise ImportError(
51
+ "create_proxy_server() needs the Claude Agent SDK. pip install 'contexory-sdk[claude-agent-sdk]'"
52
+ ) from error
53
+
54
+ wrapped = [
55
+ tool(t.name, t.description, t.input_schema)(t.call)
56
+ for t in proxied_tools(context, upstream, include_unmatched=include_unmatched)
57
+ ]
58
+ return create_sdk_mcp_server(name=name, tools=wrapped)
@@ -0,0 +1,36 @@
1
+ """LangChain / LangGraph: apply released tool descriptions and the system prompt per model call."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ try:
8
+ from langchain.agents.middleware import AgentMiddleware as _Base
9
+ except ImportError: # pragma: no cover - the adapter still works duck-typed against any object with `override`
10
+ _Base = object # type: ignore[assignment,misc]
11
+
12
+
13
+ class ContexoryMiddleware(_Base): # type: ignore[misc,valid-type]
14
+ """Agent middleware: `create_agent(model, tools, middleware=[ContexoryMiddleware(ctx)])`.
15
+
16
+ Rewrites each model request's tools to the released descriptions. `apply_system=True`
17
+ also replaces the system message with the release's system prompt.
18
+ """
19
+
20
+ def __init__(self, context: Any, *, apply_system: bool = False):
21
+ self.context = context
22
+ self.apply_system = apply_system
23
+
24
+ def _rewrite(self, request: Any) -> Any:
25
+ overrides: dict[str, Any] = {}
26
+ if request.tools:
27
+ overrides["tools"] = self.context.apply_tools(list(request.tools))
28
+ if self.apply_system:
29
+ overrides["system_prompt"] = self.context.system
30
+ return request.override(**overrides) if overrides else request
31
+
32
+ def wrap_model_call(self, request: Any, handler: Any) -> Any:
33
+ return handler(self._rewrite(request))
34
+
35
+ async def awrap_model_call(self, request: Any, handler: Any) -> Any:
36
+ return await handler(self._rewrite(request))
@@ -0,0 +1,27 @@
1
+ """Pydantic AI: apply released tool descriptions through `prepare_tools`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ from typing import Any
7
+
8
+ from ..apply_tools import describe_tool
9
+
10
+
11
+ def prepare_tools(context: Any) -> Any:
12
+ """`Agent(model, prepare_tools=prepare_tools(ctx))`.
13
+
14
+ Descriptions only: Pydantic AI routes a call back by the tool name it registered,
15
+ so renames are not offered here.
16
+ """
17
+ released = {tool.name: tool for tool in context.tools}
18
+
19
+ async def prepare(_run_context: Any, tool_defs: list[Any]) -> list[Any]:
20
+ return [
21
+ dataclasses.replace(definition, description=describe_tool(released[definition.name]))
22
+ if definition.name in released
23
+ else definition
24
+ for definition in tool_defs
25
+ ]
26
+
27
+ return prepare
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import dataclasses
5
+ from collections.abc import Iterable, Mapping
6
+ from dataclasses import dataclass, field
7
+ from typing import Any, TypeVar
8
+
9
+ from .mcp_upstream import description_hash
10
+ from .types import ResolvedTool
11
+
12
+ T = TypeVar("T")
13
+
14
+
15
+ @dataclass
16
+ class AppliedTools:
17
+ tools: Any
18
+ #: Applied name to the host's original name; present only for renamed tools.
19
+ reverse: dict[str, str] = field(default_factory=dict)
20
+ #: Host tools no resolved tool matched; they pass through unchanged.
21
+ unmatched: list[str] = field(default_factory=list)
22
+ #: Resolved tools nothing in the host matched.
23
+ unused: list[str] = field(default_factory=list)
24
+ #: Host tools whose override was not applied because the MCP server changed the description
25
+ #: the override was written against and Contexory has not accepted the change. The server's own text stands.
26
+ held: list[str] = field(default_factory=list)
27
+
28
+
29
+ def describe_tool(tool: ResolvedTool) -> str:
30
+ return f"{tool.description}\n\n{tool.parameter_notes}" if tool.parameter_notes else tool.description
31
+
32
+
33
+ def _get(tool: Any, key: str) -> Any:
34
+ return tool.get(key) if isinstance(tool, Mapping) else getattr(tool, key, None)
35
+
36
+
37
+ def _has(tool: Any, key: str) -> bool:
38
+ return key in tool if isinstance(tool, Mapping) else hasattr(tool, key)
39
+
40
+
41
+ def with_fields(tool: Any, fields: Mapping[str, Any]) -> Any:
42
+ """A shallow copy with `fields` replaced. Dicts stay dicts and objects keep their class."""
43
+ if isinstance(tool, Mapping):
44
+ return {**tool, **fields}
45
+ if dataclasses.is_dataclass(tool) and not isinstance(tool, type):
46
+ return dataclasses.replace(tool, **fields)
47
+ if hasattr(tool, "model_copy"):
48
+ return tool.model_copy(update=dict(fields))
49
+ clone = copy.copy(tool)
50
+ for key, value in fields.items():
51
+ object.__setattr__(clone, key, value)
52
+ return clone
53
+
54
+
55
+ def _takes_description(tool: Any) -> bool:
56
+ # A function tool declares `description`; hosted tools have a name and no
57
+ # description, and growing one would change what the host sends.
58
+ return isinstance(_get(tool, "description"), str) or _get(tool, "type") == "function"
59
+
60
+
61
+ def _upstream_name(host_name: str, server: str | None) -> str:
62
+ """The name the MCP server gives a tool a host lists as `mcp__<server>__<tool>`."""
63
+ prefix = f"mcp__{server}__" if server is not None else ""
64
+ return (
65
+ host_name[len(prefix) :]
66
+ if prefix and host_name.startswith(prefix) and len(host_name) > len(prefix)
67
+ else host_name
68
+ )
69
+
70
+
71
+ def _is_held(match: ResolvedTool, host_name: str, tool: Any, upstream_hashes: Mapping[str, Any] | None) -> bool:
72
+ """An override pinned to an upstream description is held once the upstream hash is no longer the accepted one."""
73
+ accepted = match.upstream.hash if match.upstream else None
74
+ if accepted is None:
75
+ return False
76
+ name = _upstream_name(host_name, match.upstream.server)
77
+ observed = None
78
+ for key in (name, host_name):
79
+ entry = (upstream_hashes or {}).get(key)
80
+ observed = entry.get("d") if isinstance(entry, Mapping) else None
81
+ if observed is not None:
82
+ break
83
+ if observed is None:
84
+ description = _get(tool, "description")
85
+ observed = description_hash(name, description) if isinstance(description, str) else None
86
+ return observed is not None and observed != accepted
87
+
88
+
89
+ def apply_tools(
90
+ resolved: Iterable[ResolvedTool],
91
+ tools: Any,
92
+ renames: Mapping[str, str] | None = None,
93
+ upstream_hashes: Mapping[str, Any] | None = None,
94
+ ) -> AppliedTools:
95
+ """Replace tool descriptions with the released ones.
96
+
97
+ `tools` is a dict keyed by tool name or a list of tool objects (dicts or objects
98
+ with `name`/`description`). Without `renames` a tool is matched by name and only
99
+ its description changes. A renamed tool (`{your_name: contexory_name}`) takes the
100
+ Contexory name and description; `reverse` maps the new name back.
101
+
102
+ `upstream_hashes` is what each MCP tool's description hashes to now, by host tool name
103
+ (`watch_mcp_tools(...).latest()`); without it the hash is taken from the host tool's own
104
+ `description`. An override whose upstream hash differs from the accepted one is `held`.
105
+ """
106
+ resolved = list(resolved)
107
+ by_name = {t.name: t for t in resolved}
108
+ renames = renames or {}
109
+ reverse: dict[str, str] = {}
110
+ unmatched: list[str] = []
111
+ held: list[str] = []
112
+ used: set[str] = set()
113
+ taken: set[str] = set()
114
+
115
+ def apply(host_name: str, tool: Any) -> tuple[str, Any]:
116
+ target = renames[host_name] if host_name in renames else host_name
117
+ match = by_name.get(target)
118
+ out_name, out = host_name, tool
119
+ if match is not None and _takes_description(tool) and _is_held(match, host_name, tool, upstream_hashes):
120
+ used.add(match.name)
121
+ held.append(host_name)
122
+ elif match is not None and _takes_description(tool):
123
+ used.add(match.name)
124
+ out = with_fields(tool, {"description": describe_tool(match)})
125
+ if target != host_name:
126
+ out_name = target
127
+ reverse[target] = host_name
128
+ else:
129
+ unmatched.append(host_name)
130
+ # An unnamed list tool is an unmatched pass-through, so it can't collide.
131
+ if out_name != "":
132
+ if out_name in taken:
133
+ raise ValueError(f'apply_tools: two tools would be named "{out_name}"')
134
+ taken.add(out_name)
135
+ if out_name != host_name and _has(out, "name"):
136
+ out = with_fields(out, {"name": out_name})
137
+ return out_name, out
138
+
139
+ if isinstance(tools, Mapping):
140
+ result: Any = dict(apply(name, tool) for name, tool in tools.items())
141
+ else:
142
+ result = [apply(_get(tool, "name") or "", tool)[1] for tool in tools]
143
+ return AppliedTools(
144
+ tools=result,
145
+ reverse=reverse,
146
+ unmatched=unmatched,
147
+ unused=[t.name for t in resolved if t.name not in used],
148
+ held=held,
149
+ )
contexory/cache.py ADDED
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .payload import parse_payload
10
+ from .signing import sha256_hex, verify_body
11
+ from .types import ResolvePayload
12
+
13
+
14
+ @dataclass
15
+ class CachedResolution:
16
+ payload: ResolvePayload
17
+ body: str
18
+ etag: str
19
+ fetched_at: float
20
+
21
+
22
+ class ResolveCache:
23
+ """File cache under one directory, byte-compatible with the TypeScript SDK's:
24
+
25
+ labels/<h>.json the label's ETag and which release it last pointed at
26
+ releases/<hash>.json the signed body, named by its content hash
27
+ keys.json signing keys seen so far
28
+
29
+ A release is immutable, so its file is write-once. Every read re-verifies the
30
+ signature against the cached keys: a file edited on disk, or a key that is no
31
+ longer known, reads as a miss instead of as trusted context.
32
+ """
33
+
34
+ def __init__(self, directory: str | os.PathLike[str]):
35
+ self.dir = Path(directory)
36
+
37
+ def _read_json(self, *parts: str) -> Any:
38
+ try:
39
+ return json.loads(self.dir.joinpath(*parts).read_text("utf8"))
40
+ except (OSError, ValueError):
41
+ return None
42
+
43
+ def _write_json(self, value: Any, *parts: str) -> None:
44
+ """Atomic, and best effort: a cache that cannot be written must not fail a resolve."""
45
+ path = self.dir.joinpath(*parts)
46
+ tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
47
+ try:
48
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
49
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
50
+ with os.fdopen(fd, "w", encoding="utf8") as handle:
51
+ handle.write(json.dumps(value, separators=(",", ":")))
52
+ os.replace(tmp, path)
53
+ except OSError:
54
+ pass
55
+
56
+ def read_keys(self) -> list[dict[str, str]]:
57
+ data = self._read_json("keys.json")
58
+ if not isinstance(data, list):
59
+ return []
60
+ return [
61
+ k
62
+ for k in data
63
+ if isinstance(k, dict) and isinstance(k.get("keyId"), str) and isinstance(k.get("publicKey"), str)
64
+ ]
65
+
66
+ def write_keys(self, keys: list[dict[str, str]]) -> None:
67
+ self._write_json(keys, "keys.json")
68
+
69
+ def read(self, label_key: str) -> CachedResolution | None:
70
+ label = self._read_json("labels", f"{label_key}.json")
71
+ if (
72
+ not isinstance(label, dict)
73
+ or not isinstance(label.get("etag"), str)
74
+ or not isinstance(label.get("hash"), str)
75
+ or isinstance(label.get("fetchedAt"), bool)
76
+ or not isinstance(label.get("fetchedAt"), (int, float))
77
+ ):
78
+ return None
79
+ release = self._read_json("releases", f"{label['hash']}.json")
80
+ if (
81
+ not isinstance(release, dict)
82
+ or not isinstance(release.get("body"), str)
83
+ or not isinstance(release.get("signature"), str)
84
+ or not isinstance(release.get("keyId"), str)
85
+ or sha256_hex(release["body"]) != label["hash"]
86
+ ):
87
+ return None
88
+ key = next((k for k in self.read_keys() if k["keyId"] == release["keyId"]), None)
89
+ if key is None or not verify_body(key["publicKey"], release["body"], release["signature"]):
90
+ return None
91
+ payload = parse_payload(release["body"])
92
+ if payload is None:
93
+ return None
94
+ return CachedResolution(payload, release["body"], label["etag"], label["fetchedAt"])
95
+
96
+ def write(self, label_key: str, *, body: str, signature: str, key_id: str, etag: str, now: float) -> None:
97
+ """Only ever called with a body whose signature has just verified."""
98
+ digest = sha256_hex(body)
99
+ self._write_json({"body": body, "signature": signature, "keyId": key_id}, "releases", f"{digest}.json")
100
+ self.touch(label_key, etag=etag, digest=digest, fetched_at=now)
101
+
102
+ def touch(self, label_key: str, *, etag: str, digest: str, fetched_at: float) -> None:
103
+ self._write_json({"etag": etag, "hash": digest, "fetchedAt": fetched_at}, "labels", f"{label_key}.json")
contexory/client.py ADDED
@@ -0,0 +1,229 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import time
7
+ import urllib.error
8
+ import urllib.parse
9
+ import urllib.request
10
+ from collections.abc import Callable, Mapping
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from .apply_tools import apply_tools as _apply_tools
16
+ from .cache import CachedResolution, ResolveCache
17
+ from .errors import ContexoryResolveError, ContexorySignatureError
18
+ from .payload import parse_payload
19
+ from .signing import parse_keys, sha256_hex, verify_body
20
+ from .types import ResolvedTool, ResolvePayload, ResolveSource
21
+
22
+ DEFAULT_BASE_URL = "https://sync.contexory.com"
23
+ DEFAULT_LABEL = "production"
24
+ _REFUSAL_STATUSES = (401, 403, 404)
25
+
26
+
27
+ @dataclass
28
+ class FetchResponse:
29
+ status: int
30
+ #: Header names lower-cased.
31
+ headers: Mapping[str, str]
32
+ body: bytes
33
+
34
+ @property
35
+ def ok(self) -> bool:
36
+ return 200 <= self.status < 300
37
+
38
+ def text(self) -> str:
39
+ return self.body.decode("utf8")
40
+
41
+
42
+ #: `fetch(url, headers, timeout_seconds)`; raise on a network failure, return any HTTP status.
43
+ Fetch = Callable[[str, Mapping[str, str], float], FetchResponse]
44
+
45
+
46
+ def default_fetch(url: str, headers: Mapping[str, str], timeout: float) -> FetchResponse:
47
+ request = urllib.request.Request(url, headers=dict(headers))
48
+ try:
49
+ with urllib.request.urlopen(request, timeout=timeout) as res: # noqa: S310 - https/http base URL
50
+ return FetchResponse(res.status, {k.lower(): v for k, v in res.headers.items()}, res.read())
51
+ except urllib.error.HTTPError as err:
52
+ return FetchResponse(err.code, {k.lower(): v for k, v in err.headers.items()}, err.read())
53
+
54
+
55
+ @dataclass
56
+ class ResolvedContext:
57
+ agent: Any
58
+ label: str
59
+ release: Any
60
+ #: The agent's system prompt for this release.
61
+ system: str
62
+ skills: list[dict[str, Any]]
63
+ tools: list[ResolvedTool]
64
+ source: ResolveSource
65
+ #: True when Contexory failed and this is the last good release.
66
+ stale: bool = False
67
+ #: Why the live fetch failed, when `stale`.
68
+ error: BaseException | None = None
69
+ #: Host tools the last `apply_tools` left alone because their MCP server's description changed.
70
+ held: list[str] = field(default_factory=list)
71
+ _reverse: dict[str, str] = field(default_factory=dict, repr=False)
72
+
73
+ def apply_tools(
74
+ self,
75
+ tools: Any,
76
+ renames: Mapping[str, str] | None = None,
77
+ upstream_hashes: Mapping[str, Any] | None = None,
78
+ ) -> Any:
79
+ """Apply the released tool descriptions to a dict or list of your tool objects."""
80
+ applied = _apply_tools(self.tools, tools, renames, upstream_hashes)
81
+ self._reverse.update(applied.reverse)
82
+ self.held = applied.held
83
+ return applied.tools
84
+
85
+ def original_tool_name(self, name: str) -> str:
86
+ """The name you registered a tool under, given the name the model called."""
87
+ return self._reverse.get(name, name)
88
+
89
+
90
+ def _is_refusal(error: BaseException) -> bool:
91
+ return isinstance(error, ContexoryResolveError) and error.status in _REFUSAL_STATUSES
92
+
93
+
94
+ class Contexory:
95
+ """Resolve the release a label points at. Never worse than the last verified release.
96
+
97
+ An unreachable or erroring Contexory serves the last verified release, marked
98
+ `stale`. A bad signature, or a refusal (revoked key, agent or label gone),
99
+ raises instead: the cache never overrides an answer.
100
+ """
101
+
102
+ def __init__(
103
+ self,
104
+ api_key: str | None = None,
105
+ *,
106
+ base_url: str | None = None,
107
+ cache_dir: str | os.PathLike[str] | None = None,
108
+ ttl_ms: float = 60_000,
109
+ timeout_ms: float = 3_000,
110
+ first_fetch_timeout_ms: float = 10_000,
111
+ fetch: Fetch | None = None,
112
+ now: Callable[[], float] | None = None,
113
+ env: Mapping[str, str] | None = None,
114
+ ):
115
+ env = os.environ if env is None else env
116
+ self._api_key = api_key if api_key is not None else env.get("CONTEXORY_API_TOKEN")
117
+ self._fingerprint = sha256_hex(self._api_key or "")[:16]
118
+ self._base_url = (base_url or env.get("CONTEXORY_API_BASE") or DEFAULT_BASE_URL).rstrip("/")
119
+ self._ttl_ms = ttl_ms
120
+ self._timeout = timeout_ms / 1000
121
+ self._first_timeout = first_fetch_timeout_ms / 1000
122
+ self._fetch = fetch or default_fetch
123
+ self._now = now or (lambda: time.time() * 1000)
124
+ directory = cache_dir or env.get("CONTEXORY_CACHE_DIR") or Path.home() / ".cache" / "contexory" / "sdk"
125
+ self._cache = ResolveCache(directory)
126
+
127
+ def resolve(self, agent: str, label: str = DEFAULT_LABEL) -> ResolvedContext:
128
+ if not self._api_key:
129
+ raise ContexoryResolveError("No Contexory runtime key: pass api_key or set CONTEXORY_API_TOKEN")
130
+ label_key = sha256_hex(f"{self._base_url}\0{self._fingerprint}\0{agent}\0{label}")[:32]
131
+ cached = self._cache.read(label_key)
132
+ if cached is not None and self._now() - cached.fetched_at < self._ttl_ms:
133
+ return _build(cached.payload, "cache")
134
+ try:
135
+ return _build(self._fetch_release(label_key, agent, label, cached), "network")
136
+ except (ContexorySignatureError, ContexoryResolveError) as error:
137
+ if isinstance(error, ContexorySignatureError) or _is_refusal(error):
138
+ raise
139
+ return self._fall_back(cached, error)
140
+ except Exception as error: # noqa: BLE001 - any transport failure is "unreachable"
141
+ return self._fall_back(cached, error, wrap=True)
142
+
143
+ async def aresolve(self, agent: str, label: str = DEFAULT_LABEL) -> ResolvedContext:
144
+ """`resolve` off the event loop, for async agents."""
145
+ return await asyncio.to_thread(self.resolve, agent, label)
146
+
147
+ def _fall_back(self, cached: CachedResolution | None, error: BaseException, wrap: bool = False) -> ResolvedContext:
148
+ if cached is not None:
149
+ context = _build(cached.payload, "stale-cache")
150
+ context.error = error
151
+ return context
152
+ if wrap:
153
+ raise ContexoryResolveError(f"Could not reach Contexory: {error}", cause=error) from error
154
+ raise error
155
+
156
+ def _get(self, path: str, timeout: float, headers: Mapping[str, str] | None = None) -> FetchResponse:
157
+ return self._fetch(f"{self._base_url}{path}", dict(headers or {}), timeout)
158
+
159
+ def _fetch_release(self, label_key: str, agent: str, label: str, cached: CachedResolution | None) -> ResolvePayload:
160
+ headers = {"Authorization": f"Bearer {self._api_key}"}
161
+ if cached is not None:
162
+ headers["If-None-Match"] = cached.etag
163
+ # With nothing to fall back on a timeout raises, so the request gets the longer budget.
164
+ budget = self._timeout if cached is not None else self._first_timeout
165
+ res = self._get(
166
+ f"/api/v1/agents/{urllib.parse.quote(agent, safe='')}/resolve?label={urllib.parse.quote(label, safe='')}",
167
+ budget,
168
+ headers,
169
+ )
170
+ if res.status == 304 and cached is not None:
171
+ self._cache.touch(label_key, etag=cached.etag, digest=sha256_hex(cached.body), fetched_at=self._now())
172
+ return cached.payload
173
+ if not res.ok:
174
+ raise ContexoryResolveError(f"Contexory resolve answered {res.status}", status=res.status)
175
+
176
+ body = res.text()
177
+ signature = res.headers.get("contexory-signature")
178
+ key_id = res.headers.get("contexory-key-id")
179
+ if not signature or not key_id:
180
+ raise ContexorySignatureError("missing")
181
+ key = self._key_for(key_id, budget)
182
+ if key is None:
183
+ raise ContexorySignatureError("unknown_key")
184
+ if not verify_body(key["publicKey"], body, signature):
185
+ raise ContexorySignatureError("invalid")
186
+
187
+ payload = parse_payload(body)
188
+ if payload is None:
189
+ raise ContexoryResolveError("Contexory resolve sent a payload this SDK cannot read")
190
+ if payload.agent.slug != agent or payload.label != label:
191
+ raise ContexoryResolveError("Contexory resolve answered for a different agent or label")
192
+ self._cache.write(
193
+ label_key, body=body, signature=signature, key_id=key_id, etag=res.headers.get("etag", ""), now=self._now()
194
+ )
195
+ return payload
196
+
197
+ def _key_for(self, key_id: str, budget: float) -> dict[str, str] | None:
198
+ """An id not seen before triggers one discovery fetch, which is how a rotation arrives.
199
+
200
+ Keys are merged, not replaced, so a release cached under the previous key still verifies.
201
+ """
202
+ known = self._cache.read_keys()
203
+ hit = next((k for k in known if k["keyId"] == key_id), None)
204
+ if hit is not None:
205
+ return hit
206
+ res = self._get("/api/v1/signing-keys", budget)
207
+ if not res.ok:
208
+ raise ContexoryResolveError(f"Contexory signing-keys answered {res.status}", status=res.status)
209
+ try:
210
+ data = json.loads(res.text())
211
+ except ValueError:
212
+ data = None
213
+ fetched = parse_keys(data)
214
+ merged = [k for k in known if not any(f["keyId"] == k["keyId"] for f in fetched)] + fetched
215
+ self._cache.write_keys(merged)
216
+ return next((k for k in merged if k["keyId"] == key_id), None)
217
+
218
+
219
+ def _build(payload: ResolvePayload, source: ResolveSource) -> ResolvedContext:
220
+ return ResolvedContext(
221
+ agent=payload.agent,
222
+ label=payload.label,
223
+ release=payload.release,
224
+ system=payload.system_prompt,
225
+ skills=payload.skills,
226
+ tools=payload.tools,
227
+ source=source,
228
+ stale=source == "stale-cache",
229
+ )