@loomcycle/client 0.9.0 → 0.9.2
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.
- package/README.md +243 -1
- package/dist/client.d.ts +125 -3
- package/dist/client.js +344 -7
- package/dist/errors.d.ts +16 -0
- package/dist/errors.js +16 -0
- package/dist/fetch-helpers.js +5 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/types.d.ts +311 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -6,10 +6,19 @@ TypeScript client for the [loomcycle](https://github.com/denn-gubsky/loomcycle)
|
|
|
6
6
|
|
|
7
7
|
## Status
|
|
8
8
|
|
|
9
|
-
**v0.
|
|
9
|
+
**v0.11.0** — 31 methods covering run streaming, agent metadata, transcript, pause/resume/state, snapshot lifecycle, memory admin, interruption resolve, hook registration, **v0.8.22 substrate admin (agentDef + skillDef)**, **v0.9.x n8n Phase 0 (listChannels + streamUserRunStates)**, **v0.9.x content_sha256** (the bundle-vs-deployed comparison workflow for Docker-bundled operators), and health.
|
|
10
10
|
|
|
11
11
|
> Migrating from raw `fetch` against `/v1/*`? See **[docs/MIGRATING-FROM-HTTP.md](./docs/MIGRATING-FROM-HTTP.md)** for a side-by-side walkthrough.
|
|
12
12
|
|
|
13
|
+
### What's new since v0.8.18
|
|
14
|
+
|
|
15
|
+
- **`agentDef` / `skillDef`** (v0.8.22) — runtime fork / promote / retire / get / list / `verify` on the substrate. Lets a containerised app push agent + skill definitions to a remote loomcycle at startup without restarting it.
|
|
16
|
+
- **`listChannels`** (v0.9.x) — list operator-declared channels with aggregate stats (message_count, oldest/newest visible_at). The substrate companion to the existing Channel tool; useful for credential pickers + dashboards.
|
|
17
|
+
- **`streamUserRunStates`** (v0.9.x) — SSE stream of run state transitions scoped to one `user_id`. Yields `{ kind: "open" | "event", payload }` items until the connection closes (30-min server cap). The primary substrate hook for orchestration UIs that need to react when an agent run completes / fails / cancels.
|
|
18
|
+
- **Content signatures** (v0.9.x) — every `agent_defs` / `skill_defs` row now carries a deterministic `content_sha256`. Combined with the `verify` op and the `loomcycle hash agent|skill` CLI subcommand, this gives Docker-bundled operators a one-call answer to *"is what I have in my image identical to what's deployed?"* — see [Content signatures](#content-signatures-v09x) below for the end-to-end workflow.
|
|
19
|
+
- **Transcript first-cycle types** (v0.9.1) — `UserInputPayload` + `SystemPromptPayload` typed interfaces for the two new transcript events that surface "what the agent actually received" (the resolved system prompt + the caller's segments) as the first frames of every run.
|
|
20
|
+
- **n8n polish — `debug` toggle + `parentAgentId` filter** (v0.13.0) — opt-in synthetic `stream_open` / `stream_close` frames on `runStreaming` / `continueSession` / `streamUserRunStates` plus a client-side `parentAgentId` filter on `listUserAgents` + `streamUserRunStates`. Default behaviour is unchanged for existing callers; both knobs are off until set. See [Patterns](#patterns) for when to reach for them.
|
|
21
|
+
|
|
13
22
|
## Install
|
|
14
23
|
|
|
15
24
|
```bash
|
|
@@ -207,6 +216,165 @@ export async function POST(req: Request) {
|
|
|
207
216
|
- `fail_mode: "open"` (default) is right for telemetry hooks where a down receiver shouldn't break tool dispatch. `"closed"` is right for security hooks where a down receiver should fail the tool call (don't let bypassed payloads through).
|
|
208
217
|
- `allow_hosts` in `PreHookResult` is a **trust-sensitive surface** — it widens the agent's outbound network policy for one tool call. Server enforces an operator-yaml allowlist (`hooks.permit_host_widen.owners`); your owner has to be on that list for `allow_hosts` to take effect. See the SECURITY note in `internal/hooks/types.go` before using.
|
|
209
218
|
|
|
219
|
+
### Substrate admin: AgentDef + SkillDef (v0.8.22)
|
|
220
|
+
|
|
221
|
+
Two op-discriminated methods that mirror the in-process `AgentDef` / `SkillDef` built-in tools over HTTP. The same `op` values an agent's tool_use would invoke are reachable directly from your app code — useful for runtime fork / promote / retire / list, and for the `verify` op covered in [Content signatures](#content-signatures-v09x).
|
|
222
|
+
|
|
223
|
+
| Method | Returns | Notes |
|
|
224
|
+
|---|---|---|
|
|
225
|
+
| `agentDef(input)` | `Promise<SubstrateToolResponse>` | Op-discriminated. Mirrors `POST /v1/_agentdef`. |
|
|
226
|
+
| `skillDef(input)` | `Promise<SubstrateToolResponse>` | Op-discriminated. Mirrors `POST /v1/_skilldef`. |
|
|
227
|
+
|
|
228
|
+
The response type is intentionally `unknown` because the shape varies per op (`create`/`fork` return a row envelope; `list` returns `{name, versions: [...]}`; `verify` returns `AgentDefVerifyResult` / `SkillDefVerifyResult`). Cast / narrow as needed:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
import type { AgentDefRowResponse } from "@loomcycle/client";
|
|
232
|
+
|
|
233
|
+
const forked = (await client.agentDef({
|
|
234
|
+
op: "fork",
|
|
235
|
+
name: "researcher",
|
|
236
|
+
overlay: { system_prompt: "be very thorough", max_iterations: 32 },
|
|
237
|
+
promote: true,
|
|
238
|
+
})) as AgentDefRowResponse;
|
|
239
|
+
|
|
240
|
+
console.log(`forked def_id=${forked.def_id} hash=${forked.content_sha256}`);
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Operations on AgentDef: `create` / `fork` / `get` / `list` / `promote` / `retire` / **`verify`** (v0.9.x). SkillDef has the same set minus `retire`'s edge cases. See `internal/tools/builtin/agentdef.go` for the canonical input schema; each op enforces the agent's `agent_def_scopes` / `skill_def_scopes` capability gate from the operator yaml.
|
|
244
|
+
|
|
245
|
+
Refusals throw `SubstrateToolRefusedError` (a scope deny / empty body / allowed-tools widening); transport failures throw the usual typed errors (`AuthError`, `UnavailableError`, etc.).
|
|
246
|
+
|
|
247
|
+
### Channels + run-state stream (v0.9.x n8n Phase 0)
|
|
248
|
+
|
|
249
|
+
Two substrate-side surfaces added in the n8n integration's Phase 0 wire-API work. Useful for any orchestrator (not just n8n) that needs to see channel state or subscribe to run-state transitions.
|
|
250
|
+
|
|
251
|
+
| Method | Returns | Notes |
|
|
252
|
+
|---|---|---|
|
|
253
|
+
| `listChannels()` | `Promise<ListChannelsResponse>` | Operator-declared channels + aggregate stats (`message_count`, `oldest_visible_at`, `newest_visible_at`). Mirrors `GET /v1/_channels`. |
|
|
254
|
+
| `streamUserRunStates(userId, opts?)` | `AsyncIterable<RunStateStreamItem>` | SSE stream of run state transitions for one user. Yields one `{ kind: "open", ... }` frame then one `{ kind: "event", payload: RunStateEvent }` per matching transition until close. |
|
|
255
|
+
|
|
256
|
+
**Streaming run-state events** — for orchestration UIs that want to react when an agent run completes / fails / cancels:
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
import type { RunStateEvent } from "@loomcycle/client";
|
|
260
|
+
|
|
261
|
+
const ac = new AbortController();
|
|
262
|
+
const stream = client.streamUserRunStates(userId, {
|
|
263
|
+
statuses: ["completed", "failed", "cancelled"], // optional filter
|
|
264
|
+
agent: "researcher", // optional filter
|
|
265
|
+
signal: ac.signal,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
for await (const item of stream) {
|
|
269
|
+
if (item.kind === "open") {
|
|
270
|
+
console.log(`stream open for user=${item.payload.user_id}`);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
const evt: RunStateEvent = item.payload;
|
|
274
|
+
console.log(`${evt.agent}/${evt.run_id} -> ${evt.status} (stop_reason=${evt.stop_reason ?? "-"})`);
|
|
275
|
+
// ... persist to DB, push to UI websocket, fire webhook, etc.
|
|
276
|
+
}
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
The stream stays open for up to 30 minutes (server-enforced); reconnect on close for long-running orchestrators. Filters apply server-side; an empty filter delivers all transitions.
|
|
280
|
+
|
|
281
|
+
### Content signatures (v0.9.x)
|
|
282
|
+
|
|
283
|
+
**The bundle-vs-deployed comparison feature.** Every persisted `agent_defs` and `skill_defs` row carries a deterministic SHA-256 of its content-bearing fields (`content_sha256`). Combined with the CLI helper `loomcycle hash agent|skill <path>`, this lets Docker-bundled operators answer *"is what I have in my image identical to what's deployed?"* with one cheap call instead of fetching the full Definition JSONB and diffing it field by field.
|
|
284
|
+
|
|
285
|
+
**The workflow** — three steps, fully Dockerfile-friendly:
|
|
286
|
+
|
|
287
|
+
1. **At image-build time** (in your Dockerfile or CI): run the CLI against each bundled MD to capture the expected hash.
|
|
288
|
+
|
|
289
|
+
```dockerfile
|
|
290
|
+
# Dockerfile
|
|
291
|
+
COPY agents/ /bundle/agents/
|
|
292
|
+
COPY skills/ /bundle/skills/
|
|
293
|
+
RUN /usr/local/bin/loomcycle hash agent /bundle/agents/researcher.md > /bundle/agents/researcher.sha256
|
|
294
|
+
RUN /usr/local/bin/loomcycle hash skill /bundle/skills/summariser > /bundle/skills/summariser.sha256
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
2. **At container startup**: ask the deployed loomcycle whether each agent is in sync. Use `agentDef({op:"verify"})` / `skillDef({op:"verify"})` and narrow the response to `AgentDefVerifyResult` / `SkillDefVerifyResult`:
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
import { readFile } from "node:fs/promises";
|
|
301
|
+
import type { AgentDefVerifyResult } from "@loomcycle/client";
|
|
302
|
+
|
|
303
|
+
const localHash = (await readFile("/bundle/agents/researcher.sha256", "utf-8")).trim();
|
|
304
|
+
const verify = (await client.agentDef({
|
|
305
|
+
op: "verify",
|
|
306
|
+
name: "researcher",
|
|
307
|
+
content_sha256: localHash,
|
|
308
|
+
})) as AgentDefVerifyResult;
|
|
309
|
+
|
|
310
|
+
if (verify.matches) {
|
|
311
|
+
console.log("researcher in sync");
|
|
312
|
+
} else if (!verify.deployed) {
|
|
313
|
+
console.log("researcher not deployed yet; pushing first version");
|
|
314
|
+
await pushAgent("/bundle/agents/researcher.md"); // your set-agent helper
|
|
315
|
+
} else {
|
|
316
|
+
console.log(`researcher drifted; deployed=${verify.current_sha256} local=${localHash}; pushing update`);
|
|
317
|
+
await pushAgent("/bundle/agents/researcher.md");
|
|
318
|
+
}
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
3. **Pushing on mismatch** is `agentDef({op:"set"|"fork", overlay: {...}})` with the same content the YAML expresses, parsed from your bundle.
|
|
322
|
+
|
|
323
|
+
| Method | Returns | Notes |
|
|
324
|
+
|---|---|---|
|
|
325
|
+
| `agentDef({op:"verify", name, content_sha256})` | `Promise<AgentDefVerifyResult>` | `{matches, current_sha256, current_def_id, version, name, deployed}`. |
|
|
326
|
+
| `skillDef({op:"verify", name, content_sha256})` | `Promise<SkillDefVerifyResult>` | Same shape. |
|
|
327
|
+
|
|
328
|
+
**Key invariants:**
|
|
329
|
+
|
|
330
|
+
- `matches: true` only when both hashes are non-empty AND equal. An empty caller hash NEVER matches (no false-positive when the deployed row's hash is also empty due to a not-yet-completed backfill).
|
|
331
|
+
- `deployed: false` ⇒ `matches: false`. Use this to distinguish "no active row" (first deploy) from "drift" (push update).
|
|
332
|
+
- The CLI hash and the substrate's hash are guaranteed identical for matching content — both compute through the same Go function in `internal/agents.Sign`.
|
|
333
|
+
- Agent hash covers `name + description + system_prompt + allowed_tools + skills + model + provider + tier + effort + max_tokens + max_iterations + providers + models + memory_scopes + memory_quota_bytes`. Explicitly excluded: `def_id`, `version`, `created_at`, `retired`, **plus** `channels` and `*_scopes` (operator-yaml-only ACL fields that don't round-trip through `set` / `fork`).
|
|
334
|
+
- Skill hash covers `name + description + body + allowed_tools`. Skill bodies are normalised before hashing (CRLF → LF; trailing whitespace stripped) so editor drift doesn't cause spurious mismatches.
|
|
335
|
+
|
|
336
|
+
See `help(topic="content-signatures")` from inside an agent run for the full operator narrative.
|
|
337
|
+
|
|
338
|
+
### Transcript first-cycle types (v0.9.1)
|
|
339
|
+
|
|
340
|
+
Every run's persisted transcript now records two events that describe **what the agent actually received** before any model output:
|
|
341
|
+
|
|
342
|
+
- **`system_prompt`** — the resolved system prompt (AgentDef body + skill bodies, after overlay + merge), with provenance (`agent_def_id` + `skill_def_ids` map).
|
|
343
|
+
- **`user_input`** — the caller's `segments` from the original `POST /v1/runs`.
|
|
344
|
+
|
|
345
|
+
Surface them via `getTranscript(sessionId)` and narrow on `event.type`:
|
|
346
|
+
|
|
347
|
+
```ts
|
|
348
|
+
import type {
|
|
349
|
+
SystemPromptPayload,
|
|
350
|
+
TranscriptEvent,
|
|
351
|
+
UserInputPayload,
|
|
352
|
+
} from "@loomcycle/client";
|
|
353
|
+
|
|
354
|
+
const { events } = await client.getTranscript(sessionId);
|
|
355
|
+
|
|
356
|
+
for (const ev of events as TranscriptEvent[]) {
|
|
357
|
+
if (ev.type === "system_prompt") {
|
|
358
|
+
const p = ev.payload as SystemPromptPayload;
|
|
359
|
+
console.log(`prompt (def_id=${p.agent_def_id ?? "-"}): ${p.system_prompt.slice(0, 80)}...`);
|
|
360
|
+
if (p.skill_def_ids) {
|
|
361
|
+
for (const [skill, defId] of Object.entries(p.skill_def_ids)) {
|
|
362
|
+
console.log(` skill ${skill} resolved to def_id=${defId}`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
} else if (ev.type === "user_input") {
|
|
366
|
+
const segs = ev.payload as UserInputPayload[];
|
|
367
|
+
console.log(`caller sent ${segs.length} segment(s):`);
|
|
368
|
+
for (const seg of segs) {
|
|
369
|
+
const firstText = seg.content.find((c) => c.type.endsWith("text"))?.text ?? "";
|
|
370
|
+
console.log(` [${seg.role}] ${firstText.slice(0, 80)}`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
These events are part of the persisted transcript (not the live `runStreaming` event channel — they fire before the first model call, before the SSE stream consumer typically attaches). Existing transcript readers that don't know the new types see them as `event: unknown` with the typed body in `payload` and ignore them safely.
|
|
377
|
+
|
|
210
378
|
## Errors
|
|
211
379
|
|
|
212
380
|
Non-2xx responses throw typed subclasses of `LoomcycleError`. The original HTTP status is on `e.status`; the truncated response body is on `e.bodyText` (≤1 KiB).
|
|
@@ -254,6 +422,80 @@ try {
|
|
|
254
422
|
}
|
|
255
423
|
```
|
|
256
424
|
|
|
425
|
+
## Patterns
|
|
426
|
+
|
|
427
|
+
A short field guide for the common consumer shapes — when to use which method, what each one costs, and how the v0.9.x polish hooks (`debug`, `parentAgentId`) fit in.
|
|
428
|
+
|
|
429
|
+
### Sync vs async run consumption
|
|
430
|
+
|
|
431
|
+
`runStreaming` and `continueSession` are **sync**: the iterator stays alive for the FULL duration of the run. Use them when:
|
|
432
|
+
|
|
433
|
+
- You have a single agent run and want to render its output progressively (UI streaming, CLI tail-like display).
|
|
434
|
+
- The caller can hold a connection per active run without worker-thread starvation.
|
|
435
|
+
|
|
436
|
+
For async fire-and-forget patterns (the n8n trigger node's model), use `streamUserRunStates` instead:
|
|
437
|
+
|
|
438
|
+
```ts
|
|
439
|
+
// Don't do this in an n8n worker — blocks the worker for the full run:
|
|
440
|
+
for await (const ev of client.runStreaming({ agent: "long-task", segments })) { ... }
|
|
441
|
+
|
|
442
|
+
// Do this instead — kick off the run, get back a tracking ID, and watch run-state transitions:
|
|
443
|
+
const seedRun = await runOnce(...); // your one-shot dispatch
|
|
444
|
+
for await (const item of client.streamUserRunStates(userId, {
|
|
445
|
+
statuses: ["completed", "failed", "cancelled"],
|
|
446
|
+
})) {
|
|
447
|
+
if (item.kind === "event" && item.payload.agent_id === seedRun.agentId) {
|
|
448
|
+
// fire downstream workflow, persist to DB, etc.
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
`streamUserRunStates` holds ONE connection per user regardless of how many concurrent runs that user has. Server-enforced 30-minute timeout; reconnect on close.
|
|
455
|
+
|
|
456
|
+
### `debug: true` — synthetic open/close frames
|
|
457
|
+
|
|
458
|
+
All three streaming methods (`runStreaming`, `continueSession`, `streamUserRunStates`) accept `debug?: boolean`. Default off; behaviour is exactly the pre-v0.9.x shape.
|
|
459
|
+
|
|
460
|
+
When `debug: true`:
|
|
461
|
+
- `runStreaming` / `continueSession` brackets the real events with `{ type: "_meta", meta_subtype: "stream_open" | "stream_close", meta_reason }` frames. The leading-underscore type signals "client-synthesized; never on the wire." The `meta_reason` is `"eof"` on clean close or an error class name (e.g. `"AuthError"`) when the inner iterator threw mid-stream.
|
|
462
|
+
- `streamUserRunStates` yields an extra `{ kind: "close", payload: { reason } }` item on stream end (in addition to the existing `kind: "open" | "event"` frames).
|
|
463
|
+
|
|
464
|
+
```ts
|
|
465
|
+
for await (const ev of client.runStreaming({ agent: "qa", segments, debug: true })) {
|
|
466
|
+
if (ev.type === "_meta") {
|
|
467
|
+
console.log(`[stream ${ev.meta_subtype}] reason=${ev.meta_reason}`);
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
// ... handle real events
|
|
471
|
+
}
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
Use case: n8n trigger nodes that surface "stream re-opened / closed" log entries to the operator without inferring from event timing. Non-n8n consumers don't need to know the toggle exists.
|
|
475
|
+
|
|
476
|
+
### `parentAgentId` — client-side narrowing
|
|
477
|
+
|
|
478
|
+
`listUserAgents(userId, { parentAgentId })` and `streamUserRunStates(userId, { parentAgentId })` apply a client-side filter on the run's `parent_agent_id`. The server still returns / streams the full set; the adapter trims before yielding.
|
|
479
|
+
|
|
480
|
+
```ts
|
|
481
|
+
// All sub-runs spawned by a specific parent (one-shot snapshot)
|
|
482
|
+
const subRuns = await client.listUserAgents(userId, {
|
|
483
|
+
parentAgentId: "ag_parent_abc",
|
|
484
|
+
status: "running",
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
// Same shape, but as a live stream
|
|
488
|
+
for await (const item of client.streamUserRunStates(userId, {
|
|
489
|
+
parentAgentId: "ag_parent_abc",
|
|
490
|
+
statuses: ["completed", "failed"],
|
|
491
|
+
})) {
|
|
492
|
+
// Only events whose payload.parent_agent_id === "ag_parent_abc"
|
|
493
|
+
// (open and close frames always pass through).
|
|
494
|
+
}
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
**Cost note:** because the filter is client-side, the server doesn't shed any load. If the result set is large enough that you care about server-side narrowing, raise an issue — server-side `?parent_agent_id=` is a planned addition.
|
|
498
|
+
|
|
257
499
|
## Why HTTP, not gRPC
|
|
258
500
|
|
|
259
501
|
Loomcycle's HTTP+SSE surface is the canonical wire contract — every gRPC RPC has an HTTP equivalent (see `internal/api/http/server.go` for the route registrations). The Python adapter (gRPC) and this TS adapter (HTTP) cover the same surface; the choice between them is about ecosystem fit, not capability. HTTP+SSE works through every reverse proxy without special config; gRPC needs HTTP/2 + protoc round trips. For Node.js orchestrators that already have `fetch` in scope, HTTP is the simpler dependency.
|
package/dist/client.d.ts
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* via fetch-helpers.ts:raiseFromResponse — see README.md for the
|
|
24
24
|
* full mapping table.
|
|
25
25
|
*/
|
|
26
|
-
import type { Agent, AgentEvent, AgentStatus, CancelAgentResult, ClientOptions, ContinueOptions, CreateSnapshotOptions, HealthResponse, Hook, InterruptListResponse, InterruptStatus, ListUsersResponse, MemoryEntriesResponse, MemoryEntryResponse, MemoryScopeIDsResponse, MemoryScopesResponse, PauseResult, RegisterHookOptions, RegisterHookResponse, ResolveInterruptOptions, ResumeResult, RunOptions, RuntimeStateResponse, SnapshotCreateResponse, SnapshotDescriptor, SnapshotEnvelope, SnapshotRestoreResponse, SubstrateToolInput, SubstrateToolResponse, TranscriptResponse } from "./types.js";
|
|
26
|
+
import type { Agent, AgentEvent, AgentStatus, CancelAgentResult, ClientOptions, ContinueOptions, CreateSnapshotOptions, HealthResponse, Hook, InterruptListResponse, InterruptStatus, AckChannelOptions, ChannelAckResult, ChannelPeekResult, ChannelPublishResult, ChannelSubscribeResult, ListChannelsResponse, PeekChannelOptions, PublishChannelOptions, SubscribeChannelOptions, ListUsersResponse, MemoryEntriesResponse, MemoryEntryResponse, MemoryScopeIDsResponse, MemoryScopesResponse, PauseResult, RegisterHookOptions, RegisterHookResponse, ResolveInterruptOptions, ResumeResult, RunOptions, RunStateStreamItem, RuntimeStateResponse, SnapshotCreateResponse, SnapshotDescriptor, SnapshotEnvelope, SnapshotRestoreResponse, StreamUserRunStatesOptions, SubstrateToolInput, SubstrateToolResponse, TranscriptResponse } from "./types.js";
|
|
27
27
|
export declare class LoomcycleClient {
|
|
28
28
|
private ctx;
|
|
29
29
|
constructor(opts?: ClientOptions);
|
|
@@ -34,6 +34,19 @@ export declare class LoomcycleClient {
|
|
|
34
34
|
* Errors during the run surface as `{ type: "error", error }` events;
|
|
35
35
|
* only transport / HTTP-level failures throw — and those throw typed
|
|
36
36
|
* errors (e.g. AuthError for 401, BackpressureError for 429).
|
|
37
|
+
*
|
|
38
|
+
* **Blocking semantics.** This iterator is alive for the FULL
|
|
39
|
+
* duration of the run — typically seconds, occasionally minutes for
|
|
40
|
+
* long tool chains. Callers that need fire-and-forget completion
|
|
41
|
+
* notifications (n8n's worker model, dashboards that don't want to
|
|
42
|
+
* hold a connection per active run) should subscribe to
|
|
43
|
+
* {@link LoomcycleClient.streamUserRunStates} instead, which yields
|
|
44
|
+
* one terminal-state frame per completed run without holding the
|
|
45
|
+
* run's stream open.
|
|
46
|
+
*
|
|
47
|
+
* v0.9.x — pass `opts.debug = true` to emit synthetic
|
|
48
|
+
* `{ type: "_meta", meta_subtype: "stream_open" | "stream_close" }`
|
|
49
|
+
* events around the real frames. Silent (default) when omitted.
|
|
37
50
|
*/
|
|
38
51
|
runStreaming(opts: RunOptions): AsyncIterable<AgentEvent>;
|
|
39
52
|
/**
|
|
@@ -44,6 +57,14 @@ export declare class LoomcycleClient {
|
|
|
44
57
|
* Raises SessionNotFoundError when sessionId is unknown,
|
|
45
58
|
* SessionBusyError when another request is in flight on the same
|
|
46
59
|
* session.
|
|
60
|
+
*
|
|
61
|
+
* **Blocking semantics.** Same as {@link LoomcycleClient.runStreaming} —
|
|
62
|
+
* the iterator stays alive for the duration of the new run. For
|
|
63
|
+
* async fire-and-forget completion patterns, see
|
|
64
|
+
* {@link LoomcycleClient.streamUserRunStates}.
|
|
65
|
+
*
|
|
66
|
+
* v0.9.x — pass `opts.debug = true` for synthetic
|
|
67
|
+
* `_meta` open/close events.
|
|
47
68
|
*/
|
|
48
69
|
continueSession(opts: ContinueOptions): AsyncIterable<AgentEvent>;
|
|
49
70
|
/** Read one agent's status + usage stats. Raises AgentNotFoundError
|
|
@@ -58,9 +79,16 @@ export declare class LoomcycleClient {
|
|
|
58
79
|
reason?: string;
|
|
59
80
|
signal?: AbortSignal;
|
|
60
81
|
}): Promise<CancelAgentResult>;
|
|
61
|
-
/** List a user's recent agent runs, optionally filtered by status.
|
|
82
|
+
/** List a user's recent agent runs, optionally filtered by status.
|
|
83
|
+
*
|
|
84
|
+
* v0.9.x — `parentAgentId` narrows the result CLIENT-SIDE to runs
|
|
85
|
+
* whose `parent_agent_id` matches. The server still returns the
|
|
86
|
+
* full set (server-side `?parent_agent_id=` filter is a future
|
|
87
|
+
* request); the adapter trims before returning. Useful for the
|
|
88
|
+
* n8n trigger pattern "show me all sub-runs spawned by parent X." */
|
|
62
89
|
listUserAgents(userId: string, opts?: {
|
|
63
90
|
status?: AgentStatus;
|
|
91
|
+
parentAgentId?: string;
|
|
64
92
|
signal?: AbortSignal;
|
|
65
93
|
}): Promise<Agent[]>;
|
|
66
94
|
/** Read the full event log for a session. Each entry has seq,
|
|
@@ -233,7 +261,101 @@ export declare class LoomcycleClient {
|
|
|
233
261
|
skillDef(input: SubstrateToolInput, opts?: {
|
|
234
262
|
signal?: AbortSignal;
|
|
235
263
|
}): Promise<SubstrateToolResponse>;
|
|
264
|
+
/** Invoke the v0.9.x MCPServerDef substrate tool over HTTP.
|
|
265
|
+
* Dynamic MCP server registration — register an HTTP /
|
|
266
|
+
* Streamable-HTTP MCP server at runtime so its tools become
|
|
267
|
+
* callable from any agent's `allowed_tools` list without a yaml
|
|
268
|
+
* edit + restart.
|
|
269
|
+
*
|
|
270
|
+
* Operator-admin-only: this endpoint requires the bearer token.
|
|
271
|
+
*
|
|
272
|
+
* Op-discriminated input: `{op: "create" | "fork" | "get" | "list"
|
|
273
|
+
* | "promote" | "retire" | "rediscover" | "verify", ...}`. Returns
|
|
274
|
+
* shape varies — narrow with {@link MCPServerDefRowResponse} for
|
|
275
|
+
* create/fork/get/list rows, {@link MCPServerDefVerifyResult} for
|
|
276
|
+
* verify responses.
|
|
277
|
+
*
|
|
278
|
+
* Hard constraints (substrate refuses these):
|
|
279
|
+
* - Transport must be `http` or `streamable-http` (stdio stays
|
|
280
|
+
* yaml-only — dynamic registration doesn't allow process spawn).
|
|
281
|
+
* - URL hostname must be in LOOMCYCLE_HTTP_HOST_ALLOWLIST (SSRF
|
|
282
|
+
* defence at the registration boundary).
|
|
283
|
+
* - Name colliding with a static cfg.MCPServers entry is refused
|
|
284
|
+
* (yaml is ground truth; use a different name).
|
|
285
|
+
*
|
|
286
|
+
* Raises {@link SubstrateToolRefusedError} on tool-level refusals
|
|
287
|
+
* (transport/host/yaml-name); {@link InvalidArgumentError} on 400
|
|
288
|
+
* (malformed JSON); {@link AuthError} on 401. */
|
|
289
|
+
mcpServerDef(input: SubstrateToolInput, opts?: {
|
|
290
|
+
signal?: AbortSignal;
|
|
291
|
+
}): Promise<SubstrateToolResponse>;
|
|
236
292
|
/** Shared SSE POST → stream-of-AgentEvent path. Used by
|
|
237
|
-
* runStreaming + continueSession.
|
|
293
|
+
* runStreaming + continueSession.
|
|
294
|
+
*
|
|
295
|
+
* When `debug` is true, the iterator yields a synthetic
|
|
296
|
+
* `{ type: "_meta", meta_subtype: "stream_open" }` before any real
|
|
297
|
+
* events AND a `{ type: "_meta", meta_subtype: "stream_close",
|
|
298
|
+
* meta_reason }` on EOF / abort / error. The default is silent
|
|
299
|
+
* (matches pre-v0.9.x behaviour). */
|
|
238
300
|
private streamSSE;
|
|
301
|
+
/** List every operator-declared channel with aggregate stats
|
|
302
|
+
* (message_count, oldest_visible_at, newest_visible_at).
|
|
303
|
+
* Channels with no published messages still appear with
|
|
304
|
+
* message_count=0. Orphaned message rows for un-declared channels
|
|
305
|
+
* also appear (forensic visibility). Mirrors GET /v1/_channels. */
|
|
306
|
+
listChannels(opts?: {
|
|
307
|
+
signal?: AbortSignal;
|
|
308
|
+
}): Promise<ListChannelsResponse>;
|
|
309
|
+
/** Publish a JSON payload to an operator-declared channel. Mirrors
|
|
310
|
+
* the in-band Channel tool's publish op semantics — including
|
|
311
|
+
* deferred delivery via `deliverAt` (RFC3339Nano).
|
|
312
|
+
*
|
|
313
|
+
* Errors:
|
|
314
|
+
* - {@link NotFoundError} (404) when the channel isn't in operator
|
|
315
|
+
* yaml. The wire `code` is `channel_not_declared`.
|
|
316
|
+
* - {@link InvalidArgumentError} (400) on invalid scope / payload.
|
|
317
|
+
* - {@link AuthError} (401) on bearer mismatch. */
|
|
318
|
+
publishChannel(channel: string, opts: PublishChannelOptions): Promise<ChannelPublishResult>;
|
|
319
|
+
/** Read the next batch of messages from a channel. Single-round-
|
|
320
|
+
* trip long-poll: returns immediately if messages are present,
|
|
321
|
+
* otherwise waits up to `waitMs` for a publish. AUTO-COMMITS the
|
|
322
|
+
* cursor on a non-empty batch.
|
|
323
|
+
*
|
|
324
|
+
* For at-least-once delivery (crash safety between "loomcycle
|
|
325
|
+
* returned the batch" and "consumer finished processing"), use
|
|
326
|
+
* {@link LoomcycleClient.peekChannel} + an explicit
|
|
327
|
+
* {@link LoomcycleClient.ackChannel} after durable processing. */
|
|
328
|
+
subscribeChannel(channel: string, opts: SubscribeChannelOptions): Promise<ChannelSubscribeResult>;
|
|
329
|
+
/** Non-destructive read — never advances the committed cursor.
|
|
330
|
+
* Use for at-least-once consumption patterns: peek, process the
|
|
331
|
+
* batch durably, then `ackChannel` to advance. Multiple consumers
|
|
332
|
+
* can peek the same channel without disturbing each other. */
|
|
333
|
+
peekChannel(channel: string, opts: PeekChannelOptions): Promise<ChannelPeekResult>;
|
|
334
|
+
/** Advance the committed cursor for a (channel, scope, scope_id)
|
|
335
|
+
* tuple. Cursor must be monotonically forward — older cursors
|
|
336
|
+
* raise a {@link ConflictError} (HTTP 409, code
|
|
337
|
+
* `channel_cursor_regression`). */
|
|
338
|
+
ackChannel(channel: string, opts: AckChannelOptions): Promise<ChannelAckResult>;
|
|
339
|
+
/** Subscribe to run state transitions for one user_id via SSE.
|
|
340
|
+
* Yields one `{ kind: "open", ... }` item first (confirms the
|
|
341
|
+
* connection is live), then one `{ kind: "event", ... }` per
|
|
342
|
+
* matching state transition until the stream closes.
|
|
343
|
+
*
|
|
344
|
+
* The stream stays open for at most 30 minutes (server-enforced).
|
|
345
|
+
* Callers running indefinitely should reconnect on close.
|
|
346
|
+
*
|
|
347
|
+
* Errors during the stream throw — they do NOT surface as items.
|
|
348
|
+
* Pass an AbortSignal to terminate cleanly from the consumer side.
|
|
349
|
+
*
|
|
350
|
+
* v0.9.x options:
|
|
351
|
+
* - `parentAgentId` — client-side filter: only `kind: "event"`
|
|
352
|
+
* items whose payload's `parent_agent_id` matches are yielded.
|
|
353
|
+
* The server still streams every matching event; the adapter
|
|
354
|
+
* filters before yielding. Empty/omitted = no filter.
|
|
355
|
+
* - `debug` — when true, an additional `{ kind: "close", payload:
|
|
356
|
+
* { reason } }` item is yielded when the stream ends (EOF,
|
|
357
|
+
* abort, or pre-yield error). Useful for n8n nodes that surface
|
|
358
|
+
* "stream re-opened / closed" log entries without inferring
|
|
359
|
+
* from timing. Default false. */
|
|
360
|
+
streamUserRunStates(userId: string, opts?: StreamUserRunStatesOptions): AsyncIterable<RunStateStreamItem>;
|
|
239
361
|
}
|
package/dist/client.js
CHANGED
|
@@ -42,6 +42,19 @@ export class LoomcycleClient {
|
|
|
42
42
|
* Errors during the run surface as `{ type: "error", error }` events;
|
|
43
43
|
* only transport / HTTP-level failures throw — and those throw typed
|
|
44
44
|
* errors (e.g. AuthError for 401, BackpressureError for 429).
|
|
45
|
+
*
|
|
46
|
+
* **Blocking semantics.** This iterator is alive for the FULL
|
|
47
|
+
* duration of the run — typically seconds, occasionally minutes for
|
|
48
|
+
* long tool chains. Callers that need fire-and-forget completion
|
|
49
|
+
* notifications (n8n's worker model, dashboards that don't want to
|
|
50
|
+
* hold a connection per active run) should subscribe to
|
|
51
|
+
* {@link LoomcycleClient.streamUserRunStates} instead, which yields
|
|
52
|
+
* one terminal-state frame per completed run without holding the
|
|
53
|
+
* run's stream open.
|
|
54
|
+
*
|
|
55
|
+
* v0.9.x — pass `opts.debug = true` to emit synthetic
|
|
56
|
+
* `{ type: "_meta", meta_subtype: "stream_open" | "stream_close" }`
|
|
57
|
+
* events around the real frames. Silent (default) when omitted.
|
|
45
58
|
*/
|
|
46
59
|
async *runStreaming(opts) {
|
|
47
60
|
// Build the body conditionally so omitted fields stay off the wire.
|
|
@@ -73,7 +86,7 @@ export class LoomcycleClient {
|
|
|
73
86
|
body.user_tier = opts.userTier;
|
|
74
87
|
if (opts.userBearer !== undefined)
|
|
75
88
|
body.user_bearer = opts.userBearer;
|
|
76
|
-
yield* this.streamSSE("/v1/runs", body, opts.signal);
|
|
89
|
+
yield* this.streamSSE("/v1/runs", body, opts.signal, opts.debug);
|
|
77
90
|
}
|
|
78
91
|
/**
|
|
79
92
|
* Continue an existing session with a new run. The session's prior
|
|
@@ -83,6 +96,14 @@ export class LoomcycleClient {
|
|
|
83
96
|
* Raises SessionNotFoundError when sessionId is unknown,
|
|
84
97
|
* SessionBusyError when another request is in flight on the same
|
|
85
98
|
* session.
|
|
99
|
+
*
|
|
100
|
+
* **Blocking semantics.** Same as {@link LoomcycleClient.runStreaming} —
|
|
101
|
+
* the iterator stays alive for the duration of the new run. For
|
|
102
|
+
* async fire-and-forget completion patterns, see
|
|
103
|
+
* {@link LoomcycleClient.streamUserRunStates}.
|
|
104
|
+
*
|
|
105
|
+
* v0.9.x — pass `opts.debug = true` for synthetic
|
|
106
|
+
* `_meta` open/close events.
|
|
86
107
|
*/
|
|
87
108
|
async *continueSession(opts) {
|
|
88
109
|
const body = {
|
|
@@ -101,7 +122,7 @@ export class LoomcycleClient {
|
|
|
101
122
|
body.user_tier = opts.userTier;
|
|
102
123
|
if (opts.userBearer !== undefined)
|
|
103
124
|
body.user_bearer = opts.userBearer;
|
|
104
|
-
yield* this.streamSSE(`/v1/sessions/${encodeURIComponent(opts.sessionId)}/messages`, body, opts.signal);
|
|
125
|
+
yield* this.streamSSE(`/v1/sessions/${encodeURIComponent(opts.sessionId)}/messages`, body, opts.signal, opts.debug);
|
|
105
126
|
}
|
|
106
127
|
// ---- Agent metadata ----
|
|
107
128
|
/** Read one agent's status + usage stats. Raises AgentNotFoundError
|
|
@@ -116,11 +137,21 @@ export class LoomcycleClient {
|
|
|
116
137
|
const resp = await postJSON(this.ctx, `/v1/agents/${encodeURIComponent(agentId)}/cancel`, { reason: opts?.reason ?? "" }, opts);
|
|
117
138
|
return { cancelledCount: resp.cancelled_count };
|
|
118
139
|
}
|
|
119
|
-
/** List a user's recent agent runs, optionally filtered by status.
|
|
140
|
+
/** List a user's recent agent runs, optionally filtered by status.
|
|
141
|
+
*
|
|
142
|
+
* v0.9.x — `parentAgentId` narrows the result CLIENT-SIDE to runs
|
|
143
|
+
* whose `parent_agent_id` matches. The server still returns the
|
|
144
|
+
* full set (server-side `?parent_agent_id=` filter is a future
|
|
145
|
+
* request); the adapter trims before returning. Useful for the
|
|
146
|
+
* n8n trigger pattern "show me all sub-runs spawned by parent X." */
|
|
120
147
|
async listUserAgents(userId, opts) {
|
|
121
148
|
const q = opts?.status ? `?status=${encodeURIComponent(opts.status)}` : "";
|
|
122
149
|
const resp = await jsonFetch(this.ctx, `/v1/users/${encodeURIComponent(userId)}/agents${q}`, opts);
|
|
123
|
-
|
|
150
|
+
const all = resp.agents ?? [];
|
|
151
|
+
if (opts?.parentAgentId !== undefined && opts.parentAgentId !== "") {
|
|
152
|
+
return all.filter((a) => a.parent_agent_id === opts.parentAgentId);
|
|
153
|
+
}
|
|
154
|
+
return all;
|
|
124
155
|
}
|
|
125
156
|
/** Read the full event log for a session. Each entry has seq,
|
|
126
157
|
* run_id, ts_ns, type, event (the providers.Event payload). */
|
|
@@ -360,10 +391,44 @@ export class LoomcycleClient {
|
|
|
360
391
|
async skillDef(input, opts) {
|
|
361
392
|
return postJSON(this.ctx, "/v1/_skilldef", input, opts);
|
|
362
393
|
}
|
|
394
|
+
/** Invoke the v0.9.x MCPServerDef substrate tool over HTTP.
|
|
395
|
+
* Dynamic MCP server registration — register an HTTP /
|
|
396
|
+
* Streamable-HTTP MCP server at runtime so its tools become
|
|
397
|
+
* callable from any agent's `allowed_tools` list without a yaml
|
|
398
|
+
* edit + restart.
|
|
399
|
+
*
|
|
400
|
+
* Operator-admin-only: this endpoint requires the bearer token.
|
|
401
|
+
*
|
|
402
|
+
* Op-discriminated input: `{op: "create" | "fork" | "get" | "list"
|
|
403
|
+
* | "promote" | "retire" | "rediscover" | "verify", ...}`. Returns
|
|
404
|
+
* shape varies — narrow with {@link MCPServerDefRowResponse} for
|
|
405
|
+
* create/fork/get/list rows, {@link MCPServerDefVerifyResult} for
|
|
406
|
+
* verify responses.
|
|
407
|
+
*
|
|
408
|
+
* Hard constraints (substrate refuses these):
|
|
409
|
+
* - Transport must be `http` or `streamable-http` (stdio stays
|
|
410
|
+
* yaml-only — dynamic registration doesn't allow process spawn).
|
|
411
|
+
* - URL hostname must be in LOOMCYCLE_HTTP_HOST_ALLOWLIST (SSRF
|
|
412
|
+
* defence at the registration boundary).
|
|
413
|
+
* - Name colliding with a static cfg.MCPServers entry is refused
|
|
414
|
+
* (yaml is ground truth; use a different name).
|
|
415
|
+
*
|
|
416
|
+
* Raises {@link SubstrateToolRefusedError} on tool-level refusals
|
|
417
|
+
* (transport/host/yaml-name); {@link InvalidArgumentError} on 400
|
|
418
|
+
* (malformed JSON); {@link AuthError} on 401. */
|
|
419
|
+
async mcpServerDef(input, opts) {
|
|
420
|
+
return postJSON(this.ctx, "/v1/_mcpserverdef", input, opts);
|
|
421
|
+
}
|
|
363
422
|
// ---- Internal helpers ----
|
|
364
423
|
/** Shared SSE POST → stream-of-AgentEvent path. Used by
|
|
365
|
-
* runStreaming + continueSession.
|
|
366
|
-
|
|
424
|
+
* runStreaming + continueSession.
|
|
425
|
+
*
|
|
426
|
+
* When `debug` is true, the iterator yields a synthetic
|
|
427
|
+
* `{ type: "_meta", meta_subtype: "stream_open" }` before any real
|
|
428
|
+
* events AND a `{ type: "_meta", meta_subtype: "stream_close",
|
|
429
|
+
* meta_reason }` on EOF / abort / error. The default is silent
|
|
430
|
+
* (matches pre-v0.9.x behaviour). */
|
|
431
|
+
async *streamSSE(path, body, signal, debug) {
|
|
367
432
|
const headers = {
|
|
368
433
|
"Content-Type": "application/json",
|
|
369
434
|
// Accept BOTH text/event-stream (the success path) AND
|
|
@@ -388,6 +453,278 @@ export class LoomcycleClient {
|
|
|
388
453
|
if (!resp.body) {
|
|
389
454
|
throw new Error("loomcycle: response has no body");
|
|
390
455
|
}
|
|
391
|
-
|
|
456
|
+
if (!debug) {
|
|
457
|
+
// Silent default — pre-v0.9.x shape.
|
|
458
|
+
yield* parseSSE(resp.body.getReader());
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
// Debug shape: synthetic open + close around the real stream.
|
|
462
|
+
// The open frame carries no meta_reason — the frame itself IS the
|
|
463
|
+
// signal. The close frame's meta_reason distinguishes normal EOF
|
|
464
|
+
// from caller-side abort or a typed-error throw mid-stream.
|
|
465
|
+
//
|
|
466
|
+
// Close is emitted on both paths via try/catch/throw: success path
|
|
467
|
+
// emits AFTER the try block; error path emits INSIDE the catch
|
|
468
|
+
// before re-throwing. NOT a try/finally — the duplication is
|
|
469
|
+
// intentional so the close-then-throw ordering is explicit and
|
|
470
|
+
// a refactor adding `finally` doesn't accidentally double-emit.
|
|
471
|
+
yield { type: "_meta", meta_subtype: "stream_open" };
|
|
472
|
+
let closeReason = "eof";
|
|
473
|
+
try {
|
|
474
|
+
yield* parseSSE(resp.body.getReader());
|
|
475
|
+
}
|
|
476
|
+
catch (e) {
|
|
477
|
+
// Capture the error type for the close frame, then re-throw so
|
|
478
|
+
// typed-error handling at the consumer site still works.
|
|
479
|
+
closeReason =
|
|
480
|
+
e && typeof e === "object" && "name" in e
|
|
481
|
+
? String(e.name)
|
|
482
|
+
: "error";
|
|
483
|
+
yield {
|
|
484
|
+
type: "_meta",
|
|
485
|
+
meta_subtype: "stream_close",
|
|
486
|
+
meta_reason: closeReason,
|
|
487
|
+
};
|
|
488
|
+
throw e;
|
|
489
|
+
}
|
|
490
|
+
yield {
|
|
491
|
+
type: "_meta",
|
|
492
|
+
meta_subtype: "stream_close",
|
|
493
|
+
meta_reason: closeReason,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
// ---- v0.9.x n8n RFC Phase 0 ----
|
|
497
|
+
/** List every operator-declared channel with aggregate stats
|
|
498
|
+
* (message_count, oldest_visible_at, newest_visible_at).
|
|
499
|
+
* Channels with no published messages still appear with
|
|
500
|
+
* message_count=0. Orphaned message rows for un-declared channels
|
|
501
|
+
* also appear (forensic visibility). Mirrors GET /v1/_channels. */
|
|
502
|
+
async listChannels(opts) {
|
|
503
|
+
return await jsonFetch(this.ctx, "/v1/_channels", opts);
|
|
504
|
+
}
|
|
505
|
+
// ---- v0.9.x Channel CRUD ----
|
|
506
|
+
//
|
|
507
|
+
// Four bearer-authed ops mirroring the in-band Channel tool's
|
|
508
|
+
// publish/subscribe/peek/ack. Two URL families behind the
|
|
509
|
+
// `scope` field:
|
|
510
|
+
// - scope: "global" → POST /v1/_channels/{name}/{op} (admin)
|
|
511
|
+
// - scope: "user" → POST /v1/users/{userId}/channels/{name}/{op}
|
|
512
|
+
//
|
|
513
|
+
// The same operator bearer token guards both surfaces; the per-user
|
|
514
|
+
// URL embeds the user_id in the path so a caller can't forge a
|
|
515
|
+
// different user_id by lying in the body.
|
|
516
|
+
//
|
|
517
|
+
// Subscribe is a SINGLE-ROUND-TRIP long-poll, not an open stream.
|
|
518
|
+
// For continuous delivery, call `subscribeChannel` in a loop (the
|
|
519
|
+
// n8n trigger node's pattern). Auto-commits the cursor on non-empty
|
|
520
|
+
// batches (at-most-once shape) — use `peekChannel` + explicit
|
|
521
|
+
// `ackChannel` for at-least-once semantics.
|
|
522
|
+
/** Publish a JSON payload to an operator-declared channel. Mirrors
|
|
523
|
+
* the in-band Channel tool's publish op semantics — including
|
|
524
|
+
* deferred delivery via `deliverAt` (RFC3339Nano).
|
|
525
|
+
*
|
|
526
|
+
* Errors:
|
|
527
|
+
* - {@link NotFoundError} (404) when the channel isn't in operator
|
|
528
|
+
* yaml. The wire `code` is `channel_not_declared`.
|
|
529
|
+
* - {@link InvalidArgumentError} (400) on invalid scope / payload.
|
|
530
|
+
* - {@link AuthError} (401) on bearer mismatch. */
|
|
531
|
+
async publishChannel(channel, opts) {
|
|
532
|
+
const path = channelOpPath(channel, opts.scope, opts.userId, "publish");
|
|
533
|
+
const body = { payload: opts.payload };
|
|
534
|
+
if (opts.deliverAt)
|
|
535
|
+
body.deliver_at = opts.deliverAt;
|
|
536
|
+
return postJSON(this.ctx, path, body, {
|
|
537
|
+
signal: opts.signal,
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
/** Read the next batch of messages from a channel. Single-round-
|
|
541
|
+
* trip long-poll: returns immediately if messages are present,
|
|
542
|
+
* otherwise waits up to `waitMs` for a publish. AUTO-COMMITS the
|
|
543
|
+
* cursor on a non-empty batch.
|
|
544
|
+
*
|
|
545
|
+
* For at-least-once delivery (crash safety between "loomcycle
|
|
546
|
+
* returned the batch" and "consumer finished processing"), use
|
|
547
|
+
* {@link LoomcycleClient.peekChannel} + an explicit
|
|
548
|
+
* {@link LoomcycleClient.ackChannel} after durable processing. */
|
|
549
|
+
async subscribeChannel(channel, opts) {
|
|
550
|
+
const path = channelOpPath(channel, opts.scope, opts.userId, "subscribe");
|
|
551
|
+
const body = {};
|
|
552
|
+
if (opts.fromCursor !== undefined)
|
|
553
|
+
body.from_cursor = opts.fromCursor;
|
|
554
|
+
if (opts.maxMessages !== undefined)
|
|
555
|
+
body.max_messages = opts.maxMessages;
|
|
556
|
+
if (opts.waitMs !== undefined)
|
|
557
|
+
body.wait_ms = opts.waitMs;
|
|
558
|
+
return postJSON(this.ctx, path, body, {
|
|
559
|
+
signal: opts.signal,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
/** Non-destructive read — never advances the committed cursor.
|
|
563
|
+
* Use for at-least-once consumption patterns: peek, process the
|
|
564
|
+
* batch durably, then `ackChannel` to advance. Multiple consumers
|
|
565
|
+
* can peek the same channel without disturbing each other. */
|
|
566
|
+
async peekChannel(channel, opts) {
|
|
567
|
+
let path = channelOpPath(channel, opts.scope, opts.userId, "peek");
|
|
568
|
+
const params = [];
|
|
569
|
+
if (opts.fromCursor)
|
|
570
|
+
params.push(`from_cursor=${encodeURIComponent(opts.fromCursor)}`);
|
|
571
|
+
if (opts.maxMessages)
|
|
572
|
+
params.push(`max_messages=${opts.maxMessages}`);
|
|
573
|
+
if (params.length > 0)
|
|
574
|
+
path += `?${params.join("&")}`;
|
|
575
|
+
return jsonFetch(this.ctx, path, { signal: opts.signal });
|
|
576
|
+
}
|
|
577
|
+
/** Advance the committed cursor for a (channel, scope, scope_id)
|
|
578
|
+
* tuple. Cursor must be monotonically forward — older cursors
|
|
579
|
+
* raise a {@link ConflictError} (HTTP 409, code
|
|
580
|
+
* `channel_cursor_regression`). */
|
|
581
|
+
async ackChannel(channel, opts) {
|
|
582
|
+
const path = channelOpPath(channel, opts.scope, opts.userId, "ack");
|
|
583
|
+
return postJSON(this.ctx, path, { cursor: opts.cursor }, { signal: opts.signal });
|
|
584
|
+
}
|
|
585
|
+
/** Subscribe to run state transitions for one user_id via SSE.
|
|
586
|
+
* Yields one `{ kind: "open", ... }` item first (confirms the
|
|
587
|
+
* connection is live), then one `{ kind: "event", ... }` per
|
|
588
|
+
* matching state transition until the stream closes.
|
|
589
|
+
*
|
|
590
|
+
* The stream stays open for at most 30 minutes (server-enforced).
|
|
591
|
+
* Callers running indefinitely should reconnect on close.
|
|
592
|
+
*
|
|
593
|
+
* Errors during the stream throw — they do NOT surface as items.
|
|
594
|
+
* Pass an AbortSignal to terminate cleanly from the consumer side.
|
|
595
|
+
*
|
|
596
|
+
* v0.9.x options:
|
|
597
|
+
* - `parentAgentId` — client-side filter: only `kind: "event"`
|
|
598
|
+
* items whose payload's `parent_agent_id` matches are yielded.
|
|
599
|
+
* The server still streams every matching event; the adapter
|
|
600
|
+
* filters before yielding. Empty/omitted = no filter.
|
|
601
|
+
* - `debug` — when true, an additional `{ kind: "close", payload:
|
|
602
|
+
* { reason } }` item is yielded when the stream ends (EOF,
|
|
603
|
+
* abort, or pre-yield error). Useful for n8n nodes that surface
|
|
604
|
+
* "stream re-opened / closed" log entries without inferring
|
|
605
|
+
* from timing. Default false. */
|
|
606
|
+
async *streamUserRunStates(userId, opts) {
|
|
607
|
+
const params = new URLSearchParams();
|
|
608
|
+
if (opts?.statuses && opts.statuses.length > 0) {
|
|
609
|
+
params.set("status", opts.statuses.join(","));
|
|
610
|
+
}
|
|
611
|
+
if (opts?.agent) {
|
|
612
|
+
params.set("agent", opts.agent);
|
|
613
|
+
}
|
|
614
|
+
const qs = params.toString();
|
|
615
|
+
const path = `/v1/users/${encodeURIComponent(userId)}/agents/stream` +
|
|
616
|
+
(qs ? `?${qs}` : "");
|
|
617
|
+
const headers = {
|
|
618
|
+
Accept: "text/event-stream",
|
|
619
|
+
};
|
|
620
|
+
if (this.ctx.authToken) {
|
|
621
|
+
headers.Authorization = `Bearer ${this.ctx.authToken}`;
|
|
622
|
+
}
|
|
623
|
+
const resp = await this.ctx.fetchImpl(this.ctx.baseUrl + path, {
|
|
624
|
+
method: "GET",
|
|
625
|
+
headers,
|
|
626
|
+
signal: opts?.signal,
|
|
627
|
+
});
|
|
628
|
+
if (!resp.ok) {
|
|
629
|
+
await raiseFromResponse(resp);
|
|
630
|
+
}
|
|
631
|
+
if (!resp.body) {
|
|
632
|
+
throw new Error("loomcycle: streamUserRunStates response has no body");
|
|
633
|
+
}
|
|
634
|
+
const parentFilter = opts?.parentAgentId ?? "";
|
|
635
|
+
const debug = opts?.debug === true;
|
|
636
|
+
let closeReason = "eof";
|
|
637
|
+
try {
|
|
638
|
+
for await (const item of parseRunStateSSE(resp.body.getReader())) {
|
|
639
|
+
// Client-side parent_agent_id filter. Pre-v1 the server has no
|
|
640
|
+
// ?parent_agent_id= query param; n8n-style consumers that need
|
|
641
|
+
// a narrow view get a smaller iterator at the cost of
|
|
642
|
+
// unchanged server load. See StreamUserRunStatesOptions for
|
|
643
|
+
// the trade-off note.
|
|
644
|
+
if (parentFilter !== "" &&
|
|
645
|
+
item.kind === "event" &&
|
|
646
|
+
item.payload.parent_agent_id !== parentFilter) {
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
yield item;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
catch (e) {
|
|
653
|
+
closeReason =
|
|
654
|
+
e && typeof e === "object" && "name" in e
|
|
655
|
+
? String(e.name)
|
|
656
|
+
: "error";
|
|
657
|
+
if (debug) {
|
|
658
|
+
yield { kind: "close", payload: { reason: closeReason } };
|
|
659
|
+
}
|
|
660
|
+
throw e;
|
|
661
|
+
}
|
|
662
|
+
if (debug) {
|
|
663
|
+
yield { kind: "close", payload: { reason: closeReason } };
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
/** Lightweight SSE parser tailored to the run-state stream. Each
|
|
668
|
+
* frame's event name distinguishes the two kinds; data is JSON.
|
|
669
|
+
* Comment lines (": keepalive") are ignored. */
|
|
670
|
+
async function* parseRunStateSSE(reader) {
|
|
671
|
+
const decoder = new TextDecoder("utf-8");
|
|
672
|
+
let buf = "";
|
|
673
|
+
let event = "";
|
|
674
|
+
let data = "";
|
|
675
|
+
while (true) {
|
|
676
|
+
const { value, done } = await reader.read();
|
|
677
|
+
if (done)
|
|
678
|
+
break;
|
|
679
|
+
buf += decoder.decode(value, { stream: true });
|
|
680
|
+
let idx;
|
|
681
|
+
while ((idx = buf.indexOf("\n")) !== -1) {
|
|
682
|
+
const line = buf.slice(0, idx).replace(/\r$/, "");
|
|
683
|
+
buf = buf.slice(idx + 1);
|
|
684
|
+
if (line === "") {
|
|
685
|
+
if (event && data) {
|
|
686
|
+
try {
|
|
687
|
+
const parsed = JSON.parse(data);
|
|
688
|
+
if (event === "stream_open") {
|
|
689
|
+
yield {
|
|
690
|
+
kind: "open",
|
|
691
|
+
payload: parsed,
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
else if (event === "run_state") {
|
|
695
|
+
yield {
|
|
696
|
+
kind: "event",
|
|
697
|
+
payload: parsed,
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
catch {
|
|
702
|
+
// Drop malformed frame silently — same posture as parseSSE.
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
event = "";
|
|
706
|
+
data = "";
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
if (line.startsWith("event:"))
|
|
710
|
+
event = line.slice("event:".length).trim();
|
|
711
|
+
else if (line.startsWith("data:"))
|
|
712
|
+
data = line.slice("data:".length).trim();
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
// channelOpPath builds the v0.9.x Channel CRUD URL. Two families:
|
|
717
|
+
// - scope === "global" → /v1/_channels/{channel}/{op}
|
|
718
|
+
// - scope === "user" → /v1/users/{userId}/channels/{channel}/{op}
|
|
719
|
+
// Channel name is URL-encoded so names containing slashes
|
|
720
|
+
// ("findings/alpha", "_system/foo") survive transport.
|
|
721
|
+
function channelOpPath(channel, scope, userId, op) {
|
|
722
|
+
const enc = encodeURIComponent(channel);
|
|
723
|
+
if (scope === "user") {
|
|
724
|
+
if (!userId) {
|
|
725
|
+
throw new Error(`loomcycle: scope="user" requires opts.userId for the channel ${op} call`);
|
|
726
|
+
}
|
|
727
|
+
return `/v1/users/${encodeURIComponent(userId)}/channels/${enc}/${op}`;
|
|
392
728
|
}
|
|
729
|
+
return `/v1/_channels/${enc}/${op}`;
|
|
393
730
|
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -133,6 +133,22 @@ export declare class HookNotFoundError extends NotFoundError {
|
|
|
133
133
|
bodyText?: string;
|
|
134
134
|
});
|
|
135
135
|
}
|
|
136
|
+
/** ChannelCursorRegressionError — raised by `client.ackChannel()`
|
|
137
|
+
* when the caller-supplied cursor is older than the currently-
|
|
138
|
+
* committed cursor for the (channel, scope, scope_id) tuple. HTTP
|
|
139
|
+
* 409 with `{code: "channel_cursor_regression", ...}` body.
|
|
140
|
+
*
|
|
141
|
+
* Mirrors `store.ErrChannelCursorRegression` on the loomcycle
|
|
142
|
+
* side. Distinct from `SessionBusyError` etc. (which also map to
|
|
143
|
+
* 409) so the n8n adapter can distinguish "this cursor is stale,
|
|
144
|
+
* re-fetch and retry from the new committed position" from other
|
|
145
|
+
* 409 conditions. */
|
|
146
|
+
export declare class ChannelCursorRegressionError extends LoomcycleError {
|
|
147
|
+
constructor(message: string, opts?: {
|
|
148
|
+
status?: number;
|
|
149
|
+
bodyText?: string;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
136
152
|
/** SubstrateToolRefusedError — raised by `client.agentDef()` /
|
|
137
153
|
* `client.skillDef()` when the in-process tool refused the call
|
|
138
154
|
* (scope deny, empty body, allowed-tools widening, etc.). HTTP
|
package/dist/errors.js
CHANGED
|
@@ -136,6 +136,22 @@ export class HookNotFoundError extends NotFoundError {
|
|
|
136
136
|
this.name = "HookNotFoundError";
|
|
137
137
|
}
|
|
138
138
|
}
|
|
139
|
+
/** ChannelCursorRegressionError — raised by `client.ackChannel()`
|
|
140
|
+
* when the caller-supplied cursor is older than the currently-
|
|
141
|
+
* committed cursor for the (channel, scope, scope_id) tuple. HTTP
|
|
142
|
+
* 409 with `{code: "channel_cursor_regression", ...}` body.
|
|
143
|
+
*
|
|
144
|
+
* Mirrors `store.ErrChannelCursorRegression` on the loomcycle
|
|
145
|
+
* side. Distinct from `SessionBusyError` etc. (which also map to
|
|
146
|
+
* 409) so the n8n adapter can distinguish "this cursor is stale,
|
|
147
|
+
* re-fetch and retry from the new committed position" from other
|
|
148
|
+
* 409 conditions. */
|
|
149
|
+
export class ChannelCursorRegressionError extends LoomcycleError {
|
|
150
|
+
constructor(message, opts) {
|
|
151
|
+
super(message, opts);
|
|
152
|
+
this.name = "ChannelCursorRegressionError";
|
|
153
|
+
}
|
|
154
|
+
}
|
|
139
155
|
/** SubstrateToolRefusedError — raised by `client.agentDef()` /
|
|
140
156
|
* `client.skillDef()` when the in-process tool refused the call
|
|
141
157
|
* (scope deny, empty body, allowed-tools widening, etc.). HTTP
|
package/dist/fetch-helpers.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Method-level code in client.ts stays focused on URL + body shape;
|
|
10
10
|
* the boring fetch + error-translation machinery lives here.
|
|
11
11
|
*/
|
|
12
|
-
import { AgentIDInUseError, AgentNotFoundError, AlreadyPausingError, AuthError, BackpressureError, HookNotFoundError, InvalidArgumentError, LoomcycleError, NotFoundError, NotPausedError, PauseNotConfiguredError, SessionBusyError, SessionNotFoundError, SnapshotNotFoundError, SnapshotTooLargeError, SnapshotVersionError, SubstrateToolRefusedError, UnavailableError, } from "./errors.js";
|
|
12
|
+
import { AgentIDInUseError, AgentNotFoundError, AlreadyPausingError, AuthError, BackpressureError, ChannelCursorRegressionError, HookNotFoundError, InvalidArgumentError, LoomcycleError, NotFoundError, NotPausedError, PauseNotConfiguredError, SessionBusyError, SessionNotFoundError, SnapshotNotFoundError, SnapshotTooLargeError, SnapshotVersionError, SubstrateToolRefusedError, UnavailableError, } from "./errors.js";
|
|
13
13
|
/** authHeaders builds the standard request header set: JSON Accept
|
|
14
14
|
* + Bearer token when the client was constructed with one. The
|
|
15
15
|
* caller adds Content-Type when posting a body. */
|
|
@@ -157,6 +157,10 @@ export async function raiseFromResponse(resp) {
|
|
|
157
157
|
throw new AlreadyPausingError(msg, opts);
|
|
158
158
|
if (bodyLower.includes("not_paused") || bodyLower.includes("not paused"))
|
|
159
159
|
throw new NotPausedError(msg, opts);
|
|
160
|
+
// v0.9.x — Channel CRUD ack with a stale cursor. Distinct so
|
|
161
|
+
// the n8n adapter / consumer can branch on `instanceof`.
|
|
162
|
+
if (bodyLower.includes("channel_cursor_regression"))
|
|
163
|
+
throw new ChannelCursorRegressionError(msg, opts);
|
|
160
164
|
if (bodyLower.includes("session"))
|
|
161
165
|
throw new SessionBusyError(msg, opts);
|
|
162
166
|
if (bodyLower.includes("agent_id"))
|
package/dist/index.d.ts
CHANGED
|
@@ -64,5 +64,5 @@
|
|
|
64
64
|
* See `adapters/ts/README.md` for usage examples.
|
|
65
65
|
*/
|
|
66
66
|
export { LoomcycleClient } from "./client.js";
|
|
67
|
-
export type { AgentEvent, ClientOptions, ContinueOptions, EventType, HostWidening, PromptContent, PromptSegment, RetryInfo, RunOptions, ToolUse, Usage, Agent, AgentStatus, AgentUsage, CancelAgentResult, ListAgentsResponse, TranscriptEvent, TranscriptResponse, HealthResponse, ListUsersResponse, UserSummary, PauseResult, ResumeResult, RuntimeStateResponse, RuntimeStateStatus, CreateSnapshotOptions, SnapshotCreateResponse, SnapshotDescriptor, SnapshotEnvelope, SnapshotListResponse, SnapshotRestoreResponse, MemoryEntriesResponse, MemoryEntry, MemoryEntryResponse, MemoryScopeIDsResponse, MemoryScopeIDSummary, MemoryScopeKind, MemoryScopesResponse, InterruptListResponse, InterruptRow, InterruptStatus, ResolveInterruptOptions, Hook, HookFailMode, HookPhase, HookToolCall, HookToolResult, ListHooksResponse, PostHookCall, PostHookResult, PreHookCall, PreHookResult, RegisterHookOptions, RegisterHookResponse, SubstrateToolInput, SubstrateToolResponse, } from "./types.js";
|
|
68
|
-
export { AgentIDInUseError, AgentNotFoundError, AlreadyPausingError, AuthError, BackpressureError, HookNotFoundError, NotFoundError, InvalidArgumentError, LoomcycleError, NotPausedError, PauseNotConfiguredError, SessionBusyError, SessionNotFoundError, SnapshotNotFoundError, SnapshotTooLargeError, SnapshotVersionError, SubstrateToolRefusedError, UnavailableError, } from "./errors.js";
|
|
67
|
+
export type { AgentEvent, ClientOptions, ContinueOptions, EventType, HostWidening, PromptContent, PromptSegment, RetryInfo, RunOptions, ToolUse, Usage, Agent, AgentStatus, AgentUsage, CancelAgentResult, ListAgentsResponse, TranscriptEvent, TranscriptResponse, HealthResponse, ListUsersResponse, UserSummary, PauseResult, ResumeResult, RuntimeStateResponse, RuntimeStateStatus, CreateSnapshotOptions, SnapshotCreateResponse, SnapshotDescriptor, SnapshotEnvelope, SnapshotListResponse, SnapshotRestoreResponse, MemoryEntriesResponse, MemoryEntry, MemoryEntryResponse, MemoryScopeIDsResponse, MemoryScopeIDSummary, MemoryScopeKind, MemoryScopesResponse, InterruptListResponse, InterruptRow, InterruptStatus, ResolveInterruptOptions, Hook, HookFailMode, HookPhase, HookToolCall, HookToolResult, ListHooksResponse, PostHookCall, PostHookResult, PreHookCall, PreHookResult, RegisterHookOptions, RegisterHookResponse, SubstrateToolInput, SubstrateToolResponse, SystemPromptPayload, UserInputPayload, ChannelDescriptor, ListChannelsResponse, RunStateEvent, RunStateStreamClose, RunStateStreamItem, RunStateStreamOpen, StreamUserRunStatesOptions, AckChannelOptions, ChannelAckResult, ChannelMessageItem, ChannelPeekResult, ChannelPublishResult, ChannelScope, ChannelSubscribeResult, PeekChannelOptions, PublishChannelOptions, SubscribeChannelOptions, AgentDefRowResponse, AgentDefVerifyResult, SkillDefVerifyResult, MCPServerDefRowResponse, MCPServerDefVerifyResult, } from "./types.js";
|
|
68
|
+
export { AgentIDInUseError, AgentNotFoundError, AlreadyPausingError, AuthError, BackpressureError, HookNotFoundError, NotFoundError, InvalidArgumentError, ChannelCursorRegressionError, LoomcycleError, NotPausedError, PauseNotConfiguredError, SessionBusyError, SessionNotFoundError, SnapshotNotFoundError, SnapshotTooLargeError, SnapshotVersionError, SubstrateToolRefusedError, UnavailableError, } from "./errors.js";
|
package/dist/index.js
CHANGED
|
@@ -64,4 +64,4 @@
|
|
|
64
64
|
* See `adapters/ts/README.md` for usage examples.
|
|
65
65
|
*/
|
|
66
66
|
export { LoomcycleClient } from "./client.js";
|
|
67
|
-
export { AgentIDInUseError, AgentNotFoundError, AlreadyPausingError, AuthError, BackpressureError, HookNotFoundError, NotFoundError, InvalidArgumentError, LoomcycleError, NotPausedError, PauseNotConfiguredError, SessionBusyError, SessionNotFoundError, SnapshotNotFoundError, SnapshotTooLargeError, SnapshotVersionError, SubstrateToolRefusedError, UnavailableError, } from "./errors.js";
|
|
67
|
+
export { AgentIDInUseError, AgentNotFoundError, AlreadyPausingError, AuthError, BackpressureError, HookNotFoundError, NotFoundError, InvalidArgumentError, ChannelCursorRegressionError, LoomcycleError, NotPausedError, PauseNotConfiguredError, SessionBusyError, SessionNotFoundError, SnapshotNotFoundError, SnapshotTooLargeError, SnapshotVersionError, SubstrateToolRefusedError, UnavailableError, } from "./errors.js";
|
package/dist/types.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* `client.ts` for the input shapes (RunOptions, CreateSnapshotOptions,
|
|
8
8
|
* etc.) — those are translated to snake_case in the request body.
|
|
9
9
|
*/
|
|
10
|
-
export type EventType = "started" | "text" | "tool_call" | "tool_result" | "usage" | "done" | "error" | "retry" | "host_widened" | "session" | "agent";
|
|
10
|
+
export type EventType = "started" | "text" | "tool_call" | "tool_result" | "usage" | "done" | "error" | "retry" | "host_widened" | "session" | "agent" | "_meta";
|
|
11
11
|
export interface ToolUse {
|
|
12
12
|
id: string;
|
|
13
13
|
name: string;
|
|
@@ -69,6 +69,8 @@ export interface AgentEvent {
|
|
|
69
69
|
run_id?: string;
|
|
70
70
|
session_id?: string;
|
|
71
71
|
parent_agent_id?: string | null;
|
|
72
|
+
meta_subtype?: "stream_open" | "stream_close";
|
|
73
|
+
meta_reason?: string;
|
|
72
74
|
}
|
|
73
75
|
export type PromptContent = {
|
|
74
76
|
type: "trusted-text";
|
|
@@ -128,6 +130,14 @@ export interface RunOptions {
|
|
|
128
130
|
* (static-bearer setups unaffected). Sub-agents inherit identically.
|
|
129
131
|
* Never persisted; never logged in full. */
|
|
130
132
|
userBearer?: string;
|
|
133
|
+
/** Opt-in observability: when true, the iterator emits client-
|
|
134
|
+
* synthesized `{ type: "_meta", meta_subtype: "stream_open" | "stream_close" }`
|
|
135
|
+
* events around the real event stream. `meta_reason` carries the
|
|
136
|
+
* trigger ("eof", "abort", or an error class name). Default is
|
|
137
|
+
* false — existing consumers see no behaviour change. Useful for
|
|
138
|
+
* n8n nodes that want to surface "stream re-opened" / "stream
|
|
139
|
+
* closed" log entries without inferring from event timing. */
|
|
140
|
+
debug?: boolean;
|
|
131
141
|
signal?: AbortSignal;
|
|
132
142
|
}
|
|
133
143
|
export interface ContinueOptions {
|
|
@@ -156,6 +166,8 @@ export interface ContinueOptions {
|
|
|
156
166
|
* so different continuations in the same session may carry
|
|
157
167
|
* different end-user tokens. */
|
|
158
168
|
userBearer?: string;
|
|
169
|
+
/** Opt-in observability: see {@link RunOptions.debug}. Same shape. */
|
|
170
|
+
debug?: boolean;
|
|
159
171
|
signal?: AbortSignal;
|
|
160
172
|
}
|
|
161
173
|
export interface ClientOptions {
|
|
@@ -208,13 +220,50 @@ export interface CancelAgentResult {
|
|
|
208
220
|
}
|
|
209
221
|
/** TranscriptEvent — one persisted store.Event from
|
|
210
222
|
* GET /v1/sessions/{id}/transcript. The server wraps each
|
|
211
|
-
* providers.Event in {seq, run_id, ts_ns, type, event:{...}}.
|
|
223
|
+
* providers.Event in {seq, run_id, ts_ns, type, event:{...}}.
|
|
224
|
+
*
|
|
225
|
+
* `payload` is the v0.9.1 sidecar field carrying the typed body of
|
|
226
|
+
* events that don't fit the providers.Event union (the first-cycle
|
|
227
|
+
* `system_prompt` + `user_input` transcript events). Narrow on
|
|
228
|
+
* `type` to pick the right payload interface — see
|
|
229
|
+
* {@link SystemPromptPayload} and {@link UserInputPayload}. */
|
|
212
230
|
export interface TranscriptEvent {
|
|
213
231
|
seq: number;
|
|
214
232
|
run_id: string;
|
|
215
233
|
ts_ns: number;
|
|
216
234
|
type: string;
|
|
217
235
|
event: unknown;
|
|
236
|
+
/** v0.9.1+ sidecar for typed transcript events:
|
|
237
|
+
* type === "system_prompt" → SystemPromptPayload
|
|
238
|
+
* type === "user_input" → UserInputPayload[]
|
|
239
|
+
* Absent for events the server hands through via `event`. */
|
|
240
|
+
payload?: SystemPromptPayload | UserInputPayload[] | unknown;
|
|
241
|
+
}
|
|
242
|
+
/** UserInputPayload mirrors the JSON of one `loop.PromptSegment` —
|
|
243
|
+
* what the caller supplied as `segments` on POST /v1/runs +
|
|
244
|
+
* /v1/sessions/{id}/messages. The transcript event's `payload`
|
|
245
|
+
* field carries the FULL array (`UserInputPayload[]`) because one
|
|
246
|
+
* call may include multiple segments (system + user prepends, etc.). */
|
|
247
|
+
export interface UserInputPayload {
|
|
248
|
+
role: string;
|
|
249
|
+
content: Array<{
|
|
250
|
+
type: string;
|
|
251
|
+
text?: string;
|
|
252
|
+
cacheable?: boolean;
|
|
253
|
+
}>;
|
|
254
|
+
}
|
|
255
|
+
/** SystemPromptPayload mirrors the v0.9.1 system_prompt transcript
|
|
256
|
+
* event payload — the resolved system prompt + provenance metadata
|
|
257
|
+
* so operators can see WHICH AgentDef + WHICH SkillDef rows fed in. */
|
|
258
|
+
export interface SystemPromptPayload {
|
|
259
|
+
system_prompt: string;
|
|
260
|
+
/** Empty for yaml-only agents (no AgentDef row). Pinned for
|
|
261
|
+
* sub-runs spawned via the Agent tool with a def_id. */
|
|
262
|
+
agent_def_id?: string;
|
|
263
|
+
/** skillName → active SkillDef def_id. Only present for skills
|
|
264
|
+
* whose DB-active row supplied the body; static-fallback skills
|
|
265
|
+
* are absent. */
|
|
266
|
+
skill_def_ids?: Record<string, string>;
|
|
218
267
|
}
|
|
219
268
|
export interface TranscriptResponse {
|
|
220
269
|
session: {
|
|
@@ -502,3 +551,263 @@ export type SubstrateToolResponse = unknown;
|
|
|
502
551
|
export interface PostHookResult {
|
|
503
552
|
result?: HookToolResult;
|
|
504
553
|
}
|
|
554
|
+
/** Aggregate stats for one operator-declared channel. Returned by
|
|
555
|
+
* {@link LoomcycleClient.listChannels}. */
|
|
556
|
+
export interface ChannelDescriptor {
|
|
557
|
+
name: string;
|
|
558
|
+
scope?: string;
|
|
559
|
+
semantic?: string;
|
|
560
|
+
publisher?: string;
|
|
561
|
+
period?: string;
|
|
562
|
+
default_ttl?: number;
|
|
563
|
+
max_messages?: number;
|
|
564
|
+
message_count: number;
|
|
565
|
+
/** RFC3339 — empty when count == 0. */
|
|
566
|
+
oldest_visible_at?: string;
|
|
567
|
+
newest_visible_at?: string;
|
|
568
|
+
}
|
|
569
|
+
/** Response shape for {@link LoomcycleClient.listChannels}. */
|
|
570
|
+
export interface ListChannelsResponse {
|
|
571
|
+
channels: ChannelDescriptor[];
|
|
572
|
+
}
|
|
573
|
+
/** Scope selector for the Channel CRUD methods. `"global"` addresses
|
|
574
|
+
* the admin surface; `"user"` requires `userId` and addresses the
|
|
575
|
+
* per-end-user URL family. */
|
|
576
|
+
export type ChannelScope = "global" | "user";
|
|
577
|
+
/** Options for {@link LoomcycleClient.publishChannel}. `payload` is
|
|
578
|
+
* the raw JSON value (object, array, string, number) to publish.
|
|
579
|
+
* `deliverAt` (RFC3339Nano) defers the publish so long-poll
|
|
580
|
+
* subscribers wake at the visible_at time. */
|
|
581
|
+
export interface PublishChannelOptions {
|
|
582
|
+
scope: ChannelScope;
|
|
583
|
+
/** Required when scope === "user". The per-user URL is
|
|
584
|
+
* /v1/users/{userId}/channels/{channel}/publish. */
|
|
585
|
+
userId?: string;
|
|
586
|
+
payload: unknown;
|
|
587
|
+
/** RFC3339Nano deferred-publish time. Omit for "publish now". */
|
|
588
|
+
deliverAt?: string;
|
|
589
|
+
signal?: AbortSignal;
|
|
590
|
+
}
|
|
591
|
+
/** Response shape for {@link LoomcycleClient.publishChannel}. */
|
|
592
|
+
export interface ChannelPublishResult {
|
|
593
|
+
msg_id: string;
|
|
594
|
+
channel: string;
|
|
595
|
+
/** RFC3339Nano. */
|
|
596
|
+
created_at: string;
|
|
597
|
+
/** RFC3339Nano. Omitted when the publish was immediate. */
|
|
598
|
+
visible_at?: string;
|
|
599
|
+
}
|
|
600
|
+
/** Options for {@link LoomcycleClient.subscribeChannel}. The call is a
|
|
601
|
+
* single-round-trip long-poll, NOT an open SSE stream — returns
|
|
602
|
+
* immediately if messages are present, otherwise waits up to
|
|
603
|
+
* `waitMs` for a publish. Auto-commits the cursor on a non-empty
|
|
604
|
+
* batch (at-most-once shape). For at-least-once, use
|
|
605
|
+
* {@link LoomcycleClient.peekChannel} + explicit ack. */
|
|
606
|
+
export interface SubscribeChannelOptions {
|
|
607
|
+
scope: ChannelScope;
|
|
608
|
+
userId?: string;
|
|
609
|
+
/** Cursor to read forward from. Empty/omitted = the committed
|
|
610
|
+
* cursor. `"cur_0"` = replay from the oldest non-expired row. */
|
|
611
|
+
fromCursor?: string;
|
|
612
|
+
/** Defaults to 10; clamped at 100 by the server. */
|
|
613
|
+
maxMessages?: number;
|
|
614
|
+
/** Long-poll timeout in ms. 0 / omitted = poll once and return.
|
|
615
|
+
* Capped at the operator's `ChannelsLongPollCapMS` (default 30s). */
|
|
616
|
+
waitMs?: number;
|
|
617
|
+
signal?: AbortSignal;
|
|
618
|
+
}
|
|
619
|
+
/** One delivered message — same wire shape as the in-band Channel
|
|
620
|
+
* tool's subscribe response. */
|
|
621
|
+
export interface ChannelMessageItem {
|
|
622
|
+
id: string;
|
|
623
|
+
value: unknown;
|
|
624
|
+
/** RFC3339Nano. */
|
|
625
|
+
published_at: string;
|
|
626
|
+
}
|
|
627
|
+
/** Response shape for {@link LoomcycleClient.subscribeChannel}. */
|
|
628
|
+
export interface ChannelSubscribeResult {
|
|
629
|
+
channel: string;
|
|
630
|
+
messages: ChannelMessageItem[];
|
|
631
|
+
/** Cursor to pass on the next subscribe call to continue forward.
|
|
632
|
+
* Empty when the batch is empty. */
|
|
633
|
+
next_cursor: string;
|
|
634
|
+
}
|
|
635
|
+
/** Options for {@link LoomcycleClient.peekChannel}. */
|
|
636
|
+
export interface PeekChannelOptions {
|
|
637
|
+
scope: ChannelScope;
|
|
638
|
+
userId?: string;
|
|
639
|
+
fromCursor?: string;
|
|
640
|
+
maxMessages?: number;
|
|
641
|
+
signal?: AbortSignal;
|
|
642
|
+
}
|
|
643
|
+
/** Response shape for {@link LoomcycleClient.peekChannel}. */
|
|
644
|
+
export interface ChannelPeekResult {
|
|
645
|
+
channel: string;
|
|
646
|
+
messages: ChannelMessageItem[];
|
|
647
|
+
}
|
|
648
|
+
/** Options for {@link LoomcycleClient.ackChannel}. */
|
|
649
|
+
export interface AckChannelOptions {
|
|
650
|
+
scope: ChannelScope;
|
|
651
|
+
userId?: string;
|
|
652
|
+
cursor: string;
|
|
653
|
+
signal?: AbortSignal;
|
|
654
|
+
}
|
|
655
|
+
/** Response shape for {@link LoomcycleClient.ackChannel}. */
|
|
656
|
+
export interface ChannelAckResult {
|
|
657
|
+
ok: boolean;
|
|
658
|
+
}
|
|
659
|
+
/** One run state transition emitted by
|
|
660
|
+
* {@link LoomcycleClient.streamUserRunStates}. The TS field is RFC3339. */
|
|
661
|
+
export interface RunStateEvent {
|
|
662
|
+
run_id: string;
|
|
663
|
+
agent_id: string;
|
|
664
|
+
agent: string;
|
|
665
|
+
user_id: string;
|
|
666
|
+
parent_agent_id?: string;
|
|
667
|
+
status: string;
|
|
668
|
+
stop_reason?: string;
|
|
669
|
+
error?: string;
|
|
670
|
+
ts: string;
|
|
671
|
+
}
|
|
672
|
+
/** Initial stream_open frame emitted before the first run_state. */
|
|
673
|
+
export interface RunStateStreamOpen {
|
|
674
|
+
user_id: string;
|
|
675
|
+
filter_status: string[] | null;
|
|
676
|
+
filter_agent: string;
|
|
677
|
+
keepalive_interval: number;
|
|
678
|
+
}
|
|
679
|
+
/** Yielded by {@link LoomcycleClient.streamUserRunStates}.
|
|
680
|
+
*
|
|
681
|
+
* The first item is always `{ kind: "open", payload: RunStateStreamOpen }`.
|
|
682
|
+
* Subsequent items are `{ kind: "event", payload: RunStateEvent }`.
|
|
683
|
+
*
|
|
684
|
+
* Consumers branch on `kind`; the `open` frame is useful for confirming
|
|
685
|
+
* the connection before any real events flow. */
|
|
686
|
+
export type RunStateStreamItem = {
|
|
687
|
+
kind: "open";
|
|
688
|
+
payload: RunStateStreamOpen;
|
|
689
|
+
} | {
|
|
690
|
+
kind: "event";
|
|
691
|
+
payload: RunStateEvent;
|
|
692
|
+
} | {
|
|
693
|
+
kind: "close";
|
|
694
|
+
payload: RunStateStreamClose;
|
|
695
|
+
};
|
|
696
|
+
/** Optional filter for {@link LoomcycleClient.streamUserRunStates}. */
|
|
697
|
+
export interface StreamUserRunStatesOptions {
|
|
698
|
+
/** Subset of states to receive. Empty means all states. */
|
|
699
|
+
statuses?: string[];
|
|
700
|
+
/** Filter to one agent name. Empty means any. */
|
|
701
|
+
agent?: string;
|
|
702
|
+
/** v0.9.x — client-side filter on the run's parent_agent_id.
|
|
703
|
+
* Useful for "show me only the sub-runs spawned by agent X."
|
|
704
|
+
* The filter is applied AFTER the SSE frame is parsed, so this
|
|
705
|
+
* shrinks what your callback sees but doesn't reduce server-side
|
|
706
|
+
* load. Server-side filtering is a separate (future) request.
|
|
707
|
+
* Pass the empty string to opt out (default). */
|
|
708
|
+
parentAgentId?: string;
|
|
709
|
+
/** v0.9.x — opt-in observability: when true, the iterator yields a
|
|
710
|
+
* client-synthesized `{ kind: "close", payload: { reason } }` item
|
|
711
|
+
* when the stream ends (EOF, abort, or error). `reason` carries
|
|
712
|
+
* the cause ("eof" on clean close or an error class name like
|
|
713
|
+
* "AbortError" / "AuthError"). The opening `kind: "open"` frame
|
|
714
|
+
* that always appears first is server-emitted, not synthetic;
|
|
715
|
+
* `debug` has no effect on it. Default false leaves behaviour
|
|
716
|
+
* identical to v0.9.x earlier. */
|
|
717
|
+
debug?: boolean;
|
|
718
|
+
signal?: AbortSignal;
|
|
719
|
+
}
|
|
720
|
+
/** v0.9.x — close-event payload emitted only under
|
|
721
|
+
* {@link StreamUserRunStatesOptions.debug}. Synthetic; never on the
|
|
722
|
+
* wire. */
|
|
723
|
+
export interface RunStateStreamClose {
|
|
724
|
+
reason: string;
|
|
725
|
+
}
|
|
726
|
+
/** Response shape for `AgentDef set/fork/get/list` rows. Mirrors what
|
|
727
|
+
* the server-side rowResponseMap emits. The `content_sha256` field is
|
|
728
|
+
* the deterministic SHA-256 of the agent's content-bearing fields,
|
|
729
|
+
* prefixed `sha256:` (Docker image-digest convention). Empty on rows
|
|
730
|
+
* that pre-date v0.9.x and haven't been backfilled yet. */
|
|
731
|
+
export interface AgentDefRowResponse {
|
|
732
|
+
def_id: string;
|
|
733
|
+
name: string;
|
|
734
|
+
version: number;
|
|
735
|
+
parent_def_id?: string;
|
|
736
|
+
description?: string;
|
|
737
|
+
created_at: string;
|
|
738
|
+
created_by_agent_id?: string;
|
|
739
|
+
retired: boolean;
|
|
740
|
+
bootstrapped_from_static: boolean;
|
|
741
|
+
/** "sha256:" + 64 hex chars; empty for not-yet-backfilled rows. */
|
|
742
|
+
content_sha256?: string;
|
|
743
|
+
/** Only populated on `set` / `fork` responses (was the new row
|
|
744
|
+
* auto-promoted to active?). Absent on get/list. */
|
|
745
|
+
promoted?: boolean;
|
|
746
|
+
}
|
|
747
|
+
/** Response shape for `AgentDef verify`. Answers "is the supplied
|
|
748
|
+
* content_sha256 the active deployed version of this name?"
|
|
749
|
+
*
|
|
750
|
+
* - `matches: true` — caller's local hash matches the deployed
|
|
751
|
+
* active version; no push needed.
|
|
752
|
+
* - `matches: false` — bundle is out of sync; the operator should
|
|
753
|
+
* push a new version via `agentDef({op: "set",
|
|
754
|
+
* overlay: ...})`.
|
|
755
|
+
* - `deployed: false` — no active row exists for this name (no
|
|
756
|
+
* deployment yet). matches is always false. */
|
|
757
|
+
export interface AgentDefVerifyResult {
|
|
758
|
+
matches: boolean;
|
|
759
|
+
/** Deployed active row's hash; empty when not deployed. */
|
|
760
|
+
current_sha256: string;
|
|
761
|
+
/** Deployed active row's def_id; empty when not deployed. */
|
|
762
|
+
current_def_id: string;
|
|
763
|
+
/** Deployed active row's version; 0 when not deployed. */
|
|
764
|
+
version: number;
|
|
765
|
+
name: string;
|
|
766
|
+
/** True if an active row exists for this name. */
|
|
767
|
+
deployed: boolean;
|
|
768
|
+
}
|
|
769
|
+
/** Response shape for `SkillDef verify`. Same semantics as
|
|
770
|
+
* AgentDefVerifyResult; the per-skill content basis is just
|
|
771
|
+
* smaller (name + description + body + allowed_tools). */
|
|
772
|
+
export interface SkillDefVerifyResult {
|
|
773
|
+
matches: boolean;
|
|
774
|
+
current_sha256: string;
|
|
775
|
+
current_def_id: string;
|
|
776
|
+
version: number;
|
|
777
|
+
name: string;
|
|
778
|
+
deployed: boolean;
|
|
779
|
+
}
|
|
780
|
+
/** Response shape for `MCPServerDef set/fork/get/list` rows. Mirrors
|
|
781
|
+
* what the server-side rowResponseMap emits. `discovered_tools` is the
|
|
782
|
+
* cached tools/list snapshot — refreshed via the `rediscover` op; not
|
|
783
|
+
* part of the content_sha256 basis. */
|
|
784
|
+
export interface MCPServerDefRowResponse {
|
|
785
|
+
def_id: string;
|
|
786
|
+
name: string;
|
|
787
|
+
version: number;
|
|
788
|
+
parent_def_id?: string;
|
|
789
|
+
description?: string;
|
|
790
|
+
created_at: string;
|
|
791
|
+
created_by_agent_id?: string;
|
|
792
|
+
retired: boolean;
|
|
793
|
+
bootstrapped_from_static: boolean;
|
|
794
|
+
/** "sha256:" + 64 hex chars; empty for not-yet-backfilled rows. */
|
|
795
|
+
content_sha256?: string;
|
|
796
|
+
/** Only populated on `set` / `fork` responses (auto-promoted?). */
|
|
797
|
+
promoted?: boolean;
|
|
798
|
+
}
|
|
799
|
+
/** Response shape for `MCPServerDef verify`. Same semantics as
|
|
800
|
+
* AgentDefVerifyResult / SkillDefVerifyResult — answers "is the
|
|
801
|
+
* supplied content_sha256 the deployed active version of this name?" */
|
|
802
|
+
export interface MCPServerDefVerifyResult {
|
|
803
|
+
matches: boolean;
|
|
804
|
+
/** Deployed active row's hash; empty when not deployed. */
|
|
805
|
+
current_sha256: string;
|
|
806
|
+
/** Deployed active row's def_id; empty when not deployed. */
|
|
807
|
+
current_def_id: string;
|
|
808
|
+
/** Deployed active row's version; 0 when not deployed. */
|
|
809
|
+
version: number;
|
|
810
|
+
name: string;
|
|
811
|
+
/** True if an active row exists for this name. */
|
|
812
|
+
deployed: boolean;
|
|
813
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loomcycle/client",
|
|
3
|
-
"version": "0.9.
|
|
4
|
-
"description": "TypeScript client for the loomcycle sidecar (HTTP+SSE).
|
|
3
|
+
"version": "0.9.2",
|
|
4
|
+
"description": "TypeScript client for the loomcycle sidecar (HTTP+SSE). 36 methods covering run streaming, agent metadata, pause/resume/state, snapshot lifecycle, memory admin (incl. v0.9.0 Vector Memory embed_stats + reembed), interruption resolve, hook management, v0.8.22 substrate admin (agentDef + skillDef), v0.9.x n8n Phase 0 (listChannels + streamUserRunStates — with debug-mode synthetic open/close meta-frames + client-side parentAgentId filter), v0.9.x Channel CRUD (publishChannel + subscribeChannel + peekChannel + ackChannel — admin scope=global + per-user scope=user surfaces), v0.9.x content_sha256 (AgentDefVerifyResult + SkillDefVerifyResult types for the bundle-vs-deployed comparison workflow), v0.9.1 transcript first-cycle (SystemPromptPayload + UserInputPayload), and v0.9.x dynamic MCP server registration (mcpServerDef + MCPServerDefVerifyResult — register HTTP/Streamable-HTTP MCP servers at runtime without yaml edits).",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"repository": {
|