raindrop-ai 0.2.4 → 0.3.0

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 CHANGED
@@ -65,6 +65,141 @@ On runtimes that lack a usable `AsyncLocalStorage.enterWith` (notably Cloudflare
65
65
  - `withSpan`/`withTool` invoke your callback as `fn.apply(thisArg, args)` — the callback receives exactly the arguments you pass (an earlier version prepended a spurious leading `undefined`). If you had code compensating for that shifted argument, remove the workaround.
66
66
  - With per-client interaction registries, `resumeInteraction(eventId)` returns only interactions that **this** client began; a second client resuming the same event id gets its own fresh interaction rather than the first client's.
67
67
 
68
+ ## Detached sub-agents
69
+
70
+ A **detached** sub-agent runs in another process (a queue worker, a container,
71
+ another machine), does not block its caller, and reports as its own Raindrop
72
+ event with its own lifecycle. Neither the trace context nor the event id
73
+ survives that boundary, so the link is carried explicitly — the caller stamps it
74
+ on the span it dispatched from, the child stamps the reverse reference on every
75
+ span it emits, and a carrier travels with the job.
76
+
77
+ **Caller** — allocate the child's event id and get the headers to send:
78
+
79
+ ```ts
80
+ const supervisor = raindrop.begin({
81
+ eventId: turnId,
82
+ event: "supervisor_turn",
83
+ userId: "user_123",
84
+ convoId,
85
+ input: userMessage,
86
+ });
87
+
88
+ const dispatch = supervisor.subagent({ name: "researcher", input: { task } });
89
+ await queue.send({ task, headers: dispatch.headers });
90
+
91
+ // Return a handle, not an answer — the answer is the child's to report.
92
+ await supervisor.finish({ output: `Dispatched researcher as job ${dispatch.childEventId}.` });
93
+ ```
94
+
95
+ `subagent()` allocates `childEventId` **before** dispatching, so the
96
+ caller's hand-off resolves while the job is still queued. It records a tool span
97
+ carrying the hand-off stamps — named by your `toolName`, or `launch_subagent`
98
+ when you pass none, so the span name is yours to choose rather than a name the
99
+ SDK imposes. The sub-agent's own name travels on `raindrop.handoff.name`, which
100
+ is where readers group by it. It returns
101
+ `headers` (W3C `traceparent` + `raindrop-*` baggage + `x-raindrop-handoff`),
102
+ `langsmithHeaders` for services that parse LangSmith's shape, and `carrier` for
103
+ transports that are not HTTP.
104
+
105
+ Send **all** of `headers`, not just `traceparent`. The standard pair is what other
106
+ readers understand, but neither header is ours to rely on: an instrumented HTTP
107
+ client rewrites `traceparent` from its own client span, and OTel's baggage
108
+ propagator *replaces* `baggage` with the caller's own entries — so a caller
109
+ propagating a tenant id would strip every hand-off identity and leave the child
110
+ unlinked. `x-raindrop-handoff` carries the same fields under a name no propagator
111
+ owns; it is read last and wins. A carrier header arriving **repeated with
112
+ differing values** is refused outright, leaving the child honestly unlinked rather
113
+ than letting whoever prepended a value choose the event this process reports
114
+ under.
115
+
116
+ With tracing disabled there is no dispatch span for a carrier to reference, so
117
+ `carrier` is `null` and both header bags are empty — rather than naming a span
118
+ that was never recorded. `childEventId` is still allocated, so a caller that
119
+ needs the child reporting under that id can pass it to the worker itself.
120
+
121
+ The caller never writes the child's status. It returned a job handle and moved
122
+ on; status is derived from the child's own event.
123
+
124
+ **Child** — resume from the carrier and report under the id the caller allocated:
125
+
126
+ ```ts
127
+ const run = raindrop.resumeSubagent(message.headers);
128
+
129
+ try {
130
+ const answer = await run.interaction.withTool({ name: "search_docs" }, () =>
131
+ search(message.task)
132
+ );
133
+ await run.finish({ output: answer });
134
+ } catch (error) {
135
+ await run.fail(error);
136
+ }
137
+ ```
138
+
139
+ `run.interaction` is an ordinary interaction — `withSpan`, `withTool`, and
140
+ `trackAiEvent` all work as usual. The reverse reference (`parentEventId`,
141
+ `parentSpanId`, the sub-agent name, `agent.role=subagent`) is bound to the
142
+ execution context for the life of the run, so **every** span the child emits
143
+ carries it, including auto-instrumented LLM calls the SDK never sees. It is
144
+ unbound when the run settles.
145
+
146
+ `withSubagentRun` is the equivalent of Python's
147
+ `with rd.resume_subagent(...) as run:` — it guarantees an outcome is reported
148
+ even if the callback returns early or throws:
149
+
150
+ ```ts
151
+ await raindrop.withSubagentRun(request.headers, async (run) => {
152
+ await run.finish({ output: await doTheWork() });
153
+ });
154
+ ```
155
+
156
+ ### Endings
157
+
158
+ - `run.finish({ output })` — the normal path, same shape as `interaction.finish`.
159
+ - `run.fail(reason)` — errors the child's own span **and** reports the reason as
160
+ output. Both halves matter (see the ingest rules below). If the job died
161
+ before any span opened, one is recorded so there is something to mark.
162
+ - `run.cancel(reason)` — writes `raindrop.handoff.terminal=cancelled` and
163
+ reports the reason as output. It deliberately does **not** error any span:
164
+ error spans are what separate failed from cancelled.
165
+
166
+ ### Two ingest rules worth knowing
167
+
168
+ 1. **A run with no output produces no event.** A child that dies silently leaves
169
+ its launcher on `queued` forever — indistinguishable from a job that never
170
+ started. That is why `fail()` and `cancel()` both write an output, and why
171
+ `withSubagentRun` closes an unsettled run rather than letting it evaporate.
172
+ 2. **Status is derived from the child's telemetry, not asserted.** A run with
173
+ final output and no errored span reads as *finished*, which is why `fail()`
174
+ must error a span and not merely say "failed" in the output. Correspondingly,
175
+ the dispatching turn is opted in to producing an event
176
+ (`raindrop.toolEvents=allow`), because a turn whose entire content is a
177
+ dispatch has no assistant text and is otherwise dropped as an intermediate
178
+ step.
179
+
180
+ ### Carrier precedence and the trust boundary
181
+
182
+ The carrier wins over anything local. `resumeSubagent(headers, { convoId })`
183
+ treats its options as fallbacks for the **no-carrier** case, not overrides:
184
+ every field the carrier supplies names something the launcher already recorded.
185
+ A child that keeps its own convo id lands in a different conversation from the
186
+ supervisor that launched it, and one that keeps its own event id leaves the
187
+ caller's reference pointing at nothing.
188
+
189
+ `resumeSubagent` returns a usable run even when there is no carrier — a
190
+ sub-agent invoked directly rather than dispatched is a legitimate state, not an
191
+ error — so call sites stay unconditional. A malformed carrier costs the link,
192
+ never the request; check `run.linked` if you need to know.
193
+
194
+ > **Trust boundary.** A carrier names the event a child will be attributed to,
195
+ > so accepting one from an untrusted caller lets that caller write into another
196
+ > tenant's trace. Only read carriers that came from inside your own trust
197
+ > boundary.
198
+
199
+ Carriers are also readable in LangSmith's `langsmith-trace` +
200
+ `langsmith-metadata` shape, so a service already propagating those links with no
201
+ call-site change.
202
+
68
203
  ## Payload size limits
69
204
 
70
205
  Text fields (ai input/output, tool span I/O, span content) are capped at