ctxmesh 0.1.0b1__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.
- ctxmesh/__init__.py +105 -0
- ctxmesh/_approval.py +123 -0
- ctxmesh/_capability.py +64 -0
- ctxmesh/_checkpoint.py +117 -0
- ctxmesh/_http.py +229 -0
- ctxmesh/_multimodal.py +98 -0
- ctxmesh/_record.py +61 -0
- ctxmesh/_semconv.py +43 -0
- ctxmesh/_tracing.py +196 -0
- ctxmesh/agent.py +45 -0
- ctxmesh/client.py +119 -0
- ctxmesh/config.py +225 -0
- ctxmesh/errors.py +132 -0
- ctxmesh/feedback.py +74 -0
- ctxmesh/knowledge.py +178 -0
- ctxmesh/managed.py +2205 -0
- ctxmesh/memory.py +196 -0
- ctxmesh/mesh.py +100 -0
- ctxmesh/model.py +504 -0
- ctxmesh/py.typed +0 -0
- ctxmesh/runs.py +216 -0
- ctxmesh/serve.py +338 -0
- ctxmesh/skills.py +153 -0
- ctxmesh/testing.py +526 -0
- ctxmesh/tools.py +890 -0
- ctxmesh/trace.py +315 -0
- ctxmesh-0.1.0b1.dist-info/METADATA +157 -0
- ctxmesh-0.1.0b1.dist-info/RECORD +30 -0
- ctxmesh-0.1.0b1.dist-info/WHEEL +5 -0
- ctxmesh-0.1.0b1.dist-info/top_level.txt +1 -0
ctxmesh/__init__.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""ctxmesh — the ctxmesh Python SDK.
|
|
2
|
+
|
|
3
|
+
Typed, optional sugar over the launcher's language-agnostic localhost platform
|
|
4
|
+
plane (ADR 0002). Every capability the SDK exposes is *also* a raw launcher
|
|
5
|
+
endpoint; the SDK never adds a capability the plane does not have — it only
|
|
6
|
+
removes the raw-HTTP boilerplate and applies the run context.
|
|
7
|
+
|
|
8
|
+
Surface:
|
|
9
|
+
|
|
10
|
+
from ctxmesh import agent
|
|
11
|
+
|
|
12
|
+
client = agent.from_env() # reads the launcher-injected env
|
|
13
|
+
|
|
14
|
+
client.memory.get(); client.memory.put(entries)
|
|
15
|
+
client.memory.append(entry); client.memory.search(query)
|
|
16
|
+
|
|
17
|
+
client.tools.list() # live discovery manifest (:2999)
|
|
18
|
+
client.tools.call(name, **args) # invoke a bound MCP tool
|
|
19
|
+
|
|
20
|
+
client.feedback.score(trace_id, name, value, comment=None) # :2995
|
|
21
|
+
|
|
22
|
+
client.model.chat(model, messages, **opts) # $MODEL_GATEWAY_URL; emits LLM span
|
|
23
|
+
|
|
24
|
+
# knowledge-base retrieval (M68, ADR 0061 Fork 3):
|
|
25
|
+
client.knowledge.search("what is X?") # POST :2998/knowledge/search
|
|
26
|
+
|
|
27
|
+
# multimodal content-parts helpers (M68, ADR 0061 Fork 5):
|
|
28
|
+
from ctxmesh import text_part, image_url, content
|
|
29
|
+
msgs = [{"role": "user", "content": content(
|
|
30
|
+
text_part("What is in this image?"),
|
|
31
|
+
image_url("https://example.com/photo.jpg"),
|
|
32
|
+
)}]
|
|
33
|
+
|
|
34
|
+
# step-tracing helpers for a custom loop (the M10 core). Bind the inbound
|
|
35
|
+
# request so the tree roots under the launcher's agent.invoke span:
|
|
36
|
+
with client.trace.request_context(request.headers):
|
|
37
|
+
with client.trace.step("plan") as step: # CHAIN span
|
|
38
|
+
plan = client.model.chat(model, messages) # nested LLM span
|
|
39
|
+
with client.trace.tool("search", args) as t: # TOOL span (child)
|
|
40
|
+
t.set_output(client.tools.call("search", **args))
|
|
41
|
+
step.set_output(plan)
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from ctxmesh import agent
|
|
45
|
+
from ctxmesh._approval import pause_for_approval
|
|
46
|
+
from ctxmesh._multimodal import content, image_url, text_part
|
|
47
|
+
from ctxmesh.client import Client
|
|
48
|
+
from ctxmesh.config import PlaneConfig
|
|
49
|
+
from ctxmesh.errors import (
|
|
50
|
+
ApprovalRequiredError,
|
|
51
|
+
ConfigError,
|
|
52
|
+
CtxmeshError,
|
|
53
|
+
EndpointError,
|
|
54
|
+
GuardrailBlockedError,
|
|
55
|
+
NotInPodError,
|
|
56
|
+
)
|
|
57
|
+
from ctxmesh.knowledge import KnowledgeClient
|
|
58
|
+
from ctxmesh.managed import (
|
|
59
|
+
DEFAULT_MAX_STEPS,
|
|
60
|
+
ManagedConfig,
|
|
61
|
+
ManagedResult,
|
|
62
|
+
mint_conversation_id,
|
|
63
|
+
run_managed_loop,
|
|
64
|
+
)
|
|
65
|
+
from ctxmesh.model import ChatResponse, ModelClient
|
|
66
|
+
from ctxmesh.runs import Run, RunEvent, RunsClient
|
|
67
|
+
from ctxmesh.serve import InvokeRequest, serve
|
|
68
|
+
from ctxmesh.skills import Skill
|
|
69
|
+
from ctxmesh.trace import SpanHandle, TraceClient
|
|
70
|
+
|
|
71
|
+
__all__ = [
|
|
72
|
+
"agent",
|
|
73
|
+
"Client",
|
|
74
|
+
"PlaneConfig",
|
|
75
|
+
"CtxmeshError",
|
|
76
|
+
"ConfigError",
|
|
77
|
+
"NotInPodError",
|
|
78
|
+
"EndpointError",
|
|
79
|
+
"GuardrailBlockedError",
|
|
80
|
+
"ModelClient",
|
|
81
|
+
"ChatResponse",
|
|
82
|
+
"Skill",
|
|
83
|
+
"TraceClient",
|
|
84
|
+
"SpanHandle",
|
|
85
|
+
"run_managed_loop",
|
|
86
|
+
"mint_conversation_id",
|
|
87
|
+
"ManagedConfig",
|
|
88
|
+
"ManagedResult",
|
|
89
|
+
"DEFAULT_MAX_STEPS",
|
|
90
|
+
"pause_for_approval",
|
|
91
|
+
"ApprovalRequiredError",
|
|
92
|
+
"RunsClient",
|
|
93
|
+
"Run",
|
|
94
|
+
"RunEvent",
|
|
95
|
+
"serve",
|
|
96
|
+
"InvokeRequest",
|
|
97
|
+
# M68: knowledge-base retrieval
|
|
98
|
+
"KnowledgeClient",
|
|
99
|
+
# M68: multimodal content-parts helpers (ADR 0061 Fork 5)
|
|
100
|
+
"text_part",
|
|
101
|
+
"image_url",
|
|
102
|
+
"content",
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
__version__ = "0.1.0-beta.1"
|
ctxmesh/_approval.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Request-scoped human-in-the-loop approvals (ADR 0034 §HITL, m32.4).
|
|
2
|
+
|
|
3
|
+
A run can gate a sensitive step on human approval. The agent calls
|
|
4
|
+
:func:`pause_for_approval(key, summary)`; if ``key`` is in the set of approvals GRANTED for this
|
|
5
|
+
run, the call returns and the step proceeds — otherwise it raises
|
|
6
|
+
:class:`ctxmesh.errors.ApprovalRequiredError`, which the managed loop turns into a
|
|
7
|
+
``requires_action`` (approval) outcome. When the approver resolves it, the run is re-invoked with
|
|
8
|
+
the approved key in the granted set, so the same ``pause_for_approval`` call now proceeds.
|
|
9
|
+
|
|
10
|
+
The granted set is held in a :class:`contextvars.ContextVar` (PEP 567), **not** a module global —
|
|
11
|
+
the same no-cross-bleed guarantee as the run capability (:mod:`ctxmesh._capability`): each inbound
|
|
12
|
+
request runs in its own execution context, so concurrent runs never observe each other's approvals.
|
|
13
|
+
The only way to bind approvals is :func:`approval_scope`, which RESETS the ContextVar on exit; there
|
|
14
|
+
is no process-wide setter.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from contextlib import contextmanager
|
|
18
|
+
from contextvars import ContextVar
|
|
19
|
+
from typing import FrozenSet, Iterable, Iterator, Mapping, Optional
|
|
20
|
+
|
|
21
|
+
from ctxmesh.errors import ApprovalRequiredError
|
|
22
|
+
|
|
23
|
+
# The approvals granted for the CURRENT run (a set of step keys). Empty ⇒ nothing approved yet, so
|
|
24
|
+
# every pause_for_approval raises.
|
|
25
|
+
_granted_approvals: "ContextVar[FrozenSet[str]]" = ContextVar(
|
|
26
|
+
"ctxmesh_granted_approvals", default=frozenset()
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# ── The stateless approval VOUCHER (ADR 0074 §3, m82.4) ──────────────────────────────────────────
|
|
30
|
+
#
|
|
31
|
+
# require-approval is enforced at the egress WIRE, not just here in the loop: a tool call for a
|
|
32
|
+
# require-approval tool is FORWARDED by the sidecar only when the request carries a valid, signed
|
|
33
|
+
# X-Ctxmesh-Approval voucher (a short-lived token bound to {runId, toolName} the BFF minted on a
|
|
34
|
+
# human's approval). The managed loop's pause_for_approval is now the PRESENTATION UX — necessary
|
|
35
|
+
# but no longer sufficient — and the SDK must RELAY the voucher on the tool-call retry so the
|
|
36
|
+
# sidecar forwards it.
|
|
37
|
+
#
|
|
38
|
+
# The voucher arrives on the RESUMED run's inbound /invoke headers (the BFF stamped
|
|
39
|
+
# X-Ctxmesh-Approval after approval-grant). We hold it request-scoped in a ContextVar and relay it
|
|
40
|
+
# on every outbound tool call — the EXACT sibling of the run-capability relay
|
|
41
|
+
# (:mod:`ctxmesh._capability`) and the record toggle (:mod:`ctxmesh._record`): a launcher-internal
|
|
42
|
+
# header the SDK forwards but never originates. The sidecar's {tool, run} binding means a voucher
|
|
43
|
+
# only unlocks the ONE approved tool; relaying it on every call is safe (a mismatched tool 403s).
|
|
44
|
+
|
|
45
|
+
# The header the BFF stamps on a resumed run and the SDK relays on each tool-call egress — must
|
|
46
|
+
# match runcap.ApprovalHeaderName on the Go side (internal/runcap) + hdrApproval (internal/bff).
|
|
47
|
+
# Case-insensitive on the wire.
|
|
48
|
+
APPROVAL_HEADER = "X-Ctxmesh-Approval"
|
|
49
|
+
|
|
50
|
+
# The request-scoped approval voucher. default=None ⇒ a run with no granted require-approval tool:
|
|
51
|
+
# the tool client relays no voucher and a require-approval tool gets the sidecar's 403.
|
|
52
|
+
_approval_voucher: "ContextVar[Optional[str]]" = ContextVar(
|
|
53
|
+
"ctxmesh_approval_voucher", default=None
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@contextmanager
|
|
58
|
+
def approval_scope(approvals: Optional[Iterable[str]]) -> Iterator[None]:
|
|
59
|
+
"""Bind the set of GRANTED approval keys for the duration of the block, then reset it.
|
|
60
|
+
|
|
61
|
+
Request-scoped: set on entry, RESET on exit, so a reused worker thread can never leak a prior
|
|
62
|
+
run's approvals. ``None`` binds the empty set (nothing approved).
|
|
63
|
+
"""
|
|
64
|
+
token = _granted_approvals.set(frozenset(approvals or ()))
|
|
65
|
+
try:
|
|
66
|
+
yield
|
|
67
|
+
finally:
|
|
68
|
+
_granted_approvals.reset(token)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def pause_for_approval(key: str, summary: str) -> None:
|
|
72
|
+
"""Gate the current step on human approval (human-in-the-loop, m32.4).
|
|
73
|
+
|
|
74
|
+
If ``key`` has been approved for this run (the approver resolved a prior pause), this returns
|
|
75
|
+
and the step proceeds. Otherwise it raises :class:`~ctxmesh.errors.ApprovalRequiredError`, which
|
|
76
|
+
the managed loop surfaces as a ``requires_action`` (approval) outcome carrying ``key`` +
|
|
77
|
+
``summary`` (a human-readable description of what needs approving). The run resumes on approval.
|
|
78
|
+
|
|
79
|
+
``key`` is a STABLE identifier for this decision point (e.g. ``"send-email"``) — the same key
|
|
80
|
+
must be used across the initial call and the resumed re-invoke so the approval matches.
|
|
81
|
+
"""
|
|
82
|
+
if key in _granted_approvals.get():
|
|
83
|
+
return
|
|
84
|
+
raise ApprovalRequiredError(
|
|
85
|
+
f"approval required for {key!r}: {summary}", key=key, summary=summary
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def current_approval_voucher() -> Optional[str]:
|
|
90
|
+
"""Return the approval voucher bound to the CURRENT request context, or ``None``.
|
|
91
|
+
|
|
92
|
+
The tool client relays this on each outbound MCP tool call (the egress sidecar verifies it for a
|
|
93
|
+
require-approval tool). ``None`` outside a resumed/approved run — the sidecar then returns its
|
|
94
|
+
403 ``approval_required`` for a require-approval tool.
|
|
95
|
+
"""
|
|
96
|
+
return _approval_voucher.get()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _extract_voucher(headers: Optional[Mapping[str, str]]) -> Optional[str]:
|
|
100
|
+
"""Pull the approval voucher out of inbound *headers* case-insensitively (HTTP header case is
|
|
101
|
+
not guaranteed), returning ``None`` when absent or blank."""
|
|
102
|
+
if not headers:
|
|
103
|
+
return None
|
|
104
|
+
target = APPROVAL_HEADER.lower()
|
|
105
|
+
for key, value in headers.items():
|
|
106
|
+
if key.lower() == target:
|
|
107
|
+
stripped = (value or "").strip()
|
|
108
|
+
return stripped or None
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@contextmanager
|
|
113
|
+
def voucher_scope(headers: Optional[Mapping[str, str]]) -> Iterator[None]:
|
|
114
|
+
"""Bind the approval voucher extracted from inbound *headers* for the duration of the block,
|
|
115
|
+
then reset it.
|
|
116
|
+
|
|
117
|
+
Request-scoped (set on entry, RESET on exit) so a reused worker thread can never leak a prior
|
|
118
|
+
request's voucher. A missing/blank header binds ``None`` (no granted require-approval tool)."""
|
|
119
|
+
token = _approval_voucher.set(_extract_voucher(headers))
|
|
120
|
+
try:
|
|
121
|
+
yield
|
|
122
|
+
finally:
|
|
123
|
+
_approval_voucher.reset(token)
|
ctxmesh/_capability.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Request-scoped propagation of the run capability (ADR 0030 §3).
|
|
2
|
+
|
|
3
|
+
The run capability is the unforgeable token the BFF mints at ``/invoke`` identifying the
|
|
4
|
+
INVOKING user (its ``sub`` is that user's hashed identity). It arrives on the inbound
|
|
5
|
+
request headers, and the SDK must relay it on every outbound MCP tool call so the egress
|
|
6
|
+
sidecar can resolve *that* user's credential — never another user's.
|
|
7
|
+
|
|
8
|
+
It is held in a :class:`contextvars.ContextVar` (PEP 567), **not** a module global. Each
|
|
9
|
+
inbound request runs in its own execution context — a fresh thread under the entrypoint's
|
|
10
|
+
``ThreadingHTTPServer``, or an ``asyncio`` task — so concurrent requests for different
|
|
11
|
+
users never observe each other's capability. There is no cross-user bleed by construction.
|
|
12
|
+
|
|
13
|
+
There is deliberately **no setter that mutates a process-wide value**: the only way to bind
|
|
14
|
+
a capability is :func:`capability_scope`, which is request-scoped and RESETS the ContextVar
|
|
15
|
+
on exit. :func:`current_capability` only ever reads the current context. This structural
|
|
16
|
+
absence of a global accessor is the no-bleed guarantee (a compromised/injected agent cannot
|
|
17
|
+
reach out and set another request's capability).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from contextlib import contextmanager
|
|
21
|
+
from contextvars import ContextVar
|
|
22
|
+
from typing import Iterator, Mapping, Optional
|
|
23
|
+
|
|
24
|
+
# The header the BFF mints into and the launcher passes through — MUST match
|
|
25
|
+
# runcap.HeaderName on the Go side (internal/runcap). Case-insensitive on the wire.
|
|
26
|
+
CAPABILITY_HEADER = "X-Ctxmesh-Run-Capability"
|
|
27
|
+
|
|
28
|
+
# The request-scoped capability. default=None ⇒ a run with no capability (unattended /
|
|
29
|
+
# minting-disabled) resolves as org/public only, never another user's grant.
|
|
30
|
+
_run_capability: "ContextVar[Optional[str]]" = ContextVar("ctxmesh_run_capability", default=None)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def current_capability() -> Optional[str]:
|
|
34
|
+
"""Return the run capability bound to the CURRENT request context, or ``None``."""
|
|
35
|
+
return _run_capability.get()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _extract(headers: Optional[Mapping[str, str]]) -> Optional[str]:
|
|
39
|
+
"""Pull the capability out of inbound *headers* case-insensitively (HTTP header case
|
|
40
|
+
is not guaranteed), returning ``None`` when absent or blank."""
|
|
41
|
+
if not headers:
|
|
42
|
+
return None
|
|
43
|
+
target = CAPABILITY_HEADER.lower()
|
|
44
|
+
for key, value in headers.items():
|
|
45
|
+
if key.lower() == target:
|
|
46
|
+
stripped = (value or "").strip()
|
|
47
|
+
return stripped or None
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@contextmanager
|
|
52
|
+
def capability_scope(headers: Optional[Mapping[str, str]]) -> Iterator[None]:
|
|
53
|
+
"""Bind the run capability extracted from inbound *headers* for the duration of the
|
|
54
|
+
block, then reset it.
|
|
55
|
+
|
|
56
|
+
Request-scoped: the ContextVar is set on entry and RESET on exit, so even if a worker
|
|
57
|
+
thread is later reused for another request it can never leak a prior request's
|
|
58
|
+
capability. A missing/blank header binds ``None`` (an unattended run).
|
|
59
|
+
"""
|
|
60
|
+
token = _run_capability.set(_extract(headers))
|
|
61
|
+
try:
|
|
62
|
+
yield
|
|
63
|
+
finally:
|
|
64
|
+
_run_capability.reset(token)
|
ctxmesh/_checkpoint.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""L7 supervisor-loop checkpoint (ADR 0091) — the SDK side of the durable delegate suspend/resume.
|
|
2
|
+
|
|
3
|
+
A depth-0 durable supervisor that delegates and suspends serializes its managed-loop state into an
|
|
4
|
+
opaque **payload** string (built here) and returns it in the ``delegate_waiting`` marker. The BFF
|
|
5
|
+
wraps that payload in a hashed **envelope** (``internal/run/checkpoint.go``) and stores it in the
|
|
6
|
+
run's cursor. On resume the worker injects the ENVELOPE back into the invoke body as
|
|
7
|
+
``body["checkpoint"]``; this module re-verifies it (kind / version / hash — defense in depth; the
|
|
8
|
+
worker already verified before injecting) and extracts the payload's fields.
|
|
9
|
+
|
|
10
|
+
The payload carries ONLY loop state — never authorization (consent/approval/OBO), which is
|
|
11
|
+
re-derived server-side on resume (ADR 0091 fork 3). Verification is fail-safe: a corrupt /
|
|
12
|
+
version-skewed envelope or payload yields ``None`` and the caller runs fresh from the request input.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
from typing import Any, Dict, Optional
|
|
21
|
+
|
|
22
|
+
_log = logging.getLogger("ctxmesh.checkpoint")
|
|
23
|
+
|
|
24
|
+
#: The envelope contract shared with the Go store (internal/run/checkpoint.go). Kept in lockstep: a
|
|
25
|
+
#: bump must roll the SDKs first (a resume rejects an unknown version → fail-safe full re-invoke).
|
|
26
|
+
_ENVELOPE_KIND = "supervisor-loop"
|
|
27
|
+
_ENVELOPE_VERSION = 1
|
|
28
|
+
|
|
29
|
+
#: The payload's OWN schema version (independent of the envelope — lets the SDK evolve the
|
|
30
|
+
#: loop-state
|
|
31
|
+
#: shape without touching the store's envelope contract). A resume rejects an unknown payload
|
|
32
|
+
#: version.
|
|
33
|
+
PAYLOAD_VERSION = 1
|
|
34
|
+
|
|
35
|
+
#: Cap on the serialized payload size. The ``delegate_waiting`` marker rides the /invoke response,
|
|
36
|
+
#: which the BFF reads through a 4 MiB LimitReader (internal/bff/invoke.go) — an oversized marker is
|
|
37
|
+
#: silently TRUNCATED, so the loop measures the payload and falls back to blocking dispatch above
|
|
38
|
+
#: this
|
|
39
|
+
#: threshold (a loud, graceful M64 degradation) rather than emitting a truncated, unparseable
|
|
40
|
+
#: marker.
|
|
41
|
+
CHECKPOINT_MAX_BYTES = 1_500_000
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_payload(
|
|
45
|
+
*,
|
|
46
|
+
messages: list,
|
|
47
|
+
step: int,
|
|
48
|
+
pending: list,
|
|
49
|
+
tools_called: list,
|
|
50
|
+
consent_required: list,
|
|
51
|
+
spotlight_token: str,
|
|
52
|
+
model_index: int,
|
|
53
|
+
tool_index: int,
|
|
54
|
+
) -> str:
|
|
55
|
+
"""Serialize the resumable managed-loop state into the opaque payload string.
|
|
56
|
+
|
|
57
|
+
Only the fields that cannot be reconstructed on resume are persisted; everything re-derivable
|
|
58
|
+
(tool manifest, history/memory/knowledge, conversation/message ids, approvals, the per-run
|
|
59
|
+
breaker) is intentionally omitted and rebuilt from headers/body/store on resume.
|
|
60
|
+
"""
|
|
61
|
+
payload: Dict[str, Any] = {
|
|
62
|
+
"v": PAYLOAD_VERSION,
|
|
63
|
+
"messages": messages,
|
|
64
|
+
"step": step,
|
|
65
|
+
"pending": pending,
|
|
66
|
+
"tools_called": tools_called,
|
|
67
|
+
"consent_required": consent_required,
|
|
68
|
+
"spotlight_token": spotlight_token,
|
|
69
|
+
"model_index": model_index,
|
|
70
|
+
"tool_index": tool_index,
|
|
71
|
+
}
|
|
72
|
+
return json.dumps(payload, separators=(",", ":"))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def verify_and_extract(envelope: Any) -> Optional[Dict[str, Any]]:
|
|
76
|
+
"""Verify a checkpoint ENVELOPE and return its payload fields, or ``None`` (fail-safe).
|
|
77
|
+
|
|
78
|
+
*envelope* is ``body["checkpoint"]`` as parsed from the invoke body — the worker injects the Go
|
|
79
|
+
envelope as a raw JSON object, so it normally arrives as a ``dict`` (a JSON string is tolerated
|
|
80
|
+
too). ``None`` is returned — never raised — for any envelope that is absent, malformed, of an
|
|
81
|
+
unknown kind/version, whose payload hash does not match, or whose payload is an unknown version;
|
|
82
|
+
the caller then runs the turn fresh from the request input (the SDK mirror of the worker's
|
|
83
|
+
full-re-invoke fail-safe).
|
|
84
|
+
"""
|
|
85
|
+
if envelope is None:
|
|
86
|
+
return None
|
|
87
|
+
env = envelope
|
|
88
|
+
if isinstance(env, (str, bytes)):
|
|
89
|
+
try:
|
|
90
|
+
env = json.loads(env)
|
|
91
|
+
except (json.JSONDecodeError, ValueError):
|
|
92
|
+
_log.warning("L7 checkpoint: envelope is not valid JSON — running fresh (fail-safe)")
|
|
93
|
+
return None
|
|
94
|
+
if not isinstance(env, dict):
|
|
95
|
+
return None
|
|
96
|
+
if env.get("kind") != _ENVELOPE_KIND or env.get("version") != _ENVELOPE_VERSION:
|
|
97
|
+
_log.warning(
|
|
98
|
+
"L7 checkpoint: unrecognized envelope kind/version — running fresh (fail-safe)"
|
|
99
|
+
)
|
|
100
|
+
return None
|
|
101
|
+
payload_str = env.get("payload")
|
|
102
|
+
if not isinstance(payload_str, str):
|
|
103
|
+
return None
|
|
104
|
+
digest = hashlib.sha256(payload_str.encode()).hexdigest()
|
|
105
|
+
if digest != env.get("sha256"):
|
|
106
|
+
_log.warning(
|
|
107
|
+
"L7 checkpoint: payload hash mismatch (corruption) — running fresh (fail-safe)"
|
|
108
|
+
)
|
|
109
|
+
return None
|
|
110
|
+
try:
|
|
111
|
+
fields = json.loads(payload_str)
|
|
112
|
+
except (json.JSONDecodeError, ValueError):
|
|
113
|
+
return None
|
|
114
|
+
if not isinstance(fields, dict) or fields.get("v") != PAYLOAD_VERSION:
|
|
115
|
+
_log.warning("L7 checkpoint: unknown payload version — running fresh (fail-safe)")
|
|
116
|
+
return None
|
|
117
|
+
return fields
|
ctxmesh/_http.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Minimal stdlib HTTP helper shared by the plane clients.
|
|
2
|
+
|
|
3
|
+
The m10.2 clients depend on nothing but the standard library (``urllib``) so the
|
|
4
|
+
SDK stays lean enough to bundle into base-python and runs on the 3.9 target.
|
|
5
|
+
This module centralises the request/response plumbing and — critically — the
|
|
6
|
+
error translation: a non-2xx response or a transport failure becomes an
|
|
7
|
+
:class:`~ctxmesh.errors.EndpointError` carrying the status, never a swallowed
|
|
8
|
+
error (spec: "surface the launcher endpoint's error rather than swallowing").
|
|
9
|
+
|
|
10
|
+
307/308 redirect handling
|
|
11
|
+
-------------------------
|
|
12
|
+
``urllib`` does not re-POST a body on a 307 response — it raises
|
|
13
|
+
``HTTPError 307``. Our MCP endpoint is registered at ``/mcp/`` (trailing slash)
|
|
14
|
+
and FastMCP/Starlette issues a 307 for ``POST /mcp`` (without the slash). The
|
|
15
|
+
:func:`request` helper therefore catches 307/308 explicitly and re-issues the
|
|
16
|
+
**same method, body, and headers** to the ``Location`` URL. Two guards apply:
|
|
17
|
+
|
|
18
|
+
* **Same-origin only** — the redirect target must share scheme, host, and port
|
|
19
|
+
with the original URL. Re-POSTing a body to a different host is a security
|
|
20
|
+
boundary violation; we raise :class:`~ctxmesh.errors.EndpointError` instead.
|
|
21
|
+
* **Bounded** — at most ``_MAX_REDIRECTS`` consecutive 307/308 hops before we
|
|
22
|
+
raise, preventing infinite redirect loops.
|
|
23
|
+
|
|
24
|
+
301/302/303 are left to urllib's default handling (it converts the method to GET,
|
|
25
|
+
which is correct for those status codes and not expected on the localhost plane).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import urllib.error
|
|
32
|
+
import urllib.parse
|
|
33
|
+
import urllib.request
|
|
34
|
+
from typing import Any, Dict, Iterator, Optional, Tuple
|
|
35
|
+
|
|
36
|
+
from ctxmesh.errors import EndpointError
|
|
37
|
+
|
|
38
|
+
#: Default per-op timeout (seconds). Matches the launcher's own 2s Valkey bound
|
|
39
|
+
#: for the memory path; callers may override for slower endpoints.
|
|
40
|
+
DEFAULT_TIMEOUT = 5.0
|
|
41
|
+
|
|
42
|
+
#: SSE read timeout (seconds, FUNC-7). A long-lived event stream (runs.stream) may idle
|
|
43
|
+
#: between events for a slow model turn or a run parked at requires_action — far longer
|
|
44
|
+
#: than DEFAULT_TIMEOUT. The server heartbeats well within this window, so a live-but-idle
|
|
45
|
+
#: stream never trips it; a genuinely dead connection still errors after it (not never).
|
|
46
|
+
STREAM_READ_TIMEOUT = 120.0
|
|
47
|
+
|
|
48
|
+
#: Maximum number of 307/308 hops before we give up to prevent infinite loops.
|
|
49
|
+
_MAX_REDIRECTS = 3
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _origin(url: str) -> Tuple[str, str, int]:
|
|
53
|
+
"""Return (scheme, host, port) for *url*, normalising the default port."""
|
|
54
|
+
parsed = urllib.parse.urlparse(url)
|
|
55
|
+
scheme = parsed.scheme.lower()
|
|
56
|
+
host = parsed.hostname or ""
|
|
57
|
+
# urllib.parse returns None for the default port; normalise to the scheme's.
|
|
58
|
+
if parsed.port is not None:
|
|
59
|
+
port = parsed.port
|
|
60
|
+
elif scheme == "https":
|
|
61
|
+
port = 443
|
|
62
|
+
else:
|
|
63
|
+
port = 80
|
|
64
|
+
return scheme, host, port
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Response:
|
|
68
|
+
"""A thin view over an HTTP response the clients care about."""
|
|
69
|
+
|
|
70
|
+
__slots__ = ("status", "headers", "body")
|
|
71
|
+
|
|
72
|
+
def __init__(self, status: int, headers: Dict[str, str], body: bytes):
|
|
73
|
+
self.status = status
|
|
74
|
+
self.headers = headers
|
|
75
|
+
self.body = body
|
|
76
|
+
|
|
77
|
+
def json(self) -> Any:
|
|
78
|
+
"""Decode the body as JSON, or raise EndpointError on malformed JSON."""
|
|
79
|
+
if not self.body:
|
|
80
|
+
return None
|
|
81
|
+
try:
|
|
82
|
+
return json.loads(self.body)
|
|
83
|
+
except json.JSONDecodeError as exc:
|
|
84
|
+
raise EndpointError(
|
|
85
|
+
f"endpoint returned malformed JSON: {exc}", status=self.status
|
|
86
|
+
) from exc
|
|
87
|
+
|
|
88
|
+
def text(self) -> str:
|
|
89
|
+
return self.body.decode("utf-8", errors="replace")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def request(
|
|
93
|
+
method: str,
|
|
94
|
+
url: str,
|
|
95
|
+
*,
|
|
96
|
+
body: Optional[bytes] = None,
|
|
97
|
+
headers: Optional[Dict[str, str]] = None,
|
|
98
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
99
|
+
expect: Optional[Tuple[int, ...]] = None,
|
|
100
|
+
) -> Response:
|
|
101
|
+
"""Perform an HTTP request and return a :class:`Response`.
|
|
102
|
+
|
|
103
|
+
307/308 redirects are followed by re-issuing the **same method and body**
|
|
104
|
+
to the ``Location`` URL. Cross-origin redirects and redirect loops (more
|
|
105
|
+
than ``_MAX_REDIRECTS`` hops) are refused with an :class:`EndpointError`.
|
|
106
|
+
|
|
107
|
+
Raises :class:`EndpointError` when:
|
|
108
|
+
* the transport fails (connection refused / timeout) — ``status`` is None; or
|
|
109
|
+
* a 307/308 redirect is cross-origin or exceeds the hop limit; or
|
|
110
|
+
* ``expect`` is given and the response status is not in it — ``status`` is
|
|
111
|
+
the actual code so the caller can distinguish 400 from 502.
|
|
112
|
+
"""
|
|
113
|
+
original_origin = _origin(url)
|
|
114
|
+
current_url = url
|
|
115
|
+
hops = 0
|
|
116
|
+
|
|
117
|
+
while True:
|
|
118
|
+
req = urllib.request.Request(current_url, data=body, method=method) # noqa: S310
|
|
119
|
+
for key, value in (headers or {}).items():
|
|
120
|
+
req.add_header(key, value)
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
|
|
124
|
+
resp_headers = {k.lower(): v for k, v in resp.headers.items()}
|
|
125
|
+
response = Response(resp.status, resp_headers, resp.read())
|
|
126
|
+
except urllib.error.HTTPError as exc:
|
|
127
|
+
if exc.code in (307, 308):
|
|
128
|
+
# urllib raises HTTPError for 307/308 instead of following the
|
|
129
|
+
# redirect. Re-issue the same method and body to the Location.
|
|
130
|
+
location = exc.headers.get("Location") or exc.headers.get("location")
|
|
131
|
+
if not location:
|
|
132
|
+
raise EndpointError(
|
|
133
|
+
f"{method} {current_url} returned {exc.code} with no Location header",
|
|
134
|
+
status=exc.code,
|
|
135
|
+
) from exc
|
|
136
|
+
# Resolve relative Location against the current URL.
|
|
137
|
+
redirect_url = urllib.parse.urljoin(current_url, location)
|
|
138
|
+
if _origin(redirect_url) != original_origin:
|
|
139
|
+
raise EndpointError(
|
|
140
|
+
f"{method} {current_url} returned {exc.code} to a "
|
|
141
|
+
f"cross-origin location {redirect_url!r}; refusing to "
|
|
142
|
+
f"re-POST body to a different host (security boundary)",
|
|
143
|
+
status=exc.code,
|
|
144
|
+
) from exc
|
|
145
|
+
hops += 1
|
|
146
|
+
if hops > _MAX_REDIRECTS:
|
|
147
|
+
raise EndpointError(
|
|
148
|
+
f"{method} {url} exceeded {_MAX_REDIRECTS} redirects "
|
|
149
|
+
f"(last location: {redirect_url!r})",
|
|
150
|
+
status=exc.code,
|
|
151
|
+
) from exc
|
|
152
|
+
current_url = redirect_url
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
# A non-2xx with a response body (e.g. feedback 400/502). Read the
|
|
156
|
+
# body so the caller can see the endpoint's error message, and
|
|
157
|
+
# surface the status — never swallow it.
|
|
158
|
+
err_body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
|
|
159
|
+
raise EndpointError(
|
|
160
|
+
f"{method} {url} returned {exc.code}: {err_body.strip()}",
|
|
161
|
+
status=exc.code,
|
|
162
|
+
body=err_body,
|
|
163
|
+
) from exc
|
|
164
|
+
except (urllib.error.URLError, OSError, TimeoutError) as exc:
|
|
165
|
+
# Transport-level failure: no HTTP status. Includes connection refused
|
|
166
|
+
# (endpoint not wired / down) and timeouts.
|
|
167
|
+
raise EndpointError(f"{method} {url} failed: {exc}", status=None) from exc
|
|
168
|
+
|
|
169
|
+
break # 2xx (or unexpected status) — exit the redirect loop
|
|
170
|
+
|
|
171
|
+
if expect is not None and response.status not in expect:
|
|
172
|
+
raise EndpointError(
|
|
173
|
+
f"{method} {url} returned {response.status} "
|
|
174
|
+
f"(expected {', '.join(str(s) for s in expect)}): {response.text().strip()}",
|
|
175
|
+
status=response.status,
|
|
176
|
+
body=response.text(),
|
|
177
|
+
)
|
|
178
|
+
return response
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def stream(
|
|
182
|
+
method: str,
|
|
183
|
+
url: str,
|
|
184
|
+
*,
|
|
185
|
+
body: Optional[bytes] = None,
|
|
186
|
+
headers: Optional[Dict[str, str]] = None,
|
|
187
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
188
|
+
) -> Iterator[str]:
|
|
189
|
+
"""POST and yield the response body LINE BY LINE (for Server-Sent Events).
|
|
190
|
+
|
|
191
|
+
Unlike :func:`request` this does NOT buffer the whole body — it streams, so a token
|
|
192
|
+
stream is delivered as it arrives. No redirect following (streaming endpoints are
|
|
193
|
+
same-origin: the model gateway, the agent's own /invoke). Raises :class:`EndpointError`
|
|
194
|
+
on a non-2xx (with ``status`` + the error body) or a transport failure (``status`` None) —
|
|
195
|
+
never swallows the failure.
|
|
196
|
+
"""
|
|
197
|
+
req = urllib.request.Request(url, data=body, method=method) # noqa: S310
|
|
198
|
+
for key, value in (headers or {}).items():
|
|
199
|
+
req.add_header(key, value)
|
|
200
|
+
try:
|
|
201
|
+
resp = urllib.request.urlopen(req, timeout=timeout) # noqa: S310
|
|
202
|
+
except urllib.error.HTTPError as exc:
|
|
203
|
+
err_body = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
|
|
204
|
+
raise EndpointError(
|
|
205
|
+
f"{method} {url} returned {exc.code}: {err_body.strip()}",
|
|
206
|
+
status=exc.code,
|
|
207
|
+
body=err_body,
|
|
208
|
+
) from exc
|
|
209
|
+
except (urllib.error.URLError, OSError, TimeoutError) as exc:
|
|
210
|
+
raise EndpointError(f"{method} {url} failed: {exc}", status=None) from exc
|
|
211
|
+
|
|
212
|
+
# The read loop is INSIDE its own try/except (FUNC-7): urllib's `timeout` is a
|
|
213
|
+
# per-socket-op timeout, so a mid-stream gap between SSE events raised a RAW
|
|
214
|
+
# socket TimeoutError here — outside the connect try/except above — killing any
|
|
215
|
+
# stream with a >timeout idle gap (a slow model turn, or a run parked at
|
|
216
|
+
# requires_action). Translate it (and any transport error) to EndpointError so the
|
|
217
|
+
# caller sees the SDK's typed error, never a bare TimeoutError. SSE callers pass a
|
|
218
|
+
# long read timeout (STREAM_READ_TIMEOUT) and the server heartbeats within it.
|
|
219
|
+
try:
|
|
220
|
+
with resp:
|
|
221
|
+
for raw in resp:
|
|
222
|
+
yield raw.decode("utf-8", errors="replace").rstrip("\r\n")
|
|
223
|
+
except (urllib.error.URLError, OSError, TimeoutError) as exc:
|
|
224
|
+
raise EndpointError(f"{method} {url} stream interrupted: {exc}", status=None) from exc
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def json_body(value: Any) -> bytes:
|
|
228
|
+
"""Serialise a value as compact JSON bytes (matches the launcher contract)."""
|
|
229
|
+
return json.dumps(value, separators=(",", ":")).encode("utf-8")
|