ctxmesh 0.1.0b1__tar.gz

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.
Files changed (55) hide show
  1. ctxmesh-0.1.0b1/PKG-INFO +157 -0
  2. ctxmesh-0.1.0b1/README.md +133 -0
  3. ctxmesh-0.1.0b1/pyproject.toml +106 -0
  4. ctxmesh-0.1.0b1/setup.cfg +4 -0
  5. ctxmesh-0.1.0b1/src/ctxmesh/__init__.py +105 -0
  6. ctxmesh-0.1.0b1/src/ctxmesh/_approval.py +123 -0
  7. ctxmesh-0.1.0b1/src/ctxmesh/_capability.py +64 -0
  8. ctxmesh-0.1.0b1/src/ctxmesh/_checkpoint.py +117 -0
  9. ctxmesh-0.1.0b1/src/ctxmesh/_http.py +229 -0
  10. ctxmesh-0.1.0b1/src/ctxmesh/_multimodal.py +98 -0
  11. ctxmesh-0.1.0b1/src/ctxmesh/_record.py +61 -0
  12. ctxmesh-0.1.0b1/src/ctxmesh/_semconv.py +43 -0
  13. ctxmesh-0.1.0b1/src/ctxmesh/_tracing.py +196 -0
  14. ctxmesh-0.1.0b1/src/ctxmesh/agent.py +45 -0
  15. ctxmesh-0.1.0b1/src/ctxmesh/client.py +119 -0
  16. ctxmesh-0.1.0b1/src/ctxmesh/config.py +225 -0
  17. ctxmesh-0.1.0b1/src/ctxmesh/errors.py +132 -0
  18. ctxmesh-0.1.0b1/src/ctxmesh/feedback.py +74 -0
  19. ctxmesh-0.1.0b1/src/ctxmesh/knowledge.py +178 -0
  20. ctxmesh-0.1.0b1/src/ctxmesh/managed.py +2205 -0
  21. ctxmesh-0.1.0b1/src/ctxmesh/memory.py +196 -0
  22. ctxmesh-0.1.0b1/src/ctxmesh/mesh.py +100 -0
  23. ctxmesh-0.1.0b1/src/ctxmesh/model.py +504 -0
  24. ctxmesh-0.1.0b1/src/ctxmesh/py.typed +0 -0
  25. ctxmesh-0.1.0b1/src/ctxmesh/runs.py +216 -0
  26. ctxmesh-0.1.0b1/src/ctxmesh/serve.py +338 -0
  27. ctxmesh-0.1.0b1/src/ctxmesh/skills.py +153 -0
  28. ctxmesh-0.1.0b1/src/ctxmesh/testing.py +526 -0
  29. ctxmesh-0.1.0b1/src/ctxmesh/tools.py +890 -0
  30. ctxmesh-0.1.0b1/src/ctxmesh/trace.py +315 -0
  31. ctxmesh-0.1.0b1/src/ctxmesh.egg-info/PKG-INFO +157 -0
  32. ctxmesh-0.1.0b1/src/ctxmesh.egg-info/SOURCES.txt +53 -0
  33. ctxmesh-0.1.0b1/src/ctxmesh.egg-info/dependency_links.txt +1 -0
  34. ctxmesh-0.1.0b1/src/ctxmesh.egg-info/requires.txt +8 -0
  35. ctxmesh-0.1.0b1/src/ctxmesh.egg-info/top_level.txt +1 -0
  36. ctxmesh-0.1.0b1/tests/test_approval.py +59 -0
  37. ctxmesh-0.1.0b1/tests/test_approval_voucher.py +111 -0
  38. ctxmesh-0.1.0b1/tests/test_capability.py +115 -0
  39. ctxmesh-0.1.0b1/tests/test_checkpoint.py +89 -0
  40. ctxmesh-0.1.0b1/tests/test_config.py +61 -0
  41. ctxmesh-0.1.0b1/tests/test_consent.py +43 -0
  42. ctxmesh-0.1.0b1/tests/test_delegate_suspend_loop.py +296 -0
  43. ctxmesh-0.1.0b1/tests/test_feedback.py +76 -0
  44. ctxmesh-0.1.0b1/tests/test_guardrail_sdk.py +215 -0
  45. ctxmesh-0.1.0b1/tests/test_knowledge.py +544 -0
  46. ctxmesh-0.1.0b1/tests/test_m156_audit_fixes.py +118 -0
  47. ctxmesh-0.1.0b1/tests/test_managed.py +2813 -0
  48. ctxmesh-0.1.0b1/tests/test_memory.py +125 -0
  49. ctxmesh-0.1.0b1/tests/test_model.py +374 -0
  50. ctxmesh-0.1.0b1/tests/test_runs.py +226 -0
  51. ctxmesh-0.1.0b1/tests/test_serve.py +459 -0
  52. ctxmesh-0.1.0b1/tests/test_testing.py +52 -0
  53. ctxmesh-0.1.0b1/tests/test_tools.py +610 -0
  54. ctxmesh-0.1.0b1/tests/test_trace.py +163 -0
  55. ctxmesh-0.1.0b1/tests/test_trace_context.py +171 -0
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.4
2
+ Name: ctxmesh
3
+ Version: 0.1.0b1
4
+ Summary: Typed Python SDK over the ctxmesh launcher localhost plane (memory / tools / feedback).
5
+ Author: ctxmesh
6
+ License: Apache-2.0
7
+ Keywords: ctxmesh,ctxmesh,llm,agents,observability
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.9
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: opentelemetry-sdk~=1.27
18
+ Requires-Dist: opentelemetry-exporter-otlp-proto-grpc~=1.27
19
+ Requires-Dist: openinference-semantic-conventions~=0.1.30
20
+ Requires-Dist: jsonschema<5,>=4.20
21
+ Provides-Extra: dev
22
+ Requires-Dist: ruff==0.6.9; extra == "dev"
23
+ Requires-Dist: pytest==8.3.3; extra == "dev"
24
+
25
+ # ctxmesh — ctxmesh Python SDK
26
+
27
+ Optional, typed sugar over the launcher's language-agnostic localhost platform
28
+ plane (ADR 0002). Bundled into `base-python`, importable as `ctxmesh`. Never a
29
+ hard dependency: every capability it exposes is *also* a raw launcher endpoint.
30
+
31
+ - **Distribution name:** `ctxmesh` · **import name:** `ctxmesh`
32
+ - **Python:** 3.9+ · **runtime deps:** the plane clients are pure stdlib; the
33
+ model + step-tracing helpers add a minimal OTLP/gRPC exporter +
34
+ OpenInference semantic-convention constants, **pinned to the exact versions
35
+ `images/base-python` already bundles** (opentelemetry `1.27.0`,
36
+ openinference-semantic-conventions `0.1.30`) — so `import ctxmesh` adds **zero
37
+ net footprint** to the base image.
38
+
39
+ ## Surface
40
+
41
+ ```python
42
+ from ctxmesh import agent
43
+
44
+ client = agent.from_env() # in-pod: reads the launcher-injected env
45
+
46
+ # memory (:2998, M5)
47
+ client.memory.get() # full context (list)
48
+ client.memory.put([{"role": "user", "content": "hi"}])
49
+ client.memory.append({"role": "assistant", "content": "hey"})
50
+ client.memory.search("hi")
51
+ # bind a conversationId for the turn (the agent reads it from the request):
52
+ turn = client.with_conversation("conv-42")
53
+ turn.memory.get()
54
+
55
+ # tools / discovery (:2999, M4)
56
+ client.tools.list() # live manifest as Tool objects
57
+ client.tools.call("word-count", text="a b c") # MCP tools/call
58
+
59
+ # feedback (:2995, M9)
60
+ client.feedback.score("trace-abc", "thumbs-up", 1, comment="great")
61
+
62
+ # model gateway ($MODEL_GATEWAY_URL, M2/M8) — emits an OpenInference LLM span
63
+ resp = client.model.chat("gpt-4o-mini", [{"role": "user", "content": "q"}])
64
+ resp.text # the completion; resp.usage → token counts; resp.raw → full body
65
+ ```
66
+
67
+ `agent.from_env()` **fails fast** (`NotInPodError`) when no launcher env is
68
+ present — it never silently no-ops. For tests / offline use, build a
69
+ `PlaneConfig` explicitly (`PlaneConfig.for_test(...)`) and call
70
+ `agent.from_config(config)` against a fake localhost plane.
71
+
72
+ ## Step-tracing helpers (custom loops)
73
+
74
+ A framework agent (LangChain/OpenAI/Anthropic) gets its `step → tool → model`
75
+ trace tree SDK-free via base-image OpenInference auto-instrumentation. A **custom,
76
+ no-framework loop** has no inferable step boundaries, so it emits the tree
77
+ explicitly with `client.trace.*` — producing an OpenInference tree
78
+ **structurally identical** to a framework one (same `CHAIN`/`TOOL`/`LLM` span
79
+ kinds + attribute keys), exported over the same OTLP/gRPC path to the collector
80
+ (`:4317`) → Langfuse.
81
+
82
+ ```python
83
+ # Bind the inbound /invoke request so the WHOLE tree roots under the launcher's
84
+ # `agent.invoke` span (the launcher injected a W3C `traceparent`). Without this
85
+ # bind the SDK spans would start a detached trace.
86
+ with client.trace.request_context(request.headers):
87
+ with client.trace.step("plan") as step: # CHAIN span
88
+ step.set_input(user_prompt)
89
+ plan = client.model.chat(model, messages) # nested LLM span (auto)
90
+ with client.trace.tool("web_search", args) as t: # TOOL span (child of step)
91
+ t.set_output(client.tools.call("web_search", **args))
92
+ step.set_output(plan.text)
93
+ ```
94
+
95
+ `client.trace.loop(name, headers=request.headers)` is a convenience that binds
96
+ the request context **and** opens the `AGENT` loop-root span in one `with`.
97
+
98
+ **Rooting under `agent.invoke` is the invariant** — the SDK extracts the W3C
99
+ `traceparent` the launcher proxy injects on every `/invoke` and makes the step
100
+ spans children of the launcher's `agent.invoke` root (same trace id, correct
101
+ parent span id), not a detached trace.
102
+
103
+ **Offline / telemetry resilience:** when `OTEL_EXPORTER_OTLP_ENDPOINT` is unset
104
+ (offline/tests) the trace client runs in **no-op export** mode — spans are still
105
+ created (so nesting/propagation still works and can be asserted) but exported
106
+ nowhere. Export/setup failures degrade to no-op and **never crash the loop** (a
107
+ telemetry blip is not an error); this is deliberately distinct from the plane
108
+ clients, which *surface* endpoint errors (a rejected memory write is a real
109
+ error).
110
+
111
+ ## Serving an agent (`ctxmesh.serve`)
112
+
113
+ You don't hand-roll the HTTP server, the `/invoke` body/​envelope, the
114
+ `/healthz`/`/readyz` probes, the `$AGENT_PORT` the launcher proxies to, the
115
+ `traceparent` capture, or the SSE token stream — `ctxmesh.serve` encodes the whole
116
+ runtime contract, and it binds `request_scope` for you (so a custom loop keeps the
117
+ invoking user's run capability instead of silently downgrading to org/public creds):
118
+
119
+ ```python
120
+ import ctxmesh
121
+
122
+ def handle(req: ctxmesh.InvokeRequest) -> str:
123
+ # req.client is scoped to the caller (capability + granted approvals bound) and
124
+ # conversation-aware (req.conversation_id); req.headers roots the trace.
125
+ with req.client.trace.loop("my-agent", headers=req.headers):
126
+ answer = req.client.model.chat(
127
+ "gpt-4o-mini", [{"role": "user", "content": req.input}]
128
+ )
129
+ return answer.text # or return a ctxmesh.ManagedResult for steps/tools/approval
130
+
131
+ ctxmesh.serve(handle) # blocks; serves /invoke + health on $AGENT_PORT
132
+ ```
133
+
134
+ - **Streaming** is transparent: call `req.emit_token(delta)` as your loop produces
135
+ content — it emits an SSE `token` frame when the caller sent
136
+ `Accept: text/event-stream`, and is a no-op otherwise (same handler, both modes).
137
+ - **The stock managed agent** is just `ctxmesh.serve()` with **no** handler — it runs
138
+ the config-driven tool-calling loop (`run_managed_loop`) with `ManagedConfig.from_env()`.
139
+ That is exactly the managed-agent image's entrypoint.
140
+ - `serve(handler, *, client=…, agent_name=…, port=…)` overrides the env defaults
141
+ (`agent.from_env()` / `$AGENT_NAME` / `$AGENT_PORT`) — handy for local runs and tests.
142
+
143
+ `examples/sdk-custom-agent` shows the same loop with the HTTP handler written out by
144
+ hand — the "under the hood" reference for what `serve` collapses into one call.
145
+
146
+ ## Dev
147
+
148
+ The toolchain (ruff + pytest, pinned) is wired into the engine `Makefile`:
149
+
150
+ ```
151
+ make py-venv # create .venv-sdk with pinned ruff+pytest (from host python3)
152
+ make lint # go lint + ruff (sdk/python)
153
+ make test # go unit tests + pytest (sdk/python)
154
+ ```
155
+
156
+ Pins live in `requirements-dev.txt` (mirrored in the `dev` extra of
157
+ `pyproject.toml`).
@@ -0,0 +1,133 @@
1
+ # ctxmesh — ctxmesh Python SDK
2
+
3
+ Optional, typed sugar over the launcher's language-agnostic localhost platform
4
+ plane (ADR 0002). Bundled into `base-python`, importable as `ctxmesh`. Never a
5
+ hard dependency: every capability it exposes is *also* a raw launcher endpoint.
6
+
7
+ - **Distribution name:** `ctxmesh` · **import name:** `ctxmesh`
8
+ - **Python:** 3.9+ · **runtime deps:** the plane clients are pure stdlib; the
9
+ model + step-tracing helpers add a minimal OTLP/gRPC exporter +
10
+ OpenInference semantic-convention constants, **pinned to the exact versions
11
+ `images/base-python` already bundles** (opentelemetry `1.27.0`,
12
+ openinference-semantic-conventions `0.1.30`) — so `import ctxmesh` adds **zero
13
+ net footprint** to the base image.
14
+
15
+ ## Surface
16
+
17
+ ```python
18
+ from ctxmesh import agent
19
+
20
+ client = agent.from_env() # in-pod: reads the launcher-injected env
21
+
22
+ # memory (:2998, M5)
23
+ client.memory.get() # full context (list)
24
+ client.memory.put([{"role": "user", "content": "hi"}])
25
+ client.memory.append({"role": "assistant", "content": "hey"})
26
+ client.memory.search("hi")
27
+ # bind a conversationId for the turn (the agent reads it from the request):
28
+ turn = client.with_conversation("conv-42")
29
+ turn.memory.get()
30
+
31
+ # tools / discovery (:2999, M4)
32
+ client.tools.list() # live manifest as Tool objects
33
+ client.tools.call("word-count", text="a b c") # MCP tools/call
34
+
35
+ # feedback (:2995, M9)
36
+ client.feedback.score("trace-abc", "thumbs-up", 1, comment="great")
37
+
38
+ # model gateway ($MODEL_GATEWAY_URL, M2/M8) — emits an OpenInference LLM span
39
+ resp = client.model.chat("gpt-4o-mini", [{"role": "user", "content": "q"}])
40
+ resp.text # the completion; resp.usage → token counts; resp.raw → full body
41
+ ```
42
+
43
+ `agent.from_env()` **fails fast** (`NotInPodError`) when no launcher env is
44
+ present — it never silently no-ops. For tests / offline use, build a
45
+ `PlaneConfig` explicitly (`PlaneConfig.for_test(...)`) and call
46
+ `agent.from_config(config)` against a fake localhost plane.
47
+
48
+ ## Step-tracing helpers (custom loops)
49
+
50
+ A framework agent (LangChain/OpenAI/Anthropic) gets its `step → tool → model`
51
+ trace tree SDK-free via base-image OpenInference auto-instrumentation. A **custom,
52
+ no-framework loop** has no inferable step boundaries, so it emits the tree
53
+ explicitly with `client.trace.*` — producing an OpenInference tree
54
+ **structurally identical** to a framework one (same `CHAIN`/`TOOL`/`LLM` span
55
+ kinds + attribute keys), exported over the same OTLP/gRPC path to the collector
56
+ (`:4317`) → Langfuse.
57
+
58
+ ```python
59
+ # Bind the inbound /invoke request so the WHOLE tree roots under the launcher's
60
+ # `agent.invoke` span (the launcher injected a W3C `traceparent`). Without this
61
+ # bind the SDK spans would start a detached trace.
62
+ with client.trace.request_context(request.headers):
63
+ with client.trace.step("plan") as step: # CHAIN span
64
+ step.set_input(user_prompt)
65
+ plan = client.model.chat(model, messages) # nested LLM span (auto)
66
+ with client.trace.tool("web_search", args) as t: # TOOL span (child of step)
67
+ t.set_output(client.tools.call("web_search", **args))
68
+ step.set_output(plan.text)
69
+ ```
70
+
71
+ `client.trace.loop(name, headers=request.headers)` is a convenience that binds
72
+ the request context **and** opens the `AGENT` loop-root span in one `with`.
73
+
74
+ **Rooting under `agent.invoke` is the invariant** — the SDK extracts the W3C
75
+ `traceparent` the launcher proxy injects on every `/invoke` and makes the step
76
+ spans children of the launcher's `agent.invoke` root (same trace id, correct
77
+ parent span id), not a detached trace.
78
+
79
+ **Offline / telemetry resilience:** when `OTEL_EXPORTER_OTLP_ENDPOINT` is unset
80
+ (offline/tests) the trace client runs in **no-op export** mode — spans are still
81
+ created (so nesting/propagation still works and can be asserted) but exported
82
+ nowhere. Export/setup failures degrade to no-op and **never crash the loop** (a
83
+ telemetry blip is not an error); this is deliberately distinct from the plane
84
+ clients, which *surface* endpoint errors (a rejected memory write is a real
85
+ error).
86
+
87
+ ## Serving an agent (`ctxmesh.serve`)
88
+
89
+ You don't hand-roll the HTTP server, the `/invoke` body/​envelope, the
90
+ `/healthz`/`/readyz` probes, the `$AGENT_PORT` the launcher proxies to, the
91
+ `traceparent` capture, or the SSE token stream — `ctxmesh.serve` encodes the whole
92
+ runtime contract, and it binds `request_scope` for you (so a custom loop keeps the
93
+ invoking user's run capability instead of silently downgrading to org/public creds):
94
+
95
+ ```python
96
+ import ctxmesh
97
+
98
+ def handle(req: ctxmesh.InvokeRequest) -> str:
99
+ # req.client is scoped to the caller (capability + granted approvals bound) and
100
+ # conversation-aware (req.conversation_id); req.headers roots the trace.
101
+ with req.client.trace.loop("my-agent", headers=req.headers):
102
+ answer = req.client.model.chat(
103
+ "gpt-4o-mini", [{"role": "user", "content": req.input}]
104
+ )
105
+ return answer.text # or return a ctxmesh.ManagedResult for steps/tools/approval
106
+
107
+ ctxmesh.serve(handle) # blocks; serves /invoke + health on $AGENT_PORT
108
+ ```
109
+
110
+ - **Streaming** is transparent: call `req.emit_token(delta)` as your loop produces
111
+ content — it emits an SSE `token` frame when the caller sent
112
+ `Accept: text/event-stream`, and is a no-op otherwise (same handler, both modes).
113
+ - **The stock managed agent** is just `ctxmesh.serve()` with **no** handler — it runs
114
+ the config-driven tool-calling loop (`run_managed_loop`) with `ManagedConfig.from_env()`.
115
+ That is exactly the managed-agent image's entrypoint.
116
+ - `serve(handler, *, client=…, agent_name=…, port=…)` overrides the env defaults
117
+ (`agent.from_env()` / `$AGENT_NAME` / `$AGENT_PORT`) — handy for local runs and tests.
118
+
119
+ `examples/sdk-custom-agent` shows the same loop with the HTTP handler written out by
120
+ hand — the "under the hood" reference for what `serve` collapses into one call.
121
+
122
+ ## Dev
123
+
124
+ The toolchain (ruff + pytest, pinned) is wired into the engine `Makefile`:
125
+
126
+ ```
127
+ make py-venv # create .venv-sdk with pinned ruff+pytest (from host python3)
128
+ make lint # go lint + ruff (sdk/python)
129
+ make test # go unit tests + pytest (sdk/python)
130
+ ```
131
+
132
+ Pins live in `requirements-dev.txt` (mirrored in the `dev` extra of
133
+ `pyproject.toml`).
@@ -0,0 +1,106 @@
1
+ # ctxmesh — the ctxmesh Python SDK.
2
+ #
3
+ # Distribution name: "ctxmesh"; import name: "ctxmesh". Both match so
4
+ # `pip install ctxmesh` gives `import ctxmesh` with zero surprises (spec
5
+ # "Packaging & base-image bundling").
6
+ #
7
+ # Typed sugar over the launcher's language-agnostic localhost platform plane
8
+ # (ADR 0002): memory (:2998), tools/discovery (:2999), feedback (:2995), the
9
+ # model gateway ($MODEL_GATEWAY_URL), and the step-tracing helpers (OTLP :4317).
10
+ # The SDK is NEVER a hard dependency. The plane clients are pure stdlib (urllib);
11
+ # the m10.3 model + trace helpers add a MINIMAL OTLP/gRPC exporter + OpenInference
12
+ # semantic-convention constants — pinned to the EXACT versions already bundled in
13
+ # images/base-python (opentelemetry 1.27.0 + openinference-semantic-conventions
14
+ # 0.1.30), so the SDK adds ZERO net footprint to base-python and stays 3.9-compatible.
15
+ [build-system]
16
+ requires = ["setuptools>=61.0"]
17
+ build-backend = "setuptools.build_meta"
18
+
19
+ [project]
20
+ name = "ctxmesh"
21
+ version = "0.1.0-beta.1"
22
+ description = "Typed Python SDK over the ctxmesh launcher localhost plane (memory / tools / feedback)."
23
+ readme = "README.md"
24
+ # 3.9 is the floor because the agent base image still ships it; the classifiers below
25
+ # state the range actually supported. A library that claims only 3.9 reads as abandoned to
26
+ # anyone running a current interpreter, whether or not it works there.
27
+ requires-python = ">=3.9"
28
+ license = { text = "Apache-2.0" }
29
+ authors = [{ name = "ctxmesh" }]
30
+ keywords = ["ctxmesh", "ctxmesh", "llm", "agents", "observability"]
31
+ classifiers = [
32
+ "Programming Language :: Python :: 3",
33
+ "Programming Language :: Python :: 3.9",
34
+ "Programming Language :: Python :: 3.10",
35
+ "Programming Language :: Python :: 3.11",
36
+ "Programming Language :: Python :: 3.12",
37
+ "Programming Language :: Python :: 3.13",
38
+ "License :: OSI Approved :: Apache Software License",
39
+ ]
40
+ # Runtime deps for the m10.3 model + step-tracing helpers. Pinned to the exact
41
+ # versions images/base-python already bundles, so `import ctxmesh` adds nothing
42
+ # net to the base image (the plane clients themselves remain pure stdlib):
43
+ # * opentelemetry-sdk + the OTLP/gRPC exporter — export step spans to the
44
+ # collector (:4317) over the SAME path as auto-instrumentation → Langfuse.
45
+ # * openinference-semantic-conventions — the CHAIN/TOOL/LLM span kinds + attr
46
+ # keys, so a custom-loop tree is byte-for-byte a framework tree.
47
+ # All three install cleanly and run on the 3.9 target (host python 3.9.6).
48
+ # RANGES, not pins. This is a LIBRARY: it is installed alongside the user's own code, and
49
+ # an exact pin on opentelemetry-sdk makes ctxmesh uninstallable next to any application that
50
+ # already has a different OTel — which is most applications that would want us. An
51
+ # application pins; a library states the range it works across and lets the resolver decide.
52
+ #
53
+ # Compatible-release (~=) on the OTel packages: they move together and have broken minor to
54
+ # minor before, so the ceiling is deliberate rather than optimistic. jsonschema has a stable
55
+ # 4.x API and gets the wider range.
56
+ #
57
+ # The images that BUNDLE this SDK pin exact versions in their own requirements.txt — that is
58
+ # where pinning belongs, because an image IS an application.
59
+ dependencies = [
60
+ "opentelemetry-sdk~=1.27",
61
+ "opentelemetry-exporter-otlp-proto-grpc~=1.27",
62
+ "openinference-semantic-conventions~=0.1.30",
63
+ "jsonschema>=4.20,<5",
64
+ ]
65
+
66
+ [project.optional-dependencies]
67
+ # Pinned dev toolchain — reproducible on a clean host / CI (no host-global
68
+ # installs). Bootstrapped into a venv by `make py-venv` (see the repo Makefile).
69
+ # ruff 0.6.9 and pytest 8.3.x both support Python 3.9 (the host is 3.9.6).
70
+ dev = [
71
+ "ruff==0.6.9",
72
+ "pytest==8.3.3",
73
+ ]
74
+
75
+ [tool.setuptools.packages.find]
76
+ where = ["src"]
77
+
78
+ [tool.setuptools.package-data]
79
+ ctxmesh = ["py.typed"]
80
+
81
+ # ── ruff ──────────────────────────────────────────────────────────────────────
82
+ [tool.ruff]
83
+ line-length = 100
84
+ target-version = "py39"
85
+ src = ["src", "tests"]
86
+
87
+ [tool.ruff.lint]
88
+ # A pragmatic-but-strict set: pycodestyle/pyflakes (E,F), bugbear (B),
89
+ # isort (I), comprehensions (C4), simplify (SIM). pyupgrade (UP) is deliberately
90
+ # omitted: the SDK targets 3.9 and uses explicit typing.Dict/List/Optional for
91
+ # clarity (all under `from __future__ import annotations`), which UP would
92
+ # rewrite to PEP 585/604 forms — a style change, not a correctness one.
93
+ select = ["E", "F", "B", "I", "C4", "SIM"]
94
+
95
+ [tool.ruff.lint.per-file-ignores]
96
+ # Tests may use asserts and longer fixture bodies freely. SIM117 (combine nested
97
+ # `with`) is ignored in tests because the step-tracing tests deliberately NEST
98
+ # `with` blocks (request_context → step → tool/llm) to express — and assert — the
99
+ # span-tree topology; flattening them into one `with` would obscure the very
100
+ # parent/child nesting under test.
101
+ "tests/*" = ["B011", "SIM117"]
102
+
103
+ # ── pytest ────────────────────────────────────────────────────────────────────
104
+ [tool.pytest.ini_options]
105
+ testpaths = ["tests"]
106
+ addopts = "-ra"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"
@@ -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)