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.
- ctxmesh-0.1.0b1/PKG-INFO +157 -0
- ctxmesh-0.1.0b1/README.md +133 -0
- ctxmesh-0.1.0b1/pyproject.toml +106 -0
- ctxmesh-0.1.0b1/setup.cfg +4 -0
- ctxmesh-0.1.0b1/src/ctxmesh/__init__.py +105 -0
- ctxmesh-0.1.0b1/src/ctxmesh/_approval.py +123 -0
- ctxmesh-0.1.0b1/src/ctxmesh/_capability.py +64 -0
- ctxmesh-0.1.0b1/src/ctxmesh/_checkpoint.py +117 -0
- ctxmesh-0.1.0b1/src/ctxmesh/_http.py +229 -0
- ctxmesh-0.1.0b1/src/ctxmesh/_multimodal.py +98 -0
- ctxmesh-0.1.0b1/src/ctxmesh/_record.py +61 -0
- ctxmesh-0.1.0b1/src/ctxmesh/_semconv.py +43 -0
- ctxmesh-0.1.0b1/src/ctxmesh/_tracing.py +196 -0
- ctxmesh-0.1.0b1/src/ctxmesh/agent.py +45 -0
- ctxmesh-0.1.0b1/src/ctxmesh/client.py +119 -0
- ctxmesh-0.1.0b1/src/ctxmesh/config.py +225 -0
- ctxmesh-0.1.0b1/src/ctxmesh/errors.py +132 -0
- ctxmesh-0.1.0b1/src/ctxmesh/feedback.py +74 -0
- ctxmesh-0.1.0b1/src/ctxmesh/knowledge.py +178 -0
- ctxmesh-0.1.0b1/src/ctxmesh/managed.py +2205 -0
- ctxmesh-0.1.0b1/src/ctxmesh/memory.py +196 -0
- ctxmesh-0.1.0b1/src/ctxmesh/mesh.py +100 -0
- ctxmesh-0.1.0b1/src/ctxmesh/model.py +504 -0
- ctxmesh-0.1.0b1/src/ctxmesh/py.typed +0 -0
- ctxmesh-0.1.0b1/src/ctxmesh/runs.py +216 -0
- ctxmesh-0.1.0b1/src/ctxmesh/serve.py +338 -0
- ctxmesh-0.1.0b1/src/ctxmesh/skills.py +153 -0
- ctxmesh-0.1.0b1/src/ctxmesh/testing.py +526 -0
- ctxmesh-0.1.0b1/src/ctxmesh/tools.py +890 -0
- ctxmesh-0.1.0b1/src/ctxmesh/trace.py +315 -0
- ctxmesh-0.1.0b1/src/ctxmesh.egg-info/PKG-INFO +157 -0
- ctxmesh-0.1.0b1/src/ctxmesh.egg-info/SOURCES.txt +53 -0
- ctxmesh-0.1.0b1/src/ctxmesh.egg-info/dependency_links.txt +1 -0
- ctxmesh-0.1.0b1/src/ctxmesh.egg-info/requires.txt +8 -0
- ctxmesh-0.1.0b1/src/ctxmesh.egg-info/top_level.txt +1 -0
- ctxmesh-0.1.0b1/tests/test_approval.py +59 -0
- ctxmesh-0.1.0b1/tests/test_approval_voucher.py +111 -0
- ctxmesh-0.1.0b1/tests/test_capability.py +115 -0
- ctxmesh-0.1.0b1/tests/test_checkpoint.py +89 -0
- ctxmesh-0.1.0b1/tests/test_config.py +61 -0
- ctxmesh-0.1.0b1/tests/test_consent.py +43 -0
- ctxmesh-0.1.0b1/tests/test_delegate_suspend_loop.py +296 -0
- ctxmesh-0.1.0b1/tests/test_feedback.py +76 -0
- ctxmesh-0.1.0b1/tests/test_guardrail_sdk.py +215 -0
- ctxmesh-0.1.0b1/tests/test_knowledge.py +544 -0
- ctxmesh-0.1.0b1/tests/test_m156_audit_fixes.py +118 -0
- ctxmesh-0.1.0b1/tests/test_managed.py +2813 -0
- ctxmesh-0.1.0b1/tests/test_memory.py +125 -0
- ctxmesh-0.1.0b1/tests/test_model.py +374 -0
- ctxmesh-0.1.0b1/tests/test_runs.py +226 -0
- ctxmesh-0.1.0b1/tests/test_serve.py +459 -0
- ctxmesh-0.1.0b1/tests/test_testing.py +52 -0
- ctxmesh-0.1.0b1/tests/test_tools.py +610 -0
- ctxmesh-0.1.0b1/tests/test_trace.py +163 -0
- ctxmesh-0.1.0b1/tests/test_trace_context.py +171 -0
ctxmesh-0.1.0b1/PKG-INFO
ADDED
|
@@ -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,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)
|