contextos-client 0.1.0a13__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.
- contextos/__init__.py +76 -0
- contextos/aio.py +119 -0
- contextos/client.py +602 -0
- contextos/errors.py +91 -0
- contextos_client-0.1.0a13.dist-info/METADATA +61 -0
- contextos_client-0.1.0a13.dist-info/RECORD +9 -0
- contextos_client-0.1.0a13.dist-info/WHEEL +4 -0
- contextos_client-0.1.0a13.dist-info/licenses/LICENSE +202 -0
- contextos_client-0.1.0a13.dist-info/licenses/NOTICE +20 -0
contextos/__init__.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""ContextOS kernel for Python, in-process.
|
|
2
|
+
|
|
3
|
+
The kernel is pure and deterministic: no I/O, no clock, no randomness. Verdicts
|
|
4
|
+
(deny, rejected, insufficient evidence) are returned as results; ``ContextOSError``
|
|
5
|
+
is raised only for unusable input.
|
|
6
|
+
|
|
7
|
+
import contextos
|
|
8
|
+
compiled = contextos.compile(pack=pack, request=request, run_context=run_context)
|
|
9
|
+
decision = contextos.evaluate_authorization(authorization_input)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from . import _native
|
|
17
|
+
except ImportError: # the HTTP client (contextos.client) works without the extension
|
|
18
|
+
_native = None
|
|
19
|
+
|
|
20
|
+
__version__ = getattr(_native, "__version__", "0.1.0-alpha.13")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ContextOSError(Exception):
|
|
24
|
+
"""Raised when the kernel cannot evaluate the input."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, code: str, message: str, detail: Any = None):
|
|
27
|
+
super().__init__(f"{code}: {message}")
|
|
28
|
+
self.code = code
|
|
29
|
+
self.detail = detail
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def call(operation: str, payload: Any) -> Any:
|
|
33
|
+
"""Runs one kernel operation; see the embed facade for the operation list."""
|
|
34
|
+
if _native is None:
|
|
35
|
+
raise ContextOSError("native_unavailable", "the in-process kernel is not built: run `pip install ./bindings/python` (needs maturin and Rust); contextos.client works without it")
|
|
36
|
+
response = json.loads(_native.call(operation, json.dumps(payload, allow_nan=False)))
|
|
37
|
+
if "error" in response:
|
|
38
|
+
error = response["error"]
|
|
39
|
+
raise ContextOSError(error.get("code", "error"), error.get("message", ""), error.get("detail"))
|
|
40
|
+
return response["ok"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def compile(pack: dict, request: dict, run_context: dict, deps: Optional[dict] = None) -> dict:
|
|
44
|
+
"""Compiles a Context Pack into a CompiledContext."""
|
|
45
|
+
return call("compile", {"pack": pack, "request": request, "run_context": run_context, "deps": deps or {}})
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def evaluate_authorization(authorization_input: dict) -> dict:
|
|
49
|
+
"""Conjunctive authorization of one action binding."""
|
|
50
|
+
return call("evaluate_authorization", authorization_input)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def digest_action_binding(binding: dict) -> str:
|
|
54
|
+
"""RFC 8785 digest of an ActionBinding."""
|
|
55
|
+
return call("digest_action_binding", binding)["action_digest"]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def negotiate(contract: dict, support: dict, features: dict) -> dict:
|
|
59
|
+
"""Negotiates an execution contract against the standard profile registry."""
|
|
60
|
+
return call("negotiate", {"contract": contract, "support": support, "features": features})
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def validate(schema: str, instance: Any, definition: Optional[str] = None) -> dict:
|
|
64
|
+
"""Validates a payload against a published ContextOS schema."""
|
|
65
|
+
return call("validate", {"schema": schema, "def": definition, "instance": instance})
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def canonicalize(value: Any) -> dict:
|
|
69
|
+
"""RFC 8785 canonical JSON and its sha256 digest."""
|
|
70
|
+
return call("canonicalize", value)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
__all__ = [
|
|
74
|
+
"ContextOSError", "call", "compile", "evaluate_authorization", "digest_action_binding",
|
|
75
|
+
"negotiate", "validate", "canonicalize", "__version__",
|
|
76
|
+
]
|
contextos/aio.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""The ``contextosd`` client for asyncio agents (ADK, the OpenAI Agents SDK, LlamaIndex,
|
|
2
|
+
Microsoft Agent Framework, Pydantic AI and other async frameworks).
|
|
3
|
+
|
|
4
|
+
``AsyncClient`` and ``AsyncRun`` have the methods of ``Client`` and ``Run`` as
|
|
5
|
+
coroutines. The client stays standard-library only: each request runs the synchronous
|
|
6
|
+
client in a worker thread (``asyncio.to_thread``), so the event loop never blocks on
|
|
7
|
+
the network while urllib keeps handling TLS, proxies and retries. Outcomes raise the
|
|
8
|
+
same exceptions (``ToolPaused``, ``BudgetExhausted``, ...)::
|
|
9
|
+
|
|
10
|
+
from contextos.aio import AsyncClient
|
|
11
|
+
from contextos.client import governed_tool
|
|
12
|
+
|
|
13
|
+
client = AsyncClient("https://contextos.internal", token=os.environ["CONTEXTOS_TOKEN"])
|
|
14
|
+
run = await client.admit(admission_body)
|
|
15
|
+
|
|
16
|
+
@governed_tool(run, "adp_orders.lookup") # an async tool, since run is an AsyncRun
|
|
17
|
+
async def lookup_order(order_id: str) -> dict: ...
|
|
18
|
+
|
|
19
|
+
async with run.budgeted_model_call(tokens=2000):
|
|
20
|
+
response = await model.generate(...)
|
|
21
|
+
|
|
22
|
+
Concurrent calls share the default thread pool; pass ``executor`` to bound them
|
|
23
|
+
separately.
|
|
24
|
+
"""
|
|
25
|
+
import asyncio
|
|
26
|
+
import contextlib
|
|
27
|
+
import functools
|
|
28
|
+
from concurrent.futures import Executor
|
|
29
|
+
from typing import Any, AsyncIterator, List, Optional
|
|
30
|
+
|
|
31
|
+
from .client import Client, Run
|
|
32
|
+
|
|
33
|
+
__all__ = ["AsyncClient", "AsyncRun"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _Async:
|
|
37
|
+
"""Exposes a synchronous object's methods as coroutines run in a worker thread;
|
|
38
|
+
plain attributes read through. A returned ``Run`` comes back as an ``AsyncRun``."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, target: Any, executor: Optional[Executor]):
|
|
41
|
+
self._target = target
|
|
42
|
+
self._executor = executor
|
|
43
|
+
|
|
44
|
+
async def _call(self, function, *args: Any, **kwargs: Any) -> Any:
|
|
45
|
+
args = tuple(a._target if isinstance(a, AsyncRun) else a for a in args)
|
|
46
|
+
loop = asyncio.get_running_loop()
|
|
47
|
+
result = await loop.run_in_executor(self._executor, functools.partial(function, *args, **kwargs))
|
|
48
|
+
if isinstance(result, Run):
|
|
49
|
+
return self if result is self._target else AsyncRun(result, self._executor)
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
def __getattr__(self, name: str) -> Any:
|
|
53
|
+
attribute = getattr(self._target, name)
|
|
54
|
+
if not callable(attribute):
|
|
55
|
+
return attribute
|
|
56
|
+
|
|
57
|
+
@functools.wraps(attribute)
|
|
58
|
+
async def method(*args: Any, **kwargs: Any) -> Any:
|
|
59
|
+
return await self._call(attribute, *args, **kwargs)
|
|
60
|
+
|
|
61
|
+
return method
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AsyncClient(_Async):
|
|
65
|
+
"""``Client`` for asyncio; see the module docstring."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, base_url: str, token: str, timeout: float = 30.0, max_retries: int = 3,
|
|
68
|
+
executor: Optional[Executor] = None, *, allow_http: bool = False):
|
|
69
|
+
super().__init__(Client(base_url, token, timeout, max_retries, allow_http=allow_http), executor)
|
|
70
|
+
|
|
71
|
+
def __repr__(self) -> str:
|
|
72
|
+
return f"Async{self._target!r}"
|
|
73
|
+
|
|
74
|
+
async def admit(self, body: dict, principal_chain: Optional[List[dict]] = None) -> "AsyncRun":
|
|
75
|
+
return await self._call(self._target.admit, body, principal_chain)
|
|
76
|
+
|
|
77
|
+
async def attach(self, run_id: str, trace_id: str, principal_chain: List[dict]) -> "AsyncRun":
|
|
78
|
+
return await self._call(self._target.attach, run_id, trace_id, principal_chain)
|
|
79
|
+
|
|
80
|
+
async def audit_entries(self, start: int = 1, page: int = 500) -> AsyncIterator[dict]:
|
|
81
|
+
"""Yields the tenant's signed audit entries in order, a page per request."""
|
|
82
|
+
cursor: Optional[int] = start
|
|
83
|
+
while cursor is not None:
|
|
84
|
+
batch = await self._call(self._target.request, "GET", "/v1/audit/entries",
|
|
85
|
+
query={"from": cursor, "limit": page})
|
|
86
|
+
for entry in batch["entries"]:
|
|
87
|
+
yield entry
|
|
88
|
+
cursor = batch.get("next")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class AsyncRun(_Async):
|
|
92
|
+
"""``Run`` for asyncio; see the module docstring."""
|
|
93
|
+
|
|
94
|
+
def __init__(self, run: Run, executor: Optional[Executor] = None):
|
|
95
|
+
super().__init__(run, executor)
|
|
96
|
+
|
|
97
|
+
def __repr__(self) -> str:
|
|
98
|
+
return f"Async{self._target!r}"
|
|
99
|
+
|
|
100
|
+
async def call_tool(self, capability_id: str, args: dict, **options: Any) -> dict:
|
|
101
|
+
return await self._call(self._target.call_tool, capability_id, args, **options)
|
|
102
|
+
|
|
103
|
+
def metering_headers(self, reservation_id: str) -> dict:
|
|
104
|
+
return self._target.metering_headers(reservation_id)
|
|
105
|
+
|
|
106
|
+
@contextlib.asynccontextmanager
|
|
107
|
+
async def budgeted_model_call(self, *, tokens: int, cost_cents: int = 0,
|
|
108
|
+
metered: bool = False) -> AsyncIterator[str]:
|
|
109
|
+
"""As ``Run.budgeted_model_call``: reserves before one model call and settles
|
|
110
|
+
after it; ``metered=True`` leaves settlement to a metering LLM gateway."""
|
|
111
|
+
reservation = await self._call(self._target.reserve_usage, "model_call", tokens=tokens, cost_cents=cost_cents)
|
|
112
|
+
settle = self._target._settle_open if metered else self._target.settle_usage
|
|
113
|
+
try:
|
|
114
|
+
yield reservation
|
|
115
|
+
except BaseException:
|
|
116
|
+
await self._call(settle, reservation)
|
|
117
|
+
raise
|
|
118
|
+
if not metered:
|
|
119
|
+
await self._call(settle, reservation)
|
contextos/client.py
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
"""HTTP client for ``contextosd``: govern an agent's tool calls from Python.
|
|
2
|
+
|
|
3
|
+
Standard library only. Typical use::
|
|
4
|
+
|
|
5
|
+
from contextos.client import Client, governed_tool
|
|
6
|
+
|
|
7
|
+
client = Client("https://contextos.internal", token=os.environ["CONTEXTOS_TOKEN"])
|
|
8
|
+
run = client.admit(admission_body) # holds the executor generation
|
|
9
|
+
result = run.call_tool("adp_payments.issue_refund",
|
|
10
|
+
{"order_id": "ord_881", "amount_minor": 1200},
|
|
11
|
+
idempotency_key="refund:ord_881",
|
|
12
|
+
approval_mode="destructive",
|
|
13
|
+
evidence_refs=["kg:order:ord_881#snapshot"])
|
|
14
|
+
|
|
15
|
+
@governed_tool(run, "adp_orders.lookup", approval_mode="read_only")
|
|
16
|
+
def lookup_order(order_id: str): ... # body never runs; the gateway dispatches
|
|
17
|
+
|
|
18
|
+
Results that are not plain success raise typed exceptions, so agent code cannot
|
|
19
|
+
mistake a pause, a refusal or an unknown outcome for a result:
|
|
20
|
+
|
|
21
|
+
- ``ToolPaused``: approval pending, input required, or the outcome is unknown and
|
|
22
|
+
must be reconciled (never retry it blindly);
|
|
23
|
+
- ``ToolBlocked``: authority, policy, constraints or budget refused it;
|
|
24
|
+
- ``ToolRejected``: malformed, unsurfaced or incompatible reuse;
|
|
25
|
+
- ``ToolFailed``: the provider definitively did not apply the effect.
|
|
26
|
+
|
|
27
|
+
A model-call reservation the run cannot afford raises ``BudgetExhausted``. For asyncio
|
|
28
|
+
agents, ``contextos.aio`` has the same client as coroutines.
|
|
29
|
+
|
|
30
|
+
The client retries only requests the server did not process (``429``, ``503``),
|
|
31
|
+
honouring ``Retry-After``. It never retries a tool call after a lost response on
|
|
32
|
+
its own: the gateway's operation ledger makes a retry with the same idempotency key
|
|
33
|
+
safe, but deciding to retry is the caller's.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import contextlib
|
|
37
|
+
import functools
|
|
38
|
+
import inspect
|
|
39
|
+
import json
|
|
40
|
+
import time
|
|
41
|
+
import urllib.error
|
|
42
|
+
import urllib.parse
|
|
43
|
+
import urllib.request
|
|
44
|
+
import uuid
|
|
45
|
+
import warnings
|
|
46
|
+
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple
|
|
47
|
+
|
|
48
|
+
from .errors import (_HTTP_ERRORS, _OUTCOMES, BudgetExhausted, ContextOSHTTPError, IncompatibleServer, ToolBlocked,
|
|
49
|
+
ToolFailed, ToolOutcome, ToolPaused, ToolRejected, VersionMismatchWarning, _pause_kind)
|
|
50
|
+
|
|
51
|
+
GENERATION_HEADER = "ContextOS-Executor-Generation"
|
|
52
|
+
# On a model request, these tell a metering LLM gateway which reservation to settle
|
|
53
|
+
# with the usage it measures (see examples/metering-proxy).
|
|
54
|
+
RUN_HEADER = "ContextOS-Run-Id"
|
|
55
|
+
RESERVATION_HEADER = "ContextOS-Usage-Reservation"
|
|
56
|
+
# The HTTP API generation this client speaks; a server reports its own in
|
|
57
|
+
# ``GET /v1/deployment``.
|
|
58
|
+
API_VERSION = "v1"
|
|
59
|
+
# This client's release (scripts/package_clients.py checks it against Cargo.toml).
|
|
60
|
+
CLIENT_VERSION = "0.1.0-alpha.13"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _check_versions(deployment: Any, client_version: str) -> dict:
|
|
64
|
+
"""Raises on a different API generation; warns on a different or unreported release."""
|
|
65
|
+
deployment = deployment if isinstance(deployment, dict) else {}
|
|
66
|
+
api, runtime = deployment.get("api_version"), deployment.get("runtime_version")
|
|
67
|
+
if api is not None and api != API_VERSION:
|
|
68
|
+
raise IncompatibleServer(f"contextosd speaks API {api}; this client speaks {API_VERSION}")
|
|
69
|
+
if runtime is None:
|
|
70
|
+
warnings.warn(f"contextosd does not report its release (older than this client, {client_version}); "
|
|
71
|
+
"use the same release on both sides", VersionMismatchWarning, stacklevel=4)
|
|
72
|
+
elif runtime != client_version:
|
|
73
|
+
warnings.warn(f"contextosd is release {runtime}; this client is {client_version}. "
|
|
74
|
+
"Use the same release on both sides", VersionMismatchWarning, stacklevel=4)
|
|
75
|
+
return {"api_version": api, "runtime_version": runtime, "client_version": client_version}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def task_scope(root_run_id: str, purpose_id: str, capability_id: str, *, resources: List[str] = (),
|
|
79
|
+
destinations: List[str] = (), classification: str = "CONFIDENTIAL",
|
|
80
|
+
amount: Optional[tuple] = None, argument_constraints: List[dict] = ()) -> dict:
|
|
81
|
+
"""One `AuthorityGrant` scope for a task authorization: this capability, on these
|
|
82
|
+
resources, to these destinations, up to ``amount`` = ``(minor_units, currency)``."""
|
|
83
|
+
scope: Dict[str, Any] = {
|
|
84
|
+
"root_run_id": root_run_id, "purpose_ids": [purpose_id], "capability_ids": [capability_id],
|
|
85
|
+
"resource_ids": list(resources), "destination_ids": list(destinations),
|
|
86
|
+
"classification_labels": [classification], "argument_constraints": list(argument_constraints)}
|
|
87
|
+
if amount is not None:
|
|
88
|
+
scope["amount_ceiling"] = {"minor_units": amount[0], "currency": amount[1]}
|
|
89
|
+
return scope
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def task_authorization(issued_by: dict, scopes: List[dict], *, out_of_scope: str = "deny",
|
|
93
|
+
read_only: str = "allow", confirmation_ref: Optional[dict] = None,
|
|
94
|
+
max_effects: Optional[int] = None, amount_total: Optional[Tuple[int, str]] = None) -> dict:
|
|
95
|
+
"""What the principal authorized for a run, for ``admit(body)`` as
|
|
96
|
+
``body["task_authorization"]``. Effects outside every scope are refused
|
|
97
|
+
(``out_of_scope="deny"``) or wait for an exact approval (``"ask"``, raised as
|
|
98
|
+
``ToolPaused`` with ``kind == "approval"``). ``max_effects`` and ``amount_total``
|
|
99
|
+
(``(minor_units, currency)``) bound the task as a whole, across all its effects."""
|
|
100
|
+
task: Dict[str, Any] = {"issued_by": issued_by, "scopes": scopes, "out_of_scope": out_of_scope,
|
|
101
|
+
"read_only": read_only}
|
|
102
|
+
if confirmation_ref is not None:
|
|
103
|
+
task["confirmation_ref"] = confirmation_ref
|
|
104
|
+
limits: Dict[str, Any] = {}
|
|
105
|
+
if max_effects is not None:
|
|
106
|
+
limits["max_effects"] = max_effects
|
|
107
|
+
if amount_total is not None:
|
|
108
|
+
limits["amount_total"] = {"minor_units": amount_total[0], "currency": amount_total[1]}
|
|
109
|
+
if limits:
|
|
110
|
+
task["limits"] = limits
|
|
111
|
+
return task
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
115
|
+
"""Refuses redirects: urllib would resend the bearer token to wherever one points."""
|
|
116
|
+
|
|
117
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
_OPENER = urllib.request.build_opener(_NoRedirect())
|
|
122
|
+
_LOOPBACK = {"localhost", "127.0.0.1", "::1"}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class Client:
|
|
126
|
+
"""A connection to one ``contextosd`` deployment, authenticated as one caller.
|
|
127
|
+
|
|
128
|
+
The token is sent only to ``base_url``: redirects are not followed (a ``3xx`` raises
|
|
129
|
+
``ContextOSHTTPError``), and plain ``http`` is refused except to a loopback address
|
|
130
|
+
unless ``allow_http=True`` (for example inside a private network you trust).
|
|
131
|
+
|
|
132
|
+
Before its first admission the client reads ``GET /v1/deployment`` once: a server
|
|
133
|
+
with another API generation raises ``IncompatibleServer``, another release warns
|
|
134
|
+
with ``VersionMismatchWarning``. ``check_server=False`` skips it."""
|
|
135
|
+
|
|
136
|
+
def __init__(self, base_url: str, token: str, timeout: float = 30.0, max_retries: int = 3, *,
|
|
137
|
+
allow_http: bool = False, check_server: bool = True):
|
|
138
|
+
parsed = urllib.parse.urlsplit(base_url)
|
|
139
|
+
if parsed.scheme not in ("https", "http") or not parsed.hostname:
|
|
140
|
+
raise ValueError("base_url must be an http(s) URL")
|
|
141
|
+
if parsed.scheme == "http" and parsed.hostname not in _LOOPBACK and not allow_http:
|
|
142
|
+
raise ValueError("base_url is plain http to a remote host: the token would travel in the clear; "
|
|
143
|
+
"use https, or pass allow_http=True")
|
|
144
|
+
self.base_url = base_url.rstrip("/")
|
|
145
|
+
self._token = token
|
|
146
|
+
self.timeout = timeout
|
|
147
|
+
self.max_retries = max_retries
|
|
148
|
+
self._server_checked = not check_server
|
|
149
|
+
|
|
150
|
+
def __repr__(self) -> str: # never print the token
|
|
151
|
+
return f"Client({self.base_url!r})"
|
|
152
|
+
|
|
153
|
+
def request(self, method: str, path: str, body: Any = None, generation: Optional[int] = None,
|
|
154
|
+
query: Optional[Dict[str, Any]] = None) -> Any:
|
|
155
|
+
url = self.base_url + path
|
|
156
|
+
if query:
|
|
157
|
+
url += "?" + urllib.parse.urlencode(query)
|
|
158
|
+
data = None if body is None else json.dumps(body, allow_nan=False).encode()
|
|
159
|
+
headers = {"Authorization": f"Bearer {self._token}", "Accept": "application/json"}
|
|
160
|
+
if data is not None:
|
|
161
|
+
headers["Content-Type"] = "application/json"
|
|
162
|
+
if generation is not None:
|
|
163
|
+
headers[GENERATION_HEADER] = str(generation)
|
|
164
|
+
for attempt in range(self.max_retries + 1):
|
|
165
|
+
request = urllib.request.Request(url, data=data, method=method, headers=headers)
|
|
166
|
+
try:
|
|
167
|
+
with _OPENER.open(request, timeout=self.timeout) as response:
|
|
168
|
+
payload = response.read()
|
|
169
|
+
return json.loads(payload) if payload else None
|
|
170
|
+
except urllib.error.HTTPError as error:
|
|
171
|
+
payload = error.read()
|
|
172
|
+
try:
|
|
173
|
+
decoded = json.loads(payload) if payload else {}
|
|
174
|
+
except ValueError:
|
|
175
|
+
decoded = {}
|
|
176
|
+
# Not processed: safe to retry after the server's hint.
|
|
177
|
+
if error.code in (429, 503) and attempt < self.max_retries:
|
|
178
|
+
time.sleep(_retry_after(error.headers.get("Retry-After"), attempt))
|
|
179
|
+
continue
|
|
180
|
+
failure = decoded.get("error") if isinstance(decoded, dict) else None
|
|
181
|
+
if isinstance(failure, dict):
|
|
182
|
+
code = failure.get("code", "error")
|
|
183
|
+
raise _HTTP_ERRORS.get(code, ContextOSHTTPError)(error.code, code, failure.get("message", ""),
|
|
184
|
+
failure.get("detail")) from None
|
|
185
|
+
# Lifecycle refusals return their outcome body with 409.
|
|
186
|
+
raise ContextOSHTTPError(error.code, "refused", json.dumps(decoded), decoded) from None
|
|
187
|
+
raise AssertionError("unreachable")
|
|
188
|
+
|
|
189
|
+
def check_server(self) -> dict:
|
|
190
|
+
"""Compares the server's API generation and release with this client's (see the
|
|
191
|
+
class docstring) and returns the three versions."""
|
|
192
|
+
versions = _check_versions(self.request("GET", "/v1/deployment"), CLIENT_VERSION)
|
|
193
|
+
self._server_checked = True
|
|
194
|
+
return versions
|
|
195
|
+
|
|
196
|
+
def admit(self, body: dict, principal_chain: Optional[List[dict]] = None) -> "Run":
|
|
197
|
+
"""Admits a run (``POST /v1/runs``) and returns a handle that owns it."""
|
|
198
|
+
if not self._server_checked:
|
|
199
|
+
self.check_server()
|
|
200
|
+
admitted = self.request("POST", "/v1/runs", body)
|
|
201
|
+
context = body.get("run_context", {})
|
|
202
|
+
agent = (context.get("agent") or {}).get("agent_id")
|
|
203
|
+
chain = principal_chain or ([{"kind": "agent", "id": agent}] if agent else [])
|
|
204
|
+
return Run(self, admitted["run_id"], context.get("trace_id", ""), admitted["executor_generation"], chain,
|
|
205
|
+
admitted)
|
|
206
|
+
|
|
207
|
+
def attach(self, run_id: str, trace_id: str, principal_chain: List[dict]) -> "Run":
|
|
208
|
+
"""A handle on an existing run, at its current generation (no takeover)."""
|
|
209
|
+
status = self.request("GET", f"/v1/runs/{_quote(run_id)}")
|
|
210
|
+
return Run(self, run_id, trace_id, status["executor_generation"], principal_chain, status)
|
|
211
|
+
|
|
212
|
+
def amend_task_authorization(self, run_id: str, expected_revision: int, task: dict) -> dict:
|
|
213
|
+
"""Corrects what a run's principal authorized. Narrowing is open to the executor;
|
|
214
|
+
widening needs an ``authorize`` token that does not run tasks, the principal's
|
|
215
|
+
``confirmation_ref`` and the same ``issued_by``. Returns the new revision."""
|
|
216
|
+
return self.request("PUT", f"/v1/runs/{_quote(run_id)}/task-authorization",
|
|
217
|
+
{"expected_revision": expected_revision, "task_authorization": task})
|
|
218
|
+
|
|
219
|
+
def reconcile(self, run_id: str, operation_id: str, *, outcome: Optional[str] = None,
|
|
220
|
+
evidence_ref: Optional[dict] = None) -> dict:
|
|
221
|
+
"""Resolves another principal's unknown effect as an independent reconciler (a
|
|
222
|
+
token with ``reconcile`` that did not admit the run): by provider query, or by
|
|
223
|
+
asserting ``outcome`` with evidence. No executor generation is sent."""
|
|
224
|
+
body = {"source": "provider"} if outcome is None else \
|
|
225
|
+
{"source": "operator", "outcome": outcome, "evidence_ref": evidence_ref}
|
|
226
|
+
return self.request("POST", f"/v1/runs/{_quote(run_id)}/operations/{_quote(operation_id)}/reconcile", body)
|
|
227
|
+
|
|
228
|
+
def report_outcome(self, run_id: str, operation_id: str, attestation: str) -> dict:
|
|
229
|
+
"""Relays an outcome witness's signed reading (a compact JWS) for an applied effect
|
|
230
|
+
whose capability declares an outcome contract. Needs the ``outcome:report`` scope;
|
|
231
|
+
the signature, not the caller, is what the runtime trusts."""
|
|
232
|
+
return self.request("POST", f"/v1/runs/{_quote(run_id)}/operations/{_quote(operation_id)}/outcome",
|
|
233
|
+
{"attestation": attestation})
|
|
234
|
+
|
|
235
|
+
def score(self, run_id: str, record_id: str, evaluator_ref: str, *, policy: float, utility: float,
|
|
236
|
+
safety: float) -> dict:
|
|
237
|
+
"""Scores a recorded decision as an independent evaluator (a client whose token
|
|
238
|
+
has only the `evaluate` scope). The runtime adds the measured cost and latency."""
|
|
239
|
+
return self.request("POST", f"/v1/runs/{_quote(run_id)}/decisions/{_quote(record_id)}/scores",
|
|
240
|
+
{"evaluator_ref": evaluator_ref, "policy": policy, "utility": utility, "safety": safety})
|
|
241
|
+
|
|
242
|
+
def file_feedback(self, run_id: str, kind: str, target_surface: str, summary: str, *,
|
|
243
|
+
decision_record_id: Optional[str] = None, expected_outcome: Optional[str] = None) -> dict:
|
|
244
|
+
"""Files an operator correction or override against a run as an independent
|
|
245
|
+
reviewer (`evaluate` scope); the run's replay case becomes its replay obligation."""
|
|
246
|
+
body: Dict[str, Any] = {"kind": kind, "target_surface": target_surface, "summary": summary}
|
|
247
|
+
if decision_record_id is not None:
|
|
248
|
+
body["decision_record_id"] = decision_record_id
|
|
249
|
+
if expected_outcome is not None:
|
|
250
|
+
body["expected_outcome"] = expected_outcome
|
|
251
|
+
return self.request("POST", f"/v1/runs/{_quote(run_id)}/feedback", body)
|
|
252
|
+
|
|
253
|
+
def open_escalations(self, limit: int = 500) -> dict:
|
|
254
|
+
"""The tenant's open policy escalations, oldest first (`audit:read`)."""
|
|
255
|
+
return self.request("GET", "/v1/escalations", query={"limit": limit})
|
|
256
|
+
|
|
257
|
+
def resolve_escalation(self, escalation_id: str, resolution: str, reason: str) -> dict:
|
|
258
|
+
"""Records how a human resolved an escalation, `handled` or `dismissed` (`control`).
|
|
259
|
+
Grants nothing: an action the human approves is retried through policy."""
|
|
260
|
+
return self.request("POST", f"/v1/escalations/{_quote(escalation_id)}/resolve",
|
|
261
|
+
{"resolution": resolution, "reason": reason})
|
|
262
|
+
|
|
263
|
+
def open_feedback(self, limit: int = 500) -> dict:
|
|
264
|
+
"""The tenant's open feedback, oldest first: the replay corpus a change must pass."""
|
|
265
|
+
return self.request("GET", "/v1/feedback", query={"limit": limit})
|
|
266
|
+
|
|
267
|
+
def close_feedback(self, feedback_id: str, status: str, *, artifact_type: str, artifact_id: str,
|
|
268
|
+
sha256: str) -> dict:
|
|
269
|
+
"""Closes feedback as fixed, still_failing, invalid or superseded, with a reference
|
|
270
|
+
to the evidence (`control` scope)."""
|
|
271
|
+
return self.request("POST", f"/v1/feedback/{_quote(feedback_id)}/close", {
|
|
272
|
+
"status": status,
|
|
273
|
+
"evidence_ref": {"artifact_type": artifact_type, "artifact_id": artifact_id, "sha256": sha256}})
|
|
274
|
+
|
|
275
|
+
def revoke(self, kind: str, target: str, reason: str) -> dict:
|
|
276
|
+
"""Kill switch (a client whose token has the `control` scope): revokes a `tenant`,
|
|
277
|
+
`pack`, `agent`, `adapter` or `capability` from the next dispatch and admission."""
|
|
278
|
+
return self.request("POST", "/v1/revocations", {"kind": kind, "target": target, "reason": reason})
|
|
279
|
+
|
|
280
|
+
def restore(self, kind: str, target: str, reason: str) -> dict:
|
|
281
|
+
"""Restores a revoked target."""
|
|
282
|
+
return self.request("POST", "/v1/revocations/restore", {"kind": kind, "target": target, "reason": reason})
|
|
283
|
+
|
|
284
|
+
def revocations(self) -> dict:
|
|
285
|
+
"""The tenant's revoked targets."""
|
|
286
|
+
return self.request("GET", "/v1/revocations")
|
|
287
|
+
|
|
288
|
+
def audit_entries(self, start: int = 1, page: int = 500):
|
|
289
|
+
"""Yields the tenant's signed audit entries in order."""
|
|
290
|
+
cursor: Optional[int] = start
|
|
291
|
+
while cursor is not None:
|
|
292
|
+
batch = self.request("GET", "/v1/audit/entries", query={"from": cursor, "limit": page})
|
|
293
|
+
yield from batch["entries"]
|
|
294
|
+
cursor = batch.get("next")
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class Run:
|
|
298
|
+
"""One admitted run, held by this executor at ``executor_generation``."""
|
|
299
|
+
|
|
300
|
+
def __init__(self, client: Client, run_id: str, trace_id: str, executor_generation: int,
|
|
301
|
+
principal_chain: List[dict], admitted: Optional[dict] = None):
|
|
302
|
+
self.client = client
|
|
303
|
+
self.run_id = run_id
|
|
304
|
+
self.trace_id = trace_id
|
|
305
|
+
self.executor_generation = executor_generation
|
|
306
|
+
self.principal_chain = principal_chain
|
|
307
|
+
self.admitted = admitted or {}
|
|
308
|
+
# Peer notices that came with the latest tool result (peer data, never instructions).
|
|
309
|
+
self.last_notices: Optional[dict] = None
|
|
310
|
+
|
|
311
|
+
def __repr__(self) -> str:
|
|
312
|
+
return f"Run({self.run_id!r}, generation={self.executor_generation})"
|
|
313
|
+
|
|
314
|
+
def _path(self, suffix: str = "") -> str:
|
|
315
|
+
return f"/v1/runs/{_quote(self.run_id)}{suffix}"
|
|
316
|
+
|
|
317
|
+
def _post(self, suffix: str, body: Any = None) -> Any:
|
|
318
|
+
return self.client.request("POST", self._path(suffix), body if body is not None else {},
|
|
319
|
+
generation=self.executor_generation)
|
|
320
|
+
|
|
321
|
+
def envelope(self, capability_id: str, args: dict, *, approval_mode: str, idempotency_key: Optional[str] = None,
|
|
322
|
+
evidence_refs: Optional[List[str]] = None, tool_call_id: Optional[str] = None) -> dict:
|
|
323
|
+
"""The ``contextos.tool_call.v1`` envelope for a call."""
|
|
324
|
+
return {
|
|
325
|
+
"envelope_version": "contextos.tool_call.v1",
|
|
326
|
+
"tool_call_id": tool_call_id or f"tc_{uuid.uuid4().hex}",
|
|
327
|
+
"run_id": self.run_id, "trace_id": self.trace_id, "capability_id": capability_id, "args": args,
|
|
328
|
+
"principal_chain": self.principal_chain, "approval_mode_requested": approval_mode,
|
|
329
|
+
"approval_mode_effective": approval_mode, "approval_mode_highest": approval_mode,
|
|
330
|
+
"evidence_refs": evidence_refs or [], "idempotency_key": idempotency_key,
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
def call_tool(self, capability_id: str, args: dict, *, approval_mode: str = "read_only",
|
|
334
|
+
idempotency_key: Optional[str] = None, evidence_refs: Optional[List[str]] = None,
|
|
335
|
+
tool_call_id: Optional[str] = None) -> dict:
|
|
336
|
+
"""Governs and dispatches one tool call; returns its output or raises a ``ToolOutcome``."""
|
|
337
|
+
envelope = self.envelope(capability_id, args, approval_mode=approval_mode, idempotency_key=idempotency_key,
|
|
338
|
+
evidence_refs=evidence_refs, tool_call_id=tool_call_id)
|
|
339
|
+
response = self._post("/tool-calls", envelope)
|
|
340
|
+
self.last_notices = (response.get("result", {}).get("metadata") or {}).get("notices")
|
|
341
|
+
return _outcome(response)
|
|
342
|
+
|
|
343
|
+
def provide_input(self, paused: ToolPaused, envelope: dict, responses: dict) -> dict:
|
|
344
|
+
"""Answers a tool's input requests and continues the same operation."""
|
|
345
|
+
body = {"tool_call": envelope, "input_responses": responses}
|
|
346
|
+
return _outcome(self._post(f"/operations/{_quote(paused.operation_id or '')}/input", body))
|
|
347
|
+
|
|
348
|
+
def reconcile(self, operation_id: str, *, outcome: Optional[str] = None,
|
|
349
|
+
evidence_ref: Optional[dict] = None) -> dict:
|
|
350
|
+
"""Resolves an unknown effect: by provider query, or by asserting ``outcome`` with evidence.
|
|
351
|
+
The run's own principal may only assert ``confirmed``; ``not_applied`` and ``partial``
|
|
352
|
+
need an independent reconciler (``Client.reconcile``)."""
|
|
353
|
+
body = {"source": "provider"} if outcome is None else \
|
|
354
|
+
{"source": "operator", "outcome": outcome, "evidence_ref": evidence_ref}
|
|
355
|
+
return self._post(f"/operations/{_quote(operation_id)}/reconcile", body)
|
|
356
|
+
|
|
357
|
+
def compensations(self) -> dict:
|
|
358
|
+
"""The run's compensable effects and where each stands (``state``)."""
|
|
359
|
+
return self.client.request("GET", self._path("/compensations"))
|
|
360
|
+
|
|
361
|
+
def compensate(self, operation_id: str, *, tool_call_id: Optional[str] = None) -> dict:
|
|
362
|
+
"""Compensates one applied effect with its declared compensating capability, governed
|
|
363
|
+
as a new operation; returns its output or raises a ``ToolOutcome``."""
|
|
364
|
+
body = {"tool_call_id": tool_call_id or f"tc_{uuid.uuid4().hex}"}
|
|
365
|
+
return _outcome(self._post(f"/operations/{_quote(operation_id)}/compensate", body))
|
|
366
|
+
|
|
367
|
+
def compensate_all(self) -> dict:
|
|
368
|
+
"""Compensates the run's applied effects newest first, stopping at any in doubt.
|
|
369
|
+
Returns ``{"status": "compensated" | "stopped", "steps", "stopped_at"}``."""
|
|
370
|
+
return self._post("/compensate")
|
|
371
|
+
|
|
372
|
+
def record_decision(self, decision_key: str, status: str, *, decision_version: str = "1",
|
|
373
|
+
outcome: Optional[str] = None, tool_calls: Optional[List[dict]] = None, **fields: Any) -> dict:
|
|
374
|
+
"""Records a DecisionRecord; returns ``{"record", "replay_packet"}``."""
|
|
375
|
+
body = {"decision_key": decision_key, "decision_version": decision_version, "status": status,
|
|
376
|
+
"tool_calls": tool_calls or [], **fields}
|
|
377
|
+
if outcome is not None:
|
|
378
|
+
body["outcome"] = outcome
|
|
379
|
+
return self._post("/decisions", body)
|
|
380
|
+
|
|
381
|
+
def delegate(self, child_admission: dict, envelope: dict) -> "Run":
|
|
382
|
+
"""Admits a child run in a narrowed lane (``AgentDelegationEnvelope``). Its
|
|
383
|
+
budget is reserved from this run; this run must hold the current generation."""
|
|
384
|
+
body = dict(child_admission, delegation=envelope)
|
|
385
|
+
admitted = self.client.request("POST", "/v1/runs", body, generation=self.executor_generation)
|
|
386
|
+
context = child_admission.get("run_context", {})
|
|
387
|
+
return Run(self.client, admitted["run_id"], context.get("trace_id", self.trace_id), admitted["executor_generation"],
|
|
388
|
+
self.principal_chain, admitted)
|
|
389
|
+
|
|
390
|
+
def close_child(self, child: "Run", decision: str = "accept") -> dict:
|
|
391
|
+
"""Closes a delegated child, settling its spend into this run."""
|
|
392
|
+
return self.client.request("POST", f"/v1/runs/{_quote(child.run_id)}/close", {"decision": decision},
|
|
393
|
+
generation=self.executor_generation)
|
|
394
|
+
|
|
395
|
+
def checkpoint(self, state: Any, expected_revision: int) -> dict:
|
|
396
|
+
"""Saves the orchestrator's state as the next checkpoint revision."""
|
|
397
|
+
return self._post("/checkpoint", {"expected_revision": expected_revision, "state": state})
|
|
398
|
+
|
|
399
|
+
def latest_checkpoint(self) -> dict:
|
|
400
|
+
"""The latest checkpoint (`state`, `revision`), e.g. after taking the run over."""
|
|
401
|
+
return self.client.request("GET", self._path("/checkpoint"), generation=self.executor_generation)
|
|
402
|
+
|
|
403
|
+
def publish_work(self, task_id: str, title: str, acceptance_criteria: str, *,
|
|
404
|
+
dependencies: Optional[List[str]] = None) -> dict:
|
|
405
|
+
"""Publish immutable task metadata shared with this root's descendants.
|
|
406
|
+
Claims and worker-reported evidence confer no tool authority."""
|
|
407
|
+
return self._post("/work", {"task_id": task_id, "title": title,
|
|
408
|
+
"acceptance_criteria": acceptance_criteria, "dependencies": dependencies or []})
|
|
409
|
+
|
|
410
|
+
def work_tasks(self, *, after: Optional[str] = None, limit: int = 100) -> dict:
|
|
411
|
+
"""A live page of tasks; restart from the beginning to see new earlier ids."""
|
|
412
|
+
query: Dict[str, Any] = {"limit": limit}
|
|
413
|
+
if after is not None:
|
|
414
|
+
query["after"] = after
|
|
415
|
+
return self.client.request("GET", self._path("/work"), query=query, generation=self.executor_generation)
|
|
416
|
+
|
|
417
|
+
def work_task(self, task_id: str) -> dict:
|
|
418
|
+
"""Inspect current revision and lease, including predecessor provenance."""
|
|
419
|
+
return self.client.request("GET", self._path(f"/work/{_quote(task_id)}"), generation=self.executor_generation)
|
|
420
|
+
|
|
421
|
+
def update_work(self, task_id: str, action: str, expected_revision: int, *, lease_epoch: Optional[int] = None,
|
|
422
|
+
lease_seconds: Optional[int] = None, evidence_refs: Optional[List[dict]] = None) -> dict:
|
|
423
|
+
"""Conditionally claim, renew, release or report completion. After a lost
|
|
424
|
+
response or conflict, inspect the task. Reclaiming a lease does not establish
|
|
425
|
+
that the previous worker's external effects are safe to repeat."""
|
|
426
|
+
body: Dict[str, Any] = {"action": action, "expected_revision": expected_revision}
|
|
427
|
+
if lease_epoch is not None:
|
|
428
|
+
body["lease_epoch"] = lease_epoch
|
|
429
|
+
if lease_seconds is not None:
|
|
430
|
+
body["lease_seconds"] = lease_seconds
|
|
431
|
+
if evidence_refs is not None:
|
|
432
|
+
body["evidence_refs"] = evidence_refs
|
|
433
|
+
return self._post(f"/work/{_quote(task_id)}", body)
|
|
434
|
+
|
|
435
|
+
def publish_finding(self, finding_id: str, statement: str, *, kind: str = "claim",
|
|
436
|
+
depends_on: Optional[List[str]] = None, basis_operation_id: Optional[str] = None,
|
|
437
|
+
basis_run_id: Optional[str] = None, refutes: Optional[str] = None,
|
|
438
|
+
urgent: bool = False) -> dict:
|
|
439
|
+
"""Shares a finding with the team: a ``claim``, or an ``observed`` finding bound to a
|
|
440
|
+
recorded tool call (``basis_operation_id``, in ``basis_run_id`` or this run) whose
|
|
441
|
+
facts the runtime takes from its operation ledger; an observation that ``refutes``
|
|
442
|
+
a peer's finding can then invalidate it. ``urgent`` notifies the team."""
|
|
443
|
+
body: Dict[str, Any] = {"finding_id": finding_id, "kind": kind, "statement": statement,
|
|
444
|
+
"depends_on": depends_on or [], "urgent": urgent}
|
|
445
|
+
if basis_operation_id is not None:
|
|
446
|
+
body["kind"] = "observed"
|
|
447
|
+
body["basis"] = {"operation_id": basis_operation_id}
|
|
448
|
+
if basis_run_id is not None:
|
|
449
|
+
body["basis"]["run_id"] = basis_run_id
|
|
450
|
+
if refutes is not None:
|
|
451
|
+
body["refutes"] = refutes
|
|
452
|
+
return self._post("/findings", body)
|
|
453
|
+
|
|
454
|
+
def findings(self, *, after: Optional[str] = None, limit: int = 100) -> dict:
|
|
455
|
+
"""A page of the team's findings with status (active, suspect, invalidated)."""
|
|
456
|
+
query: Dict[str, Any] = {"limit": limit}
|
|
457
|
+
if after is not None:
|
|
458
|
+
query["after"] = after
|
|
459
|
+
return self.client.request("GET", self._path("/findings"), query=query, generation=self.executor_generation)
|
|
460
|
+
|
|
461
|
+
def invalidate_finding(self, finding_id: str, reason: str, *, refuted_by: Optional[str] = None) -> dict:
|
|
462
|
+
"""Falsifies a finding: withdraw your own, or cite an observed finding refuting a
|
|
463
|
+
peer's. Its dependents become suspect and the team is notified."""
|
|
464
|
+
body: Dict[str, Any] = {"reason": reason}
|
|
465
|
+
if refuted_by is not None:
|
|
466
|
+
body["refuted_by"] = refuted_by
|
|
467
|
+
return self._post(f"/findings/{_quote(finding_id)}/invalidate", body)
|
|
468
|
+
|
|
469
|
+
def notices(self, *, limit: int = 20) -> dict:
|
|
470
|
+
"""This run's unacknowledged peer notices; reading does not acknowledge them."""
|
|
471
|
+
return self.client.request("GET", self._path("/notices"), query={"limit": limit},
|
|
472
|
+
generation=self.executor_generation)
|
|
473
|
+
|
|
474
|
+
def ack_notices(self, through: int) -> dict:
|
|
475
|
+
"""Acknowledges notices through sequence ``through`` once the agent has seen them."""
|
|
476
|
+
return self._post("/notices/ack", {"through": through})
|
|
477
|
+
|
|
478
|
+
def reserve_usage(self, kind: str, *, tokens: int = 0, cost_cents: int = 0) -> str:
|
|
479
|
+
"""Reserves budget for a `model_call` or a `replan`; returns the reservation id."""
|
|
480
|
+
return self._post("/usage/reserve", {"kind": kind, "tokens": tokens, "cost_cents": cost_cents})["reservation_id"]
|
|
481
|
+
|
|
482
|
+
def settle_usage(self, reservation_id: str) -> dict:
|
|
483
|
+
"""Settles a reservation as the executor: the whole reservation is charged. Pass
|
|
484
|
+
the reservation id to your metering gateway instead to settle measured usage."""
|
|
485
|
+
return self._post("/usage/settle", {"reservation_id": reservation_id})
|
|
486
|
+
|
|
487
|
+
def metering_headers(self, reservation_id: str) -> Dict[str, str]:
|
|
488
|
+
"""Headers for a model request that a metering LLM gateway settles with the
|
|
489
|
+
usage it measures (``budgeted_model_call(..., metered=True)``)."""
|
|
490
|
+
return {RUN_HEADER: self.run_id, RESERVATION_HEADER: reservation_id}
|
|
491
|
+
|
|
492
|
+
@contextlib.contextmanager
|
|
493
|
+
def budgeted_model_call(self, *, tokens: int, cost_cents: int = 0, metered: bool = False) -> Iterator[str]:
|
|
494
|
+
"""Reserves budget for one model call and settles it afterwards, even if the call
|
|
495
|
+
raises. Raises ``BudgetExhausted`` before the call when the run cannot afford it.
|
|
496
|
+
|
|
497
|
+
``metered=True`` leaves settlement to a metering LLM gateway (send
|
|
498
|
+
``metering_headers(reservation)`` with the model request), which charges what it
|
|
499
|
+
measured. If the call raises, a reservation the gateway did not settle is settled
|
|
500
|
+
here, charging all of it: unknown usage is never free."""
|
|
501
|
+
reservation = self.reserve_usage("model_call", tokens=tokens, cost_cents=cost_cents)
|
|
502
|
+
try:
|
|
503
|
+
yield reservation
|
|
504
|
+
except BaseException:
|
|
505
|
+
if metered:
|
|
506
|
+
self._settle_open(reservation)
|
|
507
|
+
else:
|
|
508
|
+
self.settle_usage(reservation)
|
|
509
|
+
raise
|
|
510
|
+
if not metered:
|
|
511
|
+
self.settle_usage(reservation)
|
|
512
|
+
|
|
513
|
+
def _settle_open(self, reservation_id: str) -> None:
|
|
514
|
+
try:
|
|
515
|
+
self.settle_usage(reservation_id)
|
|
516
|
+
except ContextOSHTTPError as error:
|
|
517
|
+
if error.status != 404: # 404: already settled by the gateway
|
|
518
|
+
raise
|
|
519
|
+
|
|
520
|
+
def status(self) -> dict:
|
|
521
|
+
return self.client.request("GET", self._path())
|
|
522
|
+
|
|
523
|
+
def take_over(self) -> "Run":
|
|
524
|
+
"""Takes the run over from whichever executor held it; the old one becomes stale."""
|
|
525
|
+
current = self.status()["executor_generation"]
|
|
526
|
+
taken = self.client.request("POST", self._path("/takeover"), {"expected_generation": current})
|
|
527
|
+
self.executor_generation = taken["executor_generation"]
|
|
528
|
+
return self
|
|
529
|
+
|
|
530
|
+
def complete(self) -> dict:
|
|
531
|
+
return self._post("/complete")
|
|
532
|
+
|
|
533
|
+
def fail(self) -> dict:
|
|
534
|
+
return self._post("/fail")
|
|
535
|
+
|
|
536
|
+
def cancel(self) -> dict:
|
|
537
|
+
return self.client.request("POST", self._path("/cancel"), {})
|
|
538
|
+
|
|
539
|
+
def evidence(self) -> dict:
|
|
540
|
+
return self.client.request("GET", self._path("/evidence"))
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def governed_tool(run: Run, capability_id: str, *, approval_mode: str = "read_only",
|
|
544
|
+
idempotency_key: Optional[Callable[..., str]] = None,
|
|
545
|
+
evidence_refs: Optional[List[str]] = None,
|
|
546
|
+
tool_call_id: Optional[Callable[..., str]] = None) -> Callable[[Callable[..., Any]], Callable[..., dict]]:
|
|
547
|
+
"""Turns a tool's signature into a governed call: arguments are bound by name and
|
|
548
|
+
sent through the gateway; the decorated body never runs locally.
|
|
549
|
+
|
|
550
|
+
``idempotency_key`` derives the operation key from the call's arguments, so a
|
|
551
|
+
retried agent step maps to the same operation and cannot apply twice.
|
|
552
|
+
``tool_call_id`` derives the call's id the same way, so a DecisionRecord can cite it.
|
|
553
|
+
With an ``AsyncRun`` (``contextos.aio``) the governed function is a coroutine.
|
|
554
|
+
"""
|
|
555
|
+
|
|
556
|
+
def wrap(function: Callable[..., Any]) -> Callable[..., Any]:
|
|
557
|
+
signature = inspect.signature(function)
|
|
558
|
+
|
|
559
|
+
def dispatch(args: Any, kwargs: Any) -> Any:
|
|
560
|
+
bound = signature.bind(*args, **kwargs)
|
|
561
|
+
bound.apply_defaults()
|
|
562
|
+
arguments = dict(bound.arguments)
|
|
563
|
+
key = idempotency_key(**arguments) if idempotency_key else None
|
|
564
|
+
call_id = tool_call_id(**arguments) if tool_call_id else None
|
|
565
|
+
return run.call_tool(capability_id, arguments, approval_mode=approval_mode, idempotency_key=key,
|
|
566
|
+
evidence_refs=evidence_refs, tool_call_id=call_id)
|
|
567
|
+
|
|
568
|
+
if inspect.iscoroutinefunction(run.call_tool):
|
|
569
|
+
@functools.wraps(function)
|
|
570
|
+
async def call_async(*args: Any, **kwargs: Any) -> dict:
|
|
571
|
+
return await dispatch(args, kwargs)
|
|
572
|
+
|
|
573
|
+
return call_async
|
|
574
|
+
|
|
575
|
+
@functools.wraps(function)
|
|
576
|
+
def call(*args: Any, **kwargs: Any) -> dict:
|
|
577
|
+
return dispatch(args, kwargs)
|
|
578
|
+
|
|
579
|
+
return call
|
|
580
|
+
|
|
581
|
+
return wrap
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _outcome(response: dict) -> dict:
|
|
585
|
+
result, report = response["result"], response["report"]
|
|
586
|
+
status = result.get("status")
|
|
587
|
+
if status in ("ok", "completed"):
|
|
588
|
+
return result.get("output") or {}
|
|
589
|
+
raise _OUTCOMES.get(status, ToolOutcome)(result, report)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _retry_after(header: Optional[str], attempt: int) -> float:
|
|
593
|
+
try:
|
|
594
|
+
return min(float(header), 30.0) if header else min(0.2 * 2 ** attempt, 5.0)
|
|
595
|
+
except ValueError:
|
|
596
|
+
return min(0.2 * 2 ** attempt, 5.0)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _quote(segment: str) -> str:
|
|
600
|
+
if segment in ("", ".", ".."):
|
|
601
|
+
raise ValueError(f"invalid path segment {segment!r}")
|
|
602
|
+
return urllib.parse.quote(segment, safe="")
|
contextos/errors.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""What the ``contextosd`` client raises: refused requests, governed tool outcomes that
|
|
2
|
+
are not plain success, and an incompatible server. Import them from
|
|
3
|
+
``contextos.client``, which re-exports every name here."""
|
|
4
|
+
from typing import Any, List, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ContextOSHTTPError(Exception):
|
|
8
|
+
"""The server refused the request (4xx/5xx other than a governed tool result)."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, status: int, code: str, message: str, detail: Any = None):
|
|
11
|
+
super().__init__(f"{status} {code}: {message}")
|
|
12
|
+
self.status = status
|
|
13
|
+
self.code = code
|
|
14
|
+
self.detail = detail
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BudgetExhausted(ContextOSHTTPError):
|
|
18
|
+
"""The run's budget cannot fit a model-call or replan reservation (``reasons``
|
|
19
|
+
says which limit). Stop the loop: retrying the same reservation cannot succeed."""
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def reasons(self) -> List[str]:
|
|
23
|
+
return self.detail if isinstance(self.detail, list) else []
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_HTTP_ERRORS = {"budget_exhausted": BudgetExhausted}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class IncompatibleServer(Exception):
|
|
30
|
+
"""The server speaks a different HTTP API generation than this client."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class VersionMismatchWarning(UserWarning):
|
|
34
|
+
"""The server is a different ContextOS release than this client. Releases before
|
|
35
|
+
1.0 may change the API, so use the same release on both sides."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ToolOutcome(Exception):
|
|
39
|
+
"""A governed tool call that did not simply succeed."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, result: dict, report: dict):
|
|
42
|
+
self.result = result
|
|
43
|
+
self.report = report
|
|
44
|
+
self.status = result.get("status")
|
|
45
|
+
self.reason_codes: List[str] = report.get("reason_codes", [])
|
|
46
|
+
self.obligations: List[str] = report.get("obligations", [])
|
|
47
|
+
self.operation_id: Optional[str] = report.get("operation_id")
|
|
48
|
+
super().__init__(f"{self.status}: {', '.join(self.reason_codes) or result.get('error') or ''}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ToolPaused(ToolOutcome):
|
|
52
|
+
"""Approval pending, input required, or an unknown outcome awaiting reconciliation."""
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def kind(self) -> str:
|
|
56
|
+
"""What the pause waits on: ``approval`` (redeem by re-sending the same call once
|
|
57
|
+
approved), ``input`` (answer with ``provide_input``), ``unknown_outcome`` (the
|
|
58
|
+
effect may have happened: reconcile), ``duplicate`` (this operation was already
|
|
59
|
+
requested and is not repeated) or ``other``."""
|
|
60
|
+
return _pause_kind(self.obligations, self.reason_codes)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def input_requests(self) -> Optional[dict]:
|
|
64
|
+
return (self.result.get("output") or {}).get("result", {}).get("inputRequests")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ToolBlocked(ToolOutcome):
|
|
68
|
+
"""Authority, policy, constraints or budget refused the call."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ToolRejected(ToolOutcome):
|
|
72
|
+
"""The call was malformed, not surfaced to this run, or reused an operation key."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ToolFailed(ToolOutcome):
|
|
76
|
+
"""The provider definitively did not apply the effect."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _pause_kind(obligations: List[str], reason_codes: List[str]) -> str:
|
|
80
|
+
if "await_approval" in obligations:
|
|
81
|
+
return "approval"
|
|
82
|
+
if "provide_requested_input" in obligations:
|
|
83
|
+
return "input"
|
|
84
|
+
if "reconcile_effect_before_retry" in obligations:
|
|
85
|
+
return "unknown_outcome"
|
|
86
|
+
if "retry_unsafe" in reason_codes or "reconcile_existing_operation" in obligations:
|
|
87
|
+
return "duplicate"
|
|
88
|
+
return "other"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
_OUTCOMES = {"paused": ToolPaused, "blocked": ToolBlocked, "rejected": ToolRejected, "failed": ToolFailed}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: contextos-client
|
|
3
|
+
Version: 0.1.0a13
|
|
4
|
+
Summary: ContextOS kernel: governed context, authorization and durable effects for AI agents
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Project-URL: Source, https://github.com/contextosai/ContextOS
|
|
8
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# ContextOS for Python
|
|
13
|
+
|
|
14
|
+
Two modules, one package:
|
|
15
|
+
|
|
16
|
+
- `contextos.client` (and `contextos.aio` for asyncio) talks to `contextosd` over
|
|
17
|
+
HTTP. Standard library only: no compiled code, no dependencies.
|
|
18
|
+
- `contextos` itself is the kernel, in-process, through a compiled extension
|
|
19
|
+
(`contextos._native`). You need it only to embed the kernel without a server.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
pip install --pre contextos-client
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Published on PyPI as `contextos-client` from 0.1.0-alpha.13; the module is still
|
|
28
|
+
`import contextos`. Linux (x86_64 and aarch64, glibc 2.28 or newer) and macOS on
|
|
29
|
+
Apple silicon get a wheel with the compiled kernel. Every other platform gets
|
|
30
|
+
a pure-Python wheel with the HTTP client only; kernel calls there raise
|
|
31
|
+
`native_unavailable`. Use the client release that matches your `contextosd`: before its
|
|
32
|
+
first admission the client warns (`VersionMismatchWarning`) when the server is another
|
|
33
|
+
release, and refuses (`IncompatibleServer`) a server with another API generation.
|
|
34
|
+
|
|
35
|
+
## The HTTP client (most agents)
|
|
36
|
+
|
|
37
|
+
From a checkout instead of the package index, put the package on your path or copy
|
|
38
|
+
`bindings/python/contextos/` into your project:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
export PYTHONPATH=/path/to/ContextOS/bindings/python
|
|
42
|
+
python3 -c "from contextos.client import Client; print('ok')"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Try it against a local server: `./examples/dev-server.sh` prints `CONTEXTOS_URL` and
|
|
46
|
+
`CONTEXTOS_TOKEN`, and `examples/support-refund/requests/run.json` is a run you can
|
|
47
|
+
admit. `examples/frameworks/` shows the client inside eleven agent frameworks.
|
|
48
|
+
|
|
49
|
+
## The embedded kernel
|
|
50
|
+
|
|
51
|
+
The platform wheels above include it. To build the extension yourself, use
|
|
52
|
+
[maturin](https://www.maturin.rs/) (needs the Rust toolchain):
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
python3 -m pip install maturin
|
|
56
|
+
python3 -m pip install ./bindings/python # or: cd bindings/python && maturin develop
|
|
57
|
+
python3 -c "import contextos; print(contextos.__version__)"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Without it, `import contextos` still works for the client; kernel calls raise an error
|
|
61
|
+
that says to build the extension.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
contextos/__init__.py,sha256=pgUa4PhD2hR5b-lw8MZELmkxA4cqqVmvJq5NsP744Vo,2976
|
|
2
|
+
contextos/client.py,sha256=fl98lzwx8AxDQsm-eutKwqpzQ_Jl-UDo8C5m-Mb4XbE,33434
|
|
3
|
+
contextos/errors.py,sha256=IVU4Td2ihJxkASw2m_eQsHpuRnqd9ZxabStzUhc5DO4,3432
|
|
4
|
+
contextos/aio.py,sha256=6uUycooyBXtueitp82GopKw7hXC0MlKiBYrPUSfg_2o,5129
|
|
5
|
+
contextos_client-0.1.0a13.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
6
|
+
contextos_client-0.1.0a13.dist-info/licenses/NOTICE,sha256=lMzC_abhf9T5x-5zPUG6WWLFdhu1O493F4S0xljAOg4,894
|
|
7
|
+
contextos_client-0.1.0a13.dist-info/METADATA,sha256=4r6Dy--DAe4EsXRFVd1huYHsacorOBNBqK6OKCWQSes,2413
|
|
8
|
+
contextos_client-0.1.0a13.dist-info/WHEEL,sha256=j5NxlqV4xFdG6alboEDVE7NM2YEmUYICLhdZyMk-S7I,99
|
|
9
|
+
contextos_client-0.1.0a13.dist-info/RECORD,,
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
ContextOS
|
|
2
|
+
Copyright 2026 The ContextOS Authors
|
|
3
|
+
|
|
4
|
+
This product includes software developed as part of the ContextOS project
|
|
5
|
+
(https://github.com/contextosai/ContextOS), licensed under the Apache License,
|
|
6
|
+
Version 2.0 (see LICENSE).
|
|
7
|
+
|
|
8
|
+
Third-party dependency licenses and attribution are in THIRD_PARTY_NOTICES.txt.
|
|
9
|
+
|
|
10
|
+
conformance/reference/ includes the unmodified deterministic TypeScript reference
|
|
11
|
+
and synthetic fixtures from contextosai/contextosai, revision
|
|
12
|
+
7e2462fd931a79e3777ede81fb382e0cbf2ff46c.
|
|
13
|
+
Copyright (c) 2025-2026 ContextOS; licensed under Apache-2.0.
|
|
14
|
+
|
|
15
|
+
Documentation prose in docs/ and the Markdown guides is licensed under the
|
|
16
|
+
Creative Commons Attribution 4.0 International license (see LICENSE-docs);
|
|
17
|
+
code examples within it are licensed under Apache-2.0.
|
|
18
|
+
|
|
19
|
+
"ContextOS" and its logo are trademarks of the ContextOS project; neither
|
|
20
|
+
license grants rights to use them (see TRADEMARKS.md).
|