loomcycle 0.8.0__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.
- loomcycle-0.8.0/PKG-INFO +256 -0
- loomcycle-0.8.0/README.md +221 -0
- loomcycle-0.8.0/loomcycle/__init__.py +112 -0
- loomcycle-0.8.0/loomcycle/_generated/__init__.py +0 -0
- loomcycle-0.8.0/loomcycle/_generated/loomcycle_pb2.py +218 -0
- loomcycle-0.8.0/loomcycle/_generated/loomcycle_pb2_grpc.py +1948 -0
- loomcycle-0.8.0/loomcycle/client.py +1519 -0
- loomcycle-0.8.0/loomcycle/errors.py +157 -0
- loomcycle-0.8.0/loomcycle/events.py +143 -0
- loomcycle-0.8.0/loomcycle/py.typed +0 -0
- loomcycle-0.8.0/loomcycle.egg-info/PKG-INFO +256 -0
- loomcycle-0.8.0/loomcycle.egg-info/SOURCES.txt +28 -0
- loomcycle-0.8.0/loomcycle.egg-info/dependency_links.txt +1 -0
- loomcycle-0.8.0/loomcycle.egg-info/requires.txt +7 -0
- loomcycle-0.8.0/loomcycle.egg-info/top_level.txt +1 -0
- loomcycle-0.8.0/pyproject.toml +87 -0
- loomcycle-0.8.0/setup.cfg +4 -0
- loomcycle-0.8.0/tests/test_channels_grpc.py +191 -0
- loomcycle-0.8.0/tests/test_drive_stream.py +274 -0
- loomcycle-0.8.0/tests/test_errors.py +178 -0
- loomcycle-0.8.0/tests/test_event_host_widening.py +45 -0
- loomcycle-0.8.0/tests/test_events.py +89 -0
- loomcycle-0.8.0/tests/test_helpers.py +103 -0
- loomcycle-0.8.0/tests/test_hooks.py +219 -0
- loomcycle-0.8.0/tests/test_pause_snapshot_client.py +267 -0
- loomcycle-0.8.0/tests/test_pause_snapshot_errors.py +149 -0
- loomcycle-0.8.0/tests/test_run_mutation_grpc.py +164 -0
- loomcycle-0.8.0/tests/test_run_request_fields.py +123 -0
- loomcycle-0.8.0/tests/test_substrate_client.py +147 -0
- loomcycle-0.8.0/tests/test_substrate_defs_grpc.py +81 -0
loomcycle-0.8.0/PKG-INFO
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: loomcycle
|
|
3
|
+
Version: 0.8.0
|
|
4
|
+
Summary: Async Python client for loomcycle's gRPC API — full 39-RPC parity (run streaming, batch fan-out, compaction, pause/resume/snapshot, substrate defs, channels)
|
|
5
|
+
Author: Dennis Gubsky
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/denn-gubsky/loomcycle
|
|
8
|
+
Project-URL: Repository, https://github.com/denn-gubsky/loomcycle
|
|
9
|
+
Project-URL: Source, https://github.com/denn-gubsky/loomcycle/tree/main/adapters/python
|
|
10
|
+
Project-URL: Documentation, https://github.com/denn-gubsky/loomcycle/tree/main/adapters/python#readme
|
|
11
|
+
Project-URL: Changelog, https://github.com/denn-gubsky/loomcycle/releases
|
|
12
|
+
Project-URL: Issues, https://github.com/denn-gubsky/loomcycle/issues
|
|
13
|
+
Keywords: llm,agent,loomcycle,grpc,async
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Classifier: Framework :: AsyncIO
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Classifier: Operating System :: OS Independent
|
|
27
|
+
Requires-Python: >=3.9
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
Requires-Dist: grpcio>=1.80.0
|
|
30
|
+
Requires-Dist: protobuf<7.0.0,>=5.0.0
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: grpcio-tools>=1.80.0; extra == "dev"
|
|
33
|
+
Requires-Dist: pytest>=7.4; extra == "dev"
|
|
34
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
35
|
+
|
|
36
|
+
# loomcycle — async Python client
|
|
37
|
+
|
|
38
|
+
`loomcycle` is the async Python client for [loomcycle][1]'s gRPC API
|
|
39
|
+
(introduced in v0.5.5). As of **v0.8.0 it covers all 39 gRPC RPCs** —
|
|
40
|
+
run streaming + continuation, batch fan-out, run compaction, agent
|
|
41
|
+
metadata + transcript, pause / resume / state, the snapshot lifecycle,
|
|
42
|
+
the resolver probe, the full substrate-def family, channel publish /
|
|
43
|
+
subscribe / peek / ack / await / broadcast, and run-state streaming —
|
|
44
|
+
through an ergonomic `LoomcycleClient` class with no need to import
|
|
45
|
+
generated protobuf types in your application code.
|
|
46
|
+
|
|
47
|
+
[1]: https://github.com/denn-gubsky/loomcycle
|
|
48
|
+
|
|
49
|
+
## Status
|
|
50
|
+
|
|
51
|
+
- Wraps loomcycle's gRPC server (`LOOMCYCLE_GRPC_ADDR`).
|
|
52
|
+
- Async-only (`grpc.aio`). Python 3.9+.
|
|
53
|
+
- **Full parity with the gRPC service surface** (39 RPCs).
|
|
54
|
+
- The TypeScript adapter (`adapters/ts/`) additionally exposes
|
|
55
|
+
**HTTP-only** operations that have no gRPC RPC — memory-entry admin,
|
|
56
|
+
interruptions, library enumeration, the LLM gateway, and
|
|
57
|
+
whoami / list-users. Those are not reachable over gRPC and so are not
|
|
58
|
+
in this client; use the HTTP+SSE surface for them.
|
|
59
|
+
- Production tag: `0.8.0` (39-RPC parity, ships alongside the
|
|
60
|
+
loomcycle v0.34.x line).
|
|
61
|
+
|
|
62
|
+
## Install
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install loomcycle
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Quick start
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
import asyncio
|
|
72
|
+
from loomcycle import LoomcycleClient
|
|
73
|
+
|
|
74
|
+
async def main():
|
|
75
|
+
async with LoomcycleClient(
|
|
76
|
+
target="127.0.0.1:8788",
|
|
77
|
+
auth_token="<LOOMCYCLE_AUTH_TOKEN>",
|
|
78
|
+
) as client:
|
|
79
|
+
async for ev in client.run_streaming(
|
|
80
|
+
agent="default",
|
|
81
|
+
segments=[
|
|
82
|
+
{
|
|
83
|
+
"role": "user",
|
|
84
|
+
"content": [
|
|
85
|
+
{"type": "trusted-text", "text": "Summarize loomcycle in one sentence."}
|
|
86
|
+
],
|
|
87
|
+
}
|
|
88
|
+
],
|
|
89
|
+
):
|
|
90
|
+
if ev.type == "text":
|
|
91
|
+
print(ev.text, end="", flush=True)
|
|
92
|
+
elif ev.type == "tool_use":
|
|
93
|
+
print(f"\n[tool_use: {ev.tool_use.name}]", flush=True)
|
|
94
|
+
elif ev.type == "done":
|
|
95
|
+
print(f"\n[done: {ev.stop_reason}]")
|
|
96
|
+
|
|
97
|
+
asyncio.run(main())
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Capturing the run handle
|
|
101
|
+
|
|
102
|
+
The gRPC server's first two stream frames are synthetic
|
|
103
|
+
registration frames — `LoomcycleClient` swallows them and exposes
|
|
104
|
+
the captured IDs via an `on_handle` callback:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
from loomcycle import LoomcycleClient, RunHandle
|
|
108
|
+
|
|
109
|
+
async def main():
|
|
110
|
+
handle: RunHandle | None = None
|
|
111
|
+
|
|
112
|
+
def capture(h: RunHandle):
|
|
113
|
+
nonlocal handle
|
|
114
|
+
handle = h
|
|
115
|
+
print(f"agent_id={h.agent_id} session_id={h.session_id} run_id={h.run_id}")
|
|
116
|
+
|
|
117
|
+
async with LoomcycleClient(target="127.0.0.1:8788") as client:
|
|
118
|
+
async for ev in client.run_streaming(
|
|
119
|
+
agent="default",
|
|
120
|
+
segments=[...],
|
|
121
|
+
on_handle=capture,
|
|
122
|
+
):
|
|
123
|
+
...
|
|
124
|
+
|
|
125
|
+
# Use handle.session_id later to continue or read transcript.
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## API
|
|
129
|
+
|
|
130
|
+
All methods are coroutine methods on `LoomcycleClient`.
|
|
131
|
+
|
|
132
|
+
| Method | Returns | Notes |
|
|
133
|
+
|---|---|---|
|
|
134
|
+
| `run_streaming(agent, segments, ...)` | `AsyncIterator[AgentEvent]` | Server-streams provider events for a fresh run. |
|
|
135
|
+
| `continue_session(session_id, segments, ...)` | `AsyncIterator[AgentEvent]` | Continues an existing session. |
|
|
136
|
+
| `get_agent(agent_id)` | `dict` | One agent's status + usage. |
|
|
137
|
+
| `cancel_agent(agent_id, reason="")` | `int` | Returns count of agents cancelled (cascades to children). |
|
|
138
|
+
| `list_user_agents(user_id, status="")` | `list[dict]` | Filters: `running`, `completed`, `failed`, `cancelled`. |
|
|
139
|
+
| `get_transcript(session_id)` | `list[dict]` | Persisted event log; `payload` is raw JSON bytes. |
|
|
140
|
+
| `health()` | `dict` | Liveness + build info. Unauthenticated. |
|
|
141
|
+
| `register_hook(owner, name, phase, callback_url, ...)` | `dict` | Pre/PostTool webhook registration. Returns `{"id": ...}`. |
|
|
142
|
+
| `list_hooks()` | `list[dict]` | Every registered hook (in-memory only). |
|
|
143
|
+
| `delete_hook(hook_id)` | `bool` | Idempotent on missing id is NOT supported — raises `HookNotFoundError`. |
|
|
144
|
+
| `close()` | `None` | Idempotent. Use `async with` to do this automatically. |
|
|
145
|
+
| `pause_runtime(timeout_ms=0)` | `dict` | v0.8.18 — quiesce the runtime. Returns `{status, duration_ms, force_cancelled_count, paused_runs_count, warnings}`. |
|
|
146
|
+
| `resume_runtime()` | `dict` | v0.8.18 — release the quiesce. Returns `{status, resumed_run_count, warnings}`. |
|
|
147
|
+
| `get_runtime_state()` | `dict` | v0.8.18 — current state. Returns `{status, paused_at, paused_run_count, snapshots_count}`. |
|
|
148
|
+
| `create_snapshot(description="", include_history=False, since_ts=None, max_bytes=0)` | `dict` | v0.8.18 — capture running-state JSON envelope. |
|
|
149
|
+
| `list_snapshots()` | `list[dict]` | v0.8.18 — metadata only; up to 200 most-recent. |
|
|
150
|
+
| `get_snapshot(snapshot_id)` | `dict` | v0.8.18 — full envelope including `json_content` bytes. |
|
|
151
|
+
| `export_snapshot(snapshot_id)` | `dict` | v0.8.18 — canonical bytes via `raw_json` for streaming consumers. |
|
|
152
|
+
| `restore_snapshot(snapshot_id=..., raw_json=..., include_history=False)` | `dict` | v0.8.18 — exactly one of `snapshot_id` / `raw_json`. Per-section counters returned. |
|
|
153
|
+
| `delete_snapshot(snapshot_id)` | `bool` | v0.8.18 — idempotent; returns True. |
|
|
154
|
+
| `spawn_run_batch(spawns, mode="join", timeout_ms=0)` | `dict` | v0.8.0 — spawn up to 32 runs concurrently (RFC Y); index-aligned `{spawned, results}`, per-child failures in-envelope. |
|
|
155
|
+
| `compact_run(run_id, reason="")` | `dict` | v0.8.0 — summarize a parked run's context. `{run_id, compacted, before_tokens, after_tokens, applied}`. |
|
|
156
|
+
| `resolve_probe()` | `dict` | v0.8.0 — resolver provider/model availability matrix. |
|
|
157
|
+
| `agent_def(input)` / `skill_def(input)` | `dict` | Substrate AgentDef / SkillDef tool; op-discriminated body. |
|
|
158
|
+
| `mcp_server_def` / `schedule_def` / `a2a_server_card_def` / `a2a_agent_def` / `webhook_def` / `memory_backend_def` / `operator_token_def` `(input)` | `dict` | v0.8.0 — the rest of the substrate-def family; same shape + `SubstrateToolRefusedError` contract. |
|
|
159
|
+
| `list_channels()` | `list[dict]` | v0.8.0 — declared + runtime channels with aggregate stats. |
|
|
160
|
+
| `publish_channel(channel, payload, scope="global", scope_id="", deliver_at="")` | `dict` | v0.8.0 — publish raw-JSON `payload` (bytes); `deliver_at` defers. |
|
|
161
|
+
| `subscribe_channel(channel, ...)` / `peek_channel(channel, ...)` | `dict` | v0.8.0 — long-poll / non-destructive read; `{messages, next_cursor?}`. |
|
|
162
|
+
| `ack_channel(channel, cursor, ...)` | `bool` | v0.8.0 — commit a channel cursor. |
|
|
163
|
+
| `await_channels(channels, mode="any", n=0, ...)` | `dict` | v0.8.0 — fan-in across channels (any / all / at_least). |
|
|
164
|
+
| `broadcast_channels(channels, payload, ...)` | `dict` | v0.8.0 — fan-out one payload to N channels. |
|
|
165
|
+
| `stream_user_run_states(user_id, statuses=None, agent="")` | `AsyncIterator[dict]` | v0.8.0 — stream a user's run-state transitions. |
|
|
166
|
+
|
|
167
|
+
`run_streaming` / `continue_session` / each `spawn_run_batch` child also accept
|
|
168
|
+
per-run `sampling` and `compaction` dict overrides (v0.8.0); an explicit
|
|
169
|
+
`temperature: 0.0` is preserved as deterministic.
|
|
170
|
+
|
|
171
|
+
## Errors
|
|
172
|
+
|
|
173
|
+
Every method translates gRPC error codes to typed Python exceptions:
|
|
174
|
+
|
|
175
|
+
| gRPC code | Exception |
|
|
176
|
+
|---|---|
|
|
177
|
+
| `NOT_FOUND` (with session in msg) | `SessionNotFoundError` |
|
|
178
|
+
| `NOT_FOUND` (with hook in msg) | `HookNotFoundError` |
|
|
179
|
+
| `NOT_FOUND` (otherwise — agent ctx) | `AgentNotFoundError` |
|
|
180
|
+
| `FAILED_PRECONDITION` (session busy) | `SessionBusyError` |
|
|
181
|
+
| `FAILED_PRECONDITION` (other) | `LoomcycleError` |
|
|
182
|
+
| `ALREADY_EXISTS` | `AgentIDInUseError` |
|
|
183
|
+
| `RESOURCE_EXHAUSTED` (snapshot) | `SnapshotTooLargeError` (v0.8.18) |
|
|
184
|
+
| `RESOURCE_EXHAUSTED` (other) | `BackpressureError` |
|
|
185
|
+
| `UNAUTHENTICATED` | `AuthError` |
|
|
186
|
+
| `UNAVAILABLE` (pause not configured) | `PauseNotConfiguredError` (v0.8.18, subclass of UnavailableError) |
|
|
187
|
+
| `UNAVAILABLE` (other) | `UnavailableError` |
|
|
188
|
+
| `NOT_FOUND` (with snapshot ctx) | `SnapshotNotFoundError` (v0.8.18) |
|
|
189
|
+
| `FAILED_PRECONDITION` (already pausing) | `AlreadyPausingError` (v0.8.18) |
|
|
190
|
+
| `FAILED_PRECONDITION` (not paused) | `NotPausedError` (v0.8.18) |
|
|
191
|
+
| `FAILED_PRECONDITION` (snapshot version) | `SnapshotVersionError` (v0.8.18) |
|
|
192
|
+
| `INVALID_ARGUMENT` / `INTERNAL` / other | `LoomcycleError` |
|
|
193
|
+
|
|
194
|
+
All exceptions inherit from `LoomcycleError` and preserve the
|
|
195
|
+
original `grpc.StatusCode` on `.code` for log correlation:
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
from loomcycle import BackpressureError
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
async for ev in client.run_streaming(...): ...
|
|
202
|
+
except BackpressureError as e:
|
|
203
|
+
log.warning("loomcycle backpressure (code=%s): %s", e.code, e.message)
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Allowed-hosts semantics
|
|
207
|
+
|
|
208
|
+
`allowed_hosts` mirrors the HTTP API's narrowing semantics:
|
|
209
|
+
|
|
210
|
+
| Value | Effect |
|
|
211
|
+
|---|---|
|
|
212
|
+
| `None` (default) | No narrowing; the operator's static allowlist is the floor. |
|
|
213
|
+
| `[]` | Deny-all; the agent gets no network access. |
|
|
214
|
+
| `["foo.com"]` | Intersection with the operator's static list. |
|
|
215
|
+
|
|
216
|
+
This is enforced server-side in `internal/tools/builtin/narrowing.go`;
|
|
217
|
+
`allowed_hosts` is a trust boundary — it must come from your
|
|
218
|
+
application code, never from a model.
|
|
219
|
+
|
|
220
|
+
## Development
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
# One-time setup:
|
|
224
|
+
python3 -m venv adapters/python/.venv
|
|
225
|
+
adapters/python/.venv/bin/pip install -e adapters/python[dev]
|
|
226
|
+
|
|
227
|
+
# Run tests (offline):
|
|
228
|
+
make python-test
|
|
229
|
+
|
|
230
|
+
# Regenerate stubs after editing proto/loomcycle.proto:
|
|
231
|
+
make python-proto
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
The package commits its generated `loomcycle_pb2.py` /
|
|
235
|
+
`loomcycle_pb2_grpc.py` so end users don't need a working `protoc`
|
|
236
|
+
to install. Re-run `make python-proto` whenever the proto changes.
|
|
237
|
+
|
|
238
|
+
## Live integration test
|
|
239
|
+
|
|
240
|
+
To run the example end-to-end against a local loomcycle:
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
# In one shell — start loomcycle with gRPC enabled:
|
|
244
|
+
LOOMCYCLE_GRPC_ADDR=127.0.0.1:8788 \
|
|
245
|
+
LOOMCYCLE_AUTH_TOKEN=devtoken \
|
|
246
|
+
./bin/loomcycle --config loomcycle.yaml
|
|
247
|
+
|
|
248
|
+
# In another shell — run the example:
|
|
249
|
+
LOOMCYCLE_GRPC_ADDR=127.0.0.1:8788 \
|
|
250
|
+
LOOMCYCLE_AUTH_TOKEN=devtoken \
|
|
251
|
+
adapters/python/.venv/bin/python examples/python-cli/main.py
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## License
|
|
255
|
+
|
|
256
|
+
Apache-2.0. Same as loomcycle.
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# loomcycle — async Python client
|
|
2
|
+
|
|
3
|
+
`loomcycle` is the async Python client for [loomcycle][1]'s gRPC API
|
|
4
|
+
(introduced in v0.5.5). As of **v0.8.0 it covers all 39 gRPC RPCs** —
|
|
5
|
+
run streaming + continuation, batch fan-out, run compaction, agent
|
|
6
|
+
metadata + transcript, pause / resume / state, the snapshot lifecycle,
|
|
7
|
+
the resolver probe, the full substrate-def family, channel publish /
|
|
8
|
+
subscribe / peek / ack / await / broadcast, and run-state streaming —
|
|
9
|
+
through an ergonomic `LoomcycleClient` class with no need to import
|
|
10
|
+
generated protobuf types in your application code.
|
|
11
|
+
|
|
12
|
+
[1]: https://github.com/denn-gubsky/loomcycle
|
|
13
|
+
|
|
14
|
+
## Status
|
|
15
|
+
|
|
16
|
+
- Wraps loomcycle's gRPC server (`LOOMCYCLE_GRPC_ADDR`).
|
|
17
|
+
- Async-only (`grpc.aio`). Python 3.9+.
|
|
18
|
+
- **Full parity with the gRPC service surface** (39 RPCs).
|
|
19
|
+
- The TypeScript adapter (`adapters/ts/`) additionally exposes
|
|
20
|
+
**HTTP-only** operations that have no gRPC RPC — memory-entry admin,
|
|
21
|
+
interruptions, library enumeration, the LLM gateway, and
|
|
22
|
+
whoami / list-users. Those are not reachable over gRPC and so are not
|
|
23
|
+
in this client; use the HTTP+SSE surface for them.
|
|
24
|
+
- Production tag: `0.8.0` (39-RPC parity, ships alongside the
|
|
25
|
+
loomcycle v0.34.x line).
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install loomcycle
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import asyncio
|
|
37
|
+
from loomcycle import LoomcycleClient
|
|
38
|
+
|
|
39
|
+
async def main():
|
|
40
|
+
async with LoomcycleClient(
|
|
41
|
+
target="127.0.0.1:8788",
|
|
42
|
+
auth_token="<LOOMCYCLE_AUTH_TOKEN>",
|
|
43
|
+
) as client:
|
|
44
|
+
async for ev in client.run_streaming(
|
|
45
|
+
agent="default",
|
|
46
|
+
segments=[
|
|
47
|
+
{
|
|
48
|
+
"role": "user",
|
|
49
|
+
"content": [
|
|
50
|
+
{"type": "trusted-text", "text": "Summarize loomcycle in one sentence."}
|
|
51
|
+
],
|
|
52
|
+
}
|
|
53
|
+
],
|
|
54
|
+
):
|
|
55
|
+
if ev.type == "text":
|
|
56
|
+
print(ev.text, end="", flush=True)
|
|
57
|
+
elif ev.type == "tool_use":
|
|
58
|
+
print(f"\n[tool_use: {ev.tool_use.name}]", flush=True)
|
|
59
|
+
elif ev.type == "done":
|
|
60
|
+
print(f"\n[done: {ev.stop_reason}]")
|
|
61
|
+
|
|
62
|
+
asyncio.run(main())
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Capturing the run handle
|
|
66
|
+
|
|
67
|
+
The gRPC server's first two stream frames are synthetic
|
|
68
|
+
registration frames — `LoomcycleClient` swallows them and exposes
|
|
69
|
+
the captured IDs via an `on_handle` callback:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from loomcycle import LoomcycleClient, RunHandle
|
|
73
|
+
|
|
74
|
+
async def main():
|
|
75
|
+
handle: RunHandle | None = None
|
|
76
|
+
|
|
77
|
+
def capture(h: RunHandle):
|
|
78
|
+
nonlocal handle
|
|
79
|
+
handle = h
|
|
80
|
+
print(f"agent_id={h.agent_id} session_id={h.session_id} run_id={h.run_id}")
|
|
81
|
+
|
|
82
|
+
async with LoomcycleClient(target="127.0.0.1:8788") as client:
|
|
83
|
+
async for ev in client.run_streaming(
|
|
84
|
+
agent="default",
|
|
85
|
+
segments=[...],
|
|
86
|
+
on_handle=capture,
|
|
87
|
+
):
|
|
88
|
+
...
|
|
89
|
+
|
|
90
|
+
# Use handle.session_id later to continue or read transcript.
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## API
|
|
94
|
+
|
|
95
|
+
All methods are coroutine methods on `LoomcycleClient`.
|
|
96
|
+
|
|
97
|
+
| Method | Returns | Notes |
|
|
98
|
+
|---|---|---|
|
|
99
|
+
| `run_streaming(agent, segments, ...)` | `AsyncIterator[AgentEvent]` | Server-streams provider events for a fresh run. |
|
|
100
|
+
| `continue_session(session_id, segments, ...)` | `AsyncIterator[AgentEvent]` | Continues an existing session. |
|
|
101
|
+
| `get_agent(agent_id)` | `dict` | One agent's status + usage. |
|
|
102
|
+
| `cancel_agent(agent_id, reason="")` | `int` | Returns count of agents cancelled (cascades to children). |
|
|
103
|
+
| `list_user_agents(user_id, status="")` | `list[dict]` | Filters: `running`, `completed`, `failed`, `cancelled`. |
|
|
104
|
+
| `get_transcript(session_id)` | `list[dict]` | Persisted event log; `payload` is raw JSON bytes. |
|
|
105
|
+
| `health()` | `dict` | Liveness + build info. Unauthenticated. |
|
|
106
|
+
| `register_hook(owner, name, phase, callback_url, ...)` | `dict` | Pre/PostTool webhook registration. Returns `{"id": ...}`. |
|
|
107
|
+
| `list_hooks()` | `list[dict]` | Every registered hook (in-memory only). |
|
|
108
|
+
| `delete_hook(hook_id)` | `bool` | Idempotent on missing id is NOT supported — raises `HookNotFoundError`. |
|
|
109
|
+
| `close()` | `None` | Idempotent. Use `async with` to do this automatically. |
|
|
110
|
+
| `pause_runtime(timeout_ms=0)` | `dict` | v0.8.18 — quiesce the runtime. Returns `{status, duration_ms, force_cancelled_count, paused_runs_count, warnings}`. |
|
|
111
|
+
| `resume_runtime()` | `dict` | v0.8.18 — release the quiesce. Returns `{status, resumed_run_count, warnings}`. |
|
|
112
|
+
| `get_runtime_state()` | `dict` | v0.8.18 — current state. Returns `{status, paused_at, paused_run_count, snapshots_count}`. |
|
|
113
|
+
| `create_snapshot(description="", include_history=False, since_ts=None, max_bytes=0)` | `dict` | v0.8.18 — capture running-state JSON envelope. |
|
|
114
|
+
| `list_snapshots()` | `list[dict]` | v0.8.18 — metadata only; up to 200 most-recent. |
|
|
115
|
+
| `get_snapshot(snapshot_id)` | `dict` | v0.8.18 — full envelope including `json_content` bytes. |
|
|
116
|
+
| `export_snapshot(snapshot_id)` | `dict` | v0.8.18 — canonical bytes via `raw_json` for streaming consumers. |
|
|
117
|
+
| `restore_snapshot(snapshot_id=..., raw_json=..., include_history=False)` | `dict` | v0.8.18 — exactly one of `snapshot_id` / `raw_json`. Per-section counters returned. |
|
|
118
|
+
| `delete_snapshot(snapshot_id)` | `bool` | v0.8.18 — idempotent; returns True. |
|
|
119
|
+
| `spawn_run_batch(spawns, mode="join", timeout_ms=0)` | `dict` | v0.8.0 — spawn up to 32 runs concurrently (RFC Y); index-aligned `{spawned, results}`, per-child failures in-envelope. |
|
|
120
|
+
| `compact_run(run_id, reason="")` | `dict` | v0.8.0 — summarize a parked run's context. `{run_id, compacted, before_tokens, after_tokens, applied}`. |
|
|
121
|
+
| `resolve_probe()` | `dict` | v0.8.0 — resolver provider/model availability matrix. |
|
|
122
|
+
| `agent_def(input)` / `skill_def(input)` | `dict` | Substrate AgentDef / SkillDef tool; op-discriminated body. |
|
|
123
|
+
| `mcp_server_def` / `schedule_def` / `a2a_server_card_def` / `a2a_agent_def` / `webhook_def` / `memory_backend_def` / `operator_token_def` `(input)` | `dict` | v0.8.0 — the rest of the substrate-def family; same shape + `SubstrateToolRefusedError` contract. |
|
|
124
|
+
| `list_channels()` | `list[dict]` | v0.8.0 — declared + runtime channels with aggregate stats. |
|
|
125
|
+
| `publish_channel(channel, payload, scope="global", scope_id="", deliver_at="")` | `dict` | v0.8.0 — publish raw-JSON `payload` (bytes); `deliver_at` defers. |
|
|
126
|
+
| `subscribe_channel(channel, ...)` / `peek_channel(channel, ...)` | `dict` | v0.8.0 — long-poll / non-destructive read; `{messages, next_cursor?}`. |
|
|
127
|
+
| `ack_channel(channel, cursor, ...)` | `bool` | v0.8.0 — commit a channel cursor. |
|
|
128
|
+
| `await_channels(channels, mode="any", n=0, ...)` | `dict` | v0.8.0 — fan-in across channels (any / all / at_least). |
|
|
129
|
+
| `broadcast_channels(channels, payload, ...)` | `dict` | v0.8.0 — fan-out one payload to N channels. |
|
|
130
|
+
| `stream_user_run_states(user_id, statuses=None, agent="")` | `AsyncIterator[dict]` | v0.8.0 — stream a user's run-state transitions. |
|
|
131
|
+
|
|
132
|
+
`run_streaming` / `continue_session` / each `spawn_run_batch` child also accept
|
|
133
|
+
per-run `sampling` and `compaction` dict overrides (v0.8.0); an explicit
|
|
134
|
+
`temperature: 0.0` is preserved as deterministic.
|
|
135
|
+
|
|
136
|
+
## Errors
|
|
137
|
+
|
|
138
|
+
Every method translates gRPC error codes to typed Python exceptions:
|
|
139
|
+
|
|
140
|
+
| gRPC code | Exception |
|
|
141
|
+
|---|---|
|
|
142
|
+
| `NOT_FOUND` (with session in msg) | `SessionNotFoundError` |
|
|
143
|
+
| `NOT_FOUND` (with hook in msg) | `HookNotFoundError` |
|
|
144
|
+
| `NOT_FOUND` (otherwise — agent ctx) | `AgentNotFoundError` |
|
|
145
|
+
| `FAILED_PRECONDITION` (session busy) | `SessionBusyError` |
|
|
146
|
+
| `FAILED_PRECONDITION` (other) | `LoomcycleError` |
|
|
147
|
+
| `ALREADY_EXISTS` | `AgentIDInUseError` |
|
|
148
|
+
| `RESOURCE_EXHAUSTED` (snapshot) | `SnapshotTooLargeError` (v0.8.18) |
|
|
149
|
+
| `RESOURCE_EXHAUSTED` (other) | `BackpressureError` |
|
|
150
|
+
| `UNAUTHENTICATED` | `AuthError` |
|
|
151
|
+
| `UNAVAILABLE` (pause not configured) | `PauseNotConfiguredError` (v0.8.18, subclass of UnavailableError) |
|
|
152
|
+
| `UNAVAILABLE` (other) | `UnavailableError` |
|
|
153
|
+
| `NOT_FOUND` (with snapshot ctx) | `SnapshotNotFoundError` (v0.8.18) |
|
|
154
|
+
| `FAILED_PRECONDITION` (already pausing) | `AlreadyPausingError` (v0.8.18) |
|
|
155
|
+
| `FAILED_PRECONDITION` (not paused) | `NotPausedError` (v0.8.18) |
|
|
156
|
+
| `FAILED_PRECONDITION` (snapshot version) | `SnapshotVersionError` (v0.8.18) |
|
|
157
|
+
| `INVALID_ARGUMENT` / `INTERNAL` / other | `LoomcycleError` |
|
|
158
|
+
|
|
159
|
+
All exceptions inherit from `LoomcycleError` and preserve the
|
|
160
|
+
original `grpc.StatusCode` on `.code` for log correlation:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
from loomcycle import BackpressureError
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
async for ev in client.run_streaming(...): ...
|
|
167
|
+
except BackpressureError as e:
|
|
168
|
+
log.warning("loomcycle backpressure (code=%s): %s", e.code, e.message)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Allowed-hosts semantics
|
|
172
|
+
|
|
173
|
+
`allowed_hosts` mirrors the HTTP API's narrowing semantics:
|
|
174
|
+
|
|
175
|
+
| Value | Effect |
|
|
176
|
+
|---|---|
|
|
177
|
+
| `None` (default) | No narrowing; the operator's static allowlist is the floor. |
|
|
178
|
+
| `[]` | Deny-all; the agent gets no network access. |
|
|
179
|
+
| `["foo.com"]` | Intersection with the operator's static list. |
|
|
180
|
+
|
|
181
|
+
This is enforced server-side in `internal/tools/builtin/narrowing.go`;
|
|
182
|
+
`allowed_hosts` is a trust boundary — it must come from your
|
|
183
|
+
application code, never from a model.
|
|
184
|
+
|
|
185
|
+
## Development
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
# One-time setup:
|
|
189
|
+
python3 -m venv adapters/python/.venv
|
|
190
|
+
adapters/python/.venv/bin/pip install -e adapters/python[dev]
|
|
191
|
+
|
|
192
|
+
# Run tests (offline):
|
|
193
|
+
make python-test
|
|
194
|
+
|
|
195
|
+
# Regenerate stubs after editing proto/loomcycle.proto:
|
|
196
|
+
make python-proto
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The package commits its generated `loomcycle_pb2.py` /
|
|
200
|
+
`loomcycle_pb2_grpc.py` so end users don't need a working `protoc`
|
|
201
|
+
to install. Re-run `make python-proto` whenever the proto changes.
|
|
202
|
+
|
|
203
|
+
## Live integration test
|
|
204
|
+
|
|
205
|
+
To run the example end-to-end against a local loomcycle:
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
# In one shell — start loomcycle with gRPC enabled:
|
|
209
|
+
LOOMCYCLE_GRPC_ADDR=127.0.0.1:8788 \
|
|
210
|
+
LOOMCYCLE_AUTH_TOKEN=devtoken \
|
|
211
|
+
./bin/loomcycle --config loomcycle.yaml
|
|
212
|
+
|
|
213
|
+
# In another shell — run the example:
|
|
214
|
+
LOOMCYCLE_GRPC_ADDR=127.0.0.1:8788 \
|
|
215
|
+
LOOMCYCLE_AUTH_TOKEN=devtoken \
|
|
216
|
+
adapters/python/.venv/bin/python examples/python-cli/main.py
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## License
|
|
220
|
+
|
|
221
|
+
Apache-2.0. Same as loomcycle.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Async Python client for loomcycle's gRPC API.
|
|
2
|
+
|
|
3
|
+
Quick start:
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from loomcycle import LoomcycleClient
|
|
7
|
+
|
|
8
|
+
async def main():
|
|
9
|
+
client = LoomcycleClient(target="127.0.0.1:8788", auth_token="...")
|
|
10
|
+
try:
|
|
11
|
+
async for ev in client.run_streaming(
|
|
12
|
+
agent="default",
|
|
13
|
+
segments=[{"role": "user", "content": [
|
|
14
|
+
{"type": "trusted-text", "text": "Hello, world."}
|
|
15
|
+
]}],
|
|
16
|
+
):
|
|
17
|
+
if ev.type == "text":
|
|
18
|
+
print(ev.text, end="", flush=True)
|
|
19
|
+
finally:
|
|
20
|
+
await client.close()
|
|
21
|
+
|
|
22
|
+
asyncio.run(main())
|
|
23
|
+
|
|
24
|
+
The client surface mirrors the gRPC service in proto/loomcycle.proto:
|
|
25
|
+
|
|
26
|
+
run_streaming(...) — server-stream events from a fresh run
|
|
27
|
+
continue_session(...) — server-stream events from a continuation
|
|
28
|
+
spawn_run_batch(...) — spawn up to 32 runs concurrently (RFC Y)
|
|
29
|
+
compact_run(...) — summarize a parked run's context
|
|
30
|
+
get_agent(...) — read one agent's status + usage
|
|
31
|
+
cancel_agent(...) — cancel a live agent (cascades to children)
|
|
32
|
+
list_user_agents(...) — list a user's recent runs
|
|
33
|
+
stream_user_run_states(...) — stream a user's run-state transitions
|
|
34
|
+
get_transcript(...) — read the persisted event log for a session
|
|
35
|
+
resolve_probe() — resolver provider/model availability matrix
|
|
36
|
+
health() — liveness probe
|
|
37
|
+
|
|
38
|
+
As of v0.8.0 the client covers all 39 gRPC RPCs: the substrate-def family
|
|
39
|
+
(agent_def / skill_def / mcp_server_def / schedule_def / a2a_server_card_def /
|
|
40
|
+
a2a_agent_def / webhook_def / memory_backend_def / operator_token_def), the
|
|
41
|
+
channel ops (list_channels / publish_channel / subscribe_channel / peek_channel /
|
|
42
|
+
ack_channel / await_channels / broadcast_channels), pause/resume/state, the
|
|
43
|
+
snapshot lifecycle, and hook management. run_streaming / continue_session /
|
|
44
|
+
spawn_run_batch accept per-run ``sampling`` + ``compaction`` overrides. The
|
|
45
|
+
HTTP-only surface (memory-entry admin, interruptions, library enumeration, the
|
|
46
|
+
LLM gateway, whoami/list-users) has no gRPC RPC and is not exposed here.
|
|
47
|
+
|
|
48
|
+
All methods are async. Server-streaming methods return an
|
|
49
|
+
``AsyncIterator[AgentEvent]``. The synthetic ``"session"`` and
|
|
50
|
+
``"agent"`` registration frames the gRPC server emits before the
|
|
51
|
+
first provider event are NOT yielded to the caller — instead they're
|
|
52
|
+
captured into ``RunHandle`` (returned alongside the iterator when
|
|
53
|
+
the caller wants the IDs without re-decoding the first frames).
|
|
54
|
+
|
|
55
|
+
For environments that can't use gRPC, use HTTP+SSE through your own
|
|
56
|
+
``httpx``-based client; loomcycle's HTTP+SSE surface is
|
|
57
|
+
documented in the project README.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
from .client import LoomcycleClient, RunHandle
|
|
61
|
+
from .events import AgentEvent, ToolUse, Usage, Retry, HostWidening
|
|
62
|
+
from .errors import (
|
|
63
|
+
LoomcycleError,
|
|
64
|
+
AgentNotFoundError,
|
|
65
|
+
SessionNotFoundError,
|
|
66
|
+
SessionBusyError,
|
|
67
|
+
AgentIDInUseError,
|
|
68
|
+
BackpressureError,
|
|
69
|
+
AuthError,
|
|
70
|
+
UnavailableError,
|
|
71
|
+
HookNotFoundError,
|
|
72
|
+
# v0.8.18 — pause/snapshot typed errors.
|
|
73
|
+
PauseNotConfiguredError,
|
|
74
|
+
AlreadyPausingError,
|
|
75
|
+
NotPausedError,
|
|
76
|
+
SnapshotNotFoundError,
|
|
77
|
+
SnapshotTooLargeError,
|
|
78
|
+
SnapshotVersionError,
|
|
79
|
+
SubstrateToolRefusedError,
|
|
80
|
+
InvalidArgumentError,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
__all__ = [
|
|
84
|
+
"LoomcycleClient",
|
|
85
|
+
"RunHandle",
|
|
86
|
+
"AgentEvent",
|
|
87
|
+
"ToolUse",
|
|
88
|
+
"Usage",
|
|
89
|
+
"Retry",
|
|
90
|
+
"HostWidening",
|
|
91
|
+
"LoomcycleError",
|
|
92
|
+
"AgentNotFoundError",
|
|
93
|
+
"SessionNotFoundError",
|
|
94
|
+
"SessionBusyError",
|
|
95
|
+
"AgentIDInUseError",
|
|
96
|
+
"BackpressureError",
|
|
97
|
+
"AuthError",
|
|
98
|
+
"UnavailableError",
|
|
99
|
+
"HookNotFoundError",
|
|
100
|
+
# v0.8.18 additions.
|
|
101
|
+
"PauseNotConfiguredError",
|
|
102
|
+
"AlreadyPausingError",
|
|
103
|
+
"NotPausedError",
|
|
104
|
+
"SnapshotNotFoundError",
|
|
105
|
+
"SnapshotTooLargeError",
|
|
106
|
+
"SnapshotVersionError",
|
|
107
|
+
"InvalidArgumentError",
|
|
108
|
+
# v0.8.22 substrate admin
|
|
109
|
+
"SubstrateToolRefusedError",
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
__version__ = "0.8.0"
|
|
File without changes
|