nexus-ai-pro 2.9.0 → 2.10.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.
Files changed (77) hide show
  1. package/API_STABILITY.md +43 -0
  2. package/CHANGELOG.md +137 -1
  3. package/README.md +10 -5
  4. package/dist/core/lifecycle.d.ts +11 -1
  5. package/dist/core/lifecycle.js +9 -0
  6. package/dist/core/nexus.js +4 -0
  7. package/dist/deep-agent/helpers.d.ts +32 -0
  8. package/dist/deep-agent/helpers.js +62 -1
  9. package/dist/deep-agent/index.d.ts +10 -2
  10. package/dist/graph/graph.d.ts +5 -0
  11. package/dist/graph/graph.js +6 -0
  12. package/dist/lifecycle/index.d.ts +1 -1
  13. package/dist/protocols/a2a.d.ts +84 -1
  14. package/dist/protocols/a2a.js +178 -14
  15. package/dist/protocols/acp.d.ts +16 -1
  16. package/dist/protocols/acp.js +78 -17
  17. package/dist/protocols/shared.d.ts +15 -0
  18. package/dist/realtime/conversation.d.ts +28 -1
  19. package/dist/realtime/conversation.js +62 -1
  20. package/dist/realtime/gemini-live.transport.d.ts +134 -0
  21. package/dist/realtime/gemini-live.transport.js +769 -0
  22. package/dist/realtime/index.d.ts +8 -4
  23. package/dist/realtime/index.js +5 -3
  24. package/dist/realtime/openai-server.d.ts +49 -0
  25. package/dist/realtime/openai-server.js +103 -0
  26. package/dist/realtime/openai-sip.d.ts +114 -0
  27. package/dist/realtime/openai-sip.js +111 -0
  28. package/dist/realtime/openai-webrtc.transport.d.ts +7 -0
  29. package/dist/realtime/openai-webrtc.transport.js +4 -0
  30. package/dist/realtime/openai-websocket.transport.d.ts +5 -0
  31. package/dist/realtime/openai-websocket.transport.js +3 -0
  32. package/dist/realtime/provider.d.ts +20 -0
  33. package/dist/realtime/provider.js +17 -0
  34. package/dist/realtime/session.d.ts +51 -2
  35. package/dist/realtime/session.js +249 -20
  36. package/dist/realtime/types.d.ts +73 -2
  37. package/dist/types/lifecycle.d.ts +13 -0
  38. package/dist/version.d.ts +1 -1
  39. package/dist/version.js +1 -1
  40. package/dist-cjs/core/lifecycle.d.ts +11 -1
  41. package/dist-cjs/core/lifecycle.js +9 -0
  42. package/dist-cjs/core/nexus.js +4 -0
  43. package/dist-cjs/deep-agent/helpers.d.ts +32 -0
  44. package/dist-cjs/deep-agent/helpers.js +62 -1
  45. package/dist-cjs/deep-agent/index.d.ts +10 -2
  46. package/dist-cjs/graph/graph.d.ts +5 -0
  47. package/dist-cjs/graph/graph.js +6 -0
  48. package/dist-cjs/lifecycle/index.d.ts +1 -1
  49. package/dist-cjs/protocols/a2a.d.ts +84 -1
  50. package/dist-cjs/protocols/a2a.js +178 -14
  51. package/dist-cjs/protocols/acp.d.ts +16 -1
  52. package/dist-cjs/protocols/acp.js +78 -17
  53. package/dist-cjs/protocols/shared.d.ts +15 -0
  54. package/dist-cjs/realtime/conversation.d.ts +28 -1
  55. package/dist-cjs/realtime/conversation.js +66 -1
  56. package/dist-cjs/realtime/gemini-live.transport.d.ts +134 -0
  57. package/dist-cjs/realtime/gemini-live.transport.js +773 -0
  58. package/dist-cjs/realtime/index.d.ts +8 -4
  59. package/dist-cjs/realtime/index.js +15 -1
  60. package/dist-cjs/realtime/openai-server.d.ts +49 -0
  61. package/dist-cjs/realtime/openai-server.js +108 -0
  62. package/dist-cjs/realtime/openai-sip.d.ts +114 -0
  63. package/dist-cjs/realtime/openai-sip.js +114 -0
  64. package/dist-cjs/realtime/openai-webrtc.transport.d.ts +7 -0
  65. package/dist-cjs/realtime/openai-webrtc.transport.js +4 -0
  66. package/dist-cjs/realtime/openai-websocket.transport.d.ts +5 -0
  67. package/dist-cjs/realtime/openai-websocket.transport.js +3 -0
  68. package/dist-cjs/realtime/provider.d.ts +20 -0
  69. package/dist-cjs/realtime/provider.js +19 -1
  70. package/dist-cjs/realtime/session.d.ts +51 -2
  71. package/dist-cjs/realtime/session.js +248 -19
  72. package/dist-cjs/realtime/types.d.ts +73 -2
  73. package/dist-cjs/types/lifecycle.d.ts +13 -0
  74. package/dist-cjs/version.d.ts +1 -1
  75. package/dist-cjs/version.js +1 -1
  76. package/llms.txt +1 -1
  77. package/package.json +33 -5
package/API_STABILITY.md CHANGED
@@ -41,6 +41,49 @@ Both are covered by this policy:
41
41
  Deep imports into `dist`, `dist-cjs`, or `src` are not supported. Import only from `nexus-ai-pro` or
42
42
  one of its explicit subpaths.
43
43
 
44
+ ## 2.10 stage (2.10.0)
45
+
46
+ Everything 2.10 adds is stable from the start and additive, unless this section says otherwise.
47
+
48
+ New and stable:
49
+ - on `nexus-ai-pro/protocols/a2a`: `A2aHandler`, which `a2aHandler()` now returns, with `owner`,
50
+ `recover()`, and `close()`; `A2aRecoveryReport`; `A2aTaskLease` and `A2aTaskRun`, and
51
+ `A2aTaskRecord.lease`, `run`, and `version`; `A2aTaskStore.claim()`, optional;
52
+ `A2aServerOptions.lease` and `recoverEveryMs`. The handler is still the request function it was;
53
+ - on `nexus-ai-pro/protocols/acp`: `AcpTurnRecord` and `AcpSessionRecord.turn`;
54
+ - on `nexus-ai-pro/deep-agent`: `DeepSubagent.remote`, `DeepRemoteHelper`, and `DeepRemoteRunResult`;
55
+ - `CompiledGraph.continueWith()`, and `ProtocolGraph.continueWith()` and `state()`, both optional;
56
+ - on `nexus-ai-pro/lifecycle`: `ProviderHealthOutcome`, `OperationLifecycle.reportHealth()`,
57
+ `OperationLifecycleLike.reportHealth()`, optional, and `LifecycleRuntime.providerHealth`;
58
+ - on `nexus-ai-pro/realtime`: `RealtimeSessionConfig.tenantId`, `userId`, `principal`,
59
+ `traceContext`, `conversationStore`, and `resume`; `RealtimeConversationOptions.replayItems`;
60
+ `RealtimeConversationStore`, `MemoryRealtimeConversationStore`, `storeRealtimeConversations()`,
61
+ `replayRealtimeConversation()`, and `REALTIME_REPLAY_PREFIX`; `GeminiLiveProvider` and
62
+ `GeminiLiveProviderOptions`; `RealtimeSession.sendImage()` and `sendVideoFrame()`,
63
+ `RealtimeSessionConfig.video`, `RealtimeImageInput`, `RealtimeImageOptions`, and
64
+ `RealtimeVideoOptions`; `RealtimeTransport.sendVideo()`, optional;
65
+ - `RealtimeConversationOptions.retainRawAudio`, `RealtimeConversationReducerOptions.inPlace`, and
66
+ `RealtimePeerConnectionLike.addTransceiver()`, optional;
67
+ - `nexus-ai-pro/realtime/gemini-live`, with `GeminiLiveTransport` and `GeminiLiveTransportOptions`;
68
+ - `nexus-ai-pro/realtime/openai-sip`, with `answerOpenAISipCalls()` and its types, also on
69
+ `nexus-ai-pro/realtime`;
70
+ - on `nexus-ai-pro/realtime/openai-server` and `nexus-ai-pro/realtime`: `acceptOpenAIRealtimeCall()`,
71
+ `rejectOpenAIRealtimeCall()`, `referOpenAIRealtimeCall()`, `hangupOpenAIRealtimeCall()`,
72
+ `verifyOpenAIWebhook()`, `OpenAIRealtimeCallOptions`, `OpenAIWebhookEvent`, `OpenAIWebhookHeaders`,
73
+ and `OpenAIWebhookVerifyOptions`; `OpenAIWebSocketTransportOptions.callId`.
74
+
75
+ The realtime family stays experimental, and all of these with it.
76
+
77
+ Changed, each to keep a promise the surface already made:
78
+ - A realtime session's raw events keep an audio event's size, `delta_bytes`, in place of its audio,
79
+ unless `retainRawAudio`: a session held every frame it played, about 13 MB a 3-minute call. The
80
+ `openai-events` export follows.
81
+ - A realtime session announces `conversation.updated` only to a listener, as before, but no longer
82
+ copies its record for each event when none is listening.
83
+ - A realtime session given a lifecycle runs each turn as a `realtime.turn` operation and charges it
84
+ as it finishes; its `realtime.session` operation reports the total and charges nothing more. Its
85
+ usage and cost are the sum of its turns', where each turn's replaced the last before.
86
+
44
87
  ## 2.9 stage (2.9.0)
45
88
 
46
89
  Everything 2.9 adds is stable from the start and additive, unless this section says otherwise.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,141 @@ Notable changes to this project are recorded here. The format follows [Keep a Ch
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.10.0] - 2026-10-16
8
+
9
+ Durable conversations, and a second realtime provider.
10
+
11
+ ### Added
12
+
13
+ - **A2A tasks that outlive their replica.** A working task's record now holds an `A2aTaskLease` and
14
+ an `A2aTaskRun`. The lease names the replica running the task, which renews it every third of
15
+ `lease.ms` (30 seconds); the run says what the task was asked to do. `a2aHandler()` returns an
16
+ `A2aHandler`: the request handler, plus `recover()` and `close()`. `recover()` takes over working
17
+ tasks whose lease lapsed, and carries each run on in one of three ways:
18
+ - from the graph's last checkpoint;
19
+ - by answering its questions again, when a resume never reached the graph;
20
+ - from the start, when the run left no checkpoint.
21
+
22
+ `recoverEveryMs` runs it on a timer. `A2aTaskStore.claim()` takes a lease in one step, so two
23
+ replicas recovering at once take a task once. `MemoryA2aTaskStore` has it, and `storeA2aTasks()`
24
+ builds it on `putIfVersion()`. A replica that finds its lease taken stops its run, and never
25
+ records its result over the other's.
26
+ - **ACP turns that outlive the agent.** While a prompt runs, its session record holds an
27
+ `AcpTurnRecord`. When the editor reopens the session with `session/load`, the agent carries the
28
+ turn on, streaming its updates: from the graph's last checkpoint, by answering again, or from the
29
+ start. A turn stopped while the editor was deciding an approval asks for it again.
30
+ - **Realtime on the client's lifecycle.** A session given `ai.lifecycle` runs as `realtime.session`,
31
+ and each model turn as `realtime.turn`, carrying the session's `tenantId`, `userId` (the
32
+ `principal`'s by default), and `traceContext`. A turn is admitted as it starts, rate-limited,
33
+ authorized, and holding the average turn cost against the budget, and charged its cost when it
34
+ finishes. A turn the lifecycle refuses is cancelled before the model runs it, and the session ends
35
+ with `lifecycle_refused`. Connections are reported to the client's provider health and circuit
36
+ breaker through the new `reportHealth()`, on `OperationLifecycleLike` and `OperationLifecycle`,
37
+ with `ProviderHealthOutcome` and `LifecycleRuntime.providerHealth`.
38
+ - **Realtime conversations that outlive their process.** `conversationStore`, a
39
+ `RealtimeConversationStore`, keeps the record after each committed turn:
40
+ `MemoryRealtimeConversationStore`, or `storeRealtimeConversations()` on any `Store`. `resume`
41
+ carries a conversation on, from its record or its id: same id, items, metrics, and metadata. On
42
+ connecting, the provider is given what was said and what the tools returned before anything new,
43
+ through `replayRealtimeConversation()`. Echoes carry `REALTIME_REPLAY_PREFIX` and are not recorded
44
+ twice. `conversation.replayItems` caps the replay.
45
+ - **Gemini Live, behind the same realtime session.** `GeminiLiveTransport`, on the new
46
+ `nexus-ai-pro/realtime/gemini-live`, speaks Gemini's Live API, and `GeminiLiveProvider`, on
47
+ `nexus-ai-pro/realtime`, creates sessions on it for `createRealtimeAgent()`. The session's
48
+ configuration becomes Gemini's setup; audio, messages, and tool results become its realtime input,
49
+ turns, and tool responses; and its audio, transcripts, tool calls, interruptions, and usage become
50
+ the events the session already reads. It takes an API key on a server or an ephemeral token
51
+ anywhere, and reconnects a dropped socket with Gemini's latest resumption handle.
52
+ - **Helpers on another server, whose runs outlive their worker.** A `DeepSubagent` with `remote`, a
53
+ `DeepRemoteHelper` such as `createRemoteGraph()` returns, runs on another agent server, as a run
54
+ leased to the worker that runs it: when that worker stops, another takes it over. The agent starts
55
+ the run under a key made from the call that started it, so delegating again or collecting it, in
56
+ this process or another, finds the same run; a background task is marked on its thread first, so a
57
+ run that never reached the server is started by the process that collects it. Its answer comes from
58
+ a `DeepRemoteRunResult`.
59
+ - **Phone calls answered by a realtime agent, over SIP.** `answerOpenAISipCalls()`, on the new
60
+ `nexus-ai-pro/realtime/openai-sip`, handles OpenAI's `realtime.call.incoming` webhook: it verifies
61
+ each delivery, claims it so a retry answers its call once, and answers the call with the session
62
+ `answer()` chooses from the caller, which attaches to the call and runs it with its tools,
63
+ record, and lifecycle; or declines it with a SIP status. `OpenAISipCall` transfers and hangs up.
64
+ The call control is on `nexus-ai-pro/realtime/openai-server` too: `acceptOpenAIRealtimeCall()`,
65
+ `rejectOpenAIRealtimeCall()`, `referOpenAIRealtimeCall()`, `hangupOpenAIRealtimeCall()`, and
66
+ `verifyOpenAIWebhook()`, on Web Crypto. `OpenAIWebSocketTransportOptions.callId` attaches a
67
+ transport to a call.
68
+ - **A published method and budget for realtime turn latency and session load.** `npm run
69
+ bench:realtime`, in the release gate, holds what a session adds to a message (under 5 ms at p99 for
70
+ one call, under 20 ms with 250 calls at real time), that a turn late in a long call costs what an
71
+ early one does, that 250 calls at real time keep up, the heap each session holds, and that ended
72
+ sessions leave nothing. Budgets are in `realtime-budget.json`.
73
+ - **Realtime over real WebRTC in a browser.** `npm run test:browser:realtime`, in `npm test`, drives a
74
+ session in headless Chromium against a peer connection in the page that plays the provider: real
75
+ SDP, ICE, DTLS, and data channel, the microphone up, the model's voice down, a tool call, and a
76
+ session with no microphone.
77
+ - `retainRawAudio` on a realtime conversation's options, `inPlace` on
78
+ `RealtimeConversationReducerOptions`, and `addTransceiver()`, optional, on
79
+ `RealtimePeerConnectionLike`.
80
+ - **Video and images in a realtime session.** `sendVideoFrame()` streams camera or screen frames:
81
+ as realtime video on Gemini Live, as images in the conversation elsewhere, spaced by
82
+ `RealtimeVideoOptions.minIntervalMs`. `sendImage()` adds an image, with text, to the conversation.
83
+ `RealtimeTransport.sendVideo()`, optional, is what a transport with video input implements.
84
+ - `continueWith()` on a compiled graph: `continue()`, returning the final result.
85
+ `ProtocolGraph.continueWith()` and `state()`, both optional, are what A2A and ACP carry a run on
86
+ with.
87
+
88
+ ### Fixed
89
+
90
+ - The WebRTC transport trimmed the SDP answer's final line break, and Chromium refuses a description
91
+ whose last line has none, so a session in Chrome or Edge never connected.
92
+ - A WebRTC session that sent no microphone offered no audio to receive, so it never heard the model.
93
+ - A realtime session copied its whole conversation record twice for every event, so each event cost
94
+ more as the call went on: a turn took 39 ms by the 500th, and 250 calls took half a core. It now
95
+ applies events in place and copies the record only for a `conversation.updated` listener.
96
+ - An A2A `SendMessage` with `returnImmediately`, whose store failed while the task ran, raised an
97
+ unhandled rejection, which ends a Node process by default. A store that cannot record a task's
98
+ failure now leaves the task working, with its lease, for the replica that takes it over.
99
+ - A realtime session's usage and estimated cost are the sum of its responses'. Before, each
100
+ response's usage replaced the last, so a session recorded, and settled, only its last turn.
101
+
102
+ ### Proof
103
+
104
+ - `tests/conversation-crash.test.ts`: an A2A task, an ACP turn, and a realtime conversation are each
105
+ killed at every write a clean run makes, and taken over elsewhere. Each ends as the clean run did,
106
+ or its caller was told it never started; nothing is left half done, and nothing is recorded twice.
107
+ The suite found three defects before they shipped, each fixed: an unhandled rejection from a
108
+ failing store; a task submitted to a replica that stopped before starting it, which no replica
109
+ would ever start; and a reopened session's save that dropped the turn it was about to carry on.
110
+ - `tests/deep-agent-remote-helpers.test.ts`: against two replicas of a real agent server, the agent's
111
+ process and the worker running its remote helper both die; the second replica takes the run over,
112
+ and the agent, resumed in a new process, collects that same run: one run, its answer, and the
113
+ agent's note not written again. Without the key, the resumed agent starts a second run, and the
114
+ proof fails.
115
+ - `tests/realtime-sip.test.ts`: a signed incoming-call delivery is accepted with the agent's session,
116
+ chosen from the caller's number; the session attaches by the call's id, books a table through its
117
+ tool when the caller asks, and hangs up. Forged, altered, and stale deliveries answer nothing; a
118
+ retry after a failed accept answers the call, and a duplicate, on one replica or two, answers it
119
+ once. Without the signature check, the claim, or the release, a test fails.
120
+ - `tests/realtime-gemini.test.ts`: one agent definition (instructions, a tool, conversation settings)
121
+ runs on OpenAI's realtime API and on Gemini Live, each over its own scripted wire protocol. The two
122
+ records match item for item: what the person asked, the tool call and its result, and what the model
123
+ answered, with the same usage, and the tool runs once on each. Without the tool response, or with
124
+ the request for a response not dropped after it, the proof fails.
125
+ - `tests/realtime-lifecycle.test.ts`: through a real client with a five-cent budget, two
126
+ two-cent turns run, and the third is cancelled as it starts and ends the session; the budget holds
127
+ four cents, and every operation names the tenant and user. Connection failures open the client's
128
+ circuit. Without the admission at the turn's start, the proof fails.
129
+ - `tests/realtime-resume.test.ts`: a session's process is killed mid-conversation on the memory and
130
+ SQLite stores. Another resumes it by id on a new transport, and gives the provider what was said
131
+ before anything new. The conversation goes on under its own id with nothing recorded twice.
132
+ Without the replay, or without skipping the echoes, the proof fails.
133
+ - `tests/a2a-takeover.test.ts`: a task whose replica dies halfway through a tool is taken over, on
134
+ the memory, SQLite, Postgres, and Redis stores. The model call the dead replica finished is not
135
+ made again, and the email it never sent is sent once. Two replicas recovering at the same moment
136
+ take the task over once, and a lease renewed while a slow task works is never taken. Without the
137
+ lease check, or carried on from the start instead of the checkpoint, the proof fails.
138
+ - `tests/acp-turns.test.ts`: an agent stopped halfway through a tool is started again and its
139
+ session reopened; the turn goes on without repeating the finished model call, and sends the email
140
+ once. A turn stopped during an approval asks again. Without the recovery on load, the proof fails.
141
+
7
142
  ## [2.9.0] - 2026-10-15
8
143
 
9
144
  Video, and everything shipped, proven.
@@ -2559,7 +2694,8 @@ shared policy vocabulary is in place for a future release that revisits this.
2559
2694
  - URL fetching rejects unsafe private-network targets and limits response reads.
2560
2695
  - CLI and audit findings no longer disclose detected secret values.
2561
2696
 
2562
- [Unreleased]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v2.9.0...HEAD
2697
+ [Unreleased]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v2.10.0...HEAD
2698
+ [2.10.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v2.9.0...v2.10.0
2563
2699
  [2.9.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v2.8.0...v2.9.0
2564
2700
  [2.8.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v2.7.0...v2.8.0
2565
2701
  [2.7.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v2.6.0...v2.7.0
package/README.md CHANGED
@@ -294,14 +294,19 @@ Shipped so far:
294
294
  priced per second; online evaluation that reads every run however many share a millisecond, and
295
295
  budget reservations a dead worker leaves settled by what its call did; a deep agent's helper limit
296
296
  across processes; a media index that remembers what it holds; and the experimental families run
297
- against their live services every week.
297
+ against their live services every week;
298
+ - 2.10 durable conversations, and a second realtime provider: A2A tasks, ACP turns, and realtime
299
+ sessions taken over by another worker when theirs dies, each proven killed at every write; realtime
300
+ on the client's lifecycle; Gemini Live behind the same session, with video and images; phone calls
301
+ answered over SIP; helpers on another server whose runs outlive their worker; and realtime over
302
+ real WebRTC in a browser, with published budgets for turn latency and session load.
298
303
 
299
304
  Upgrading from 1.x: `npx nexus migrate src --write` moves your imports, and
300
305
  [MIGRATING.md](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/MIGRATING.md) covers the rest.
301
- Next, 2.10 makes every conversation that runs on a worker durable, A2A tasks, ACP turns, and
302
- realtime sessions, and adds Gemini Live as a second realtime provider; Pinecone, images, the Gemini
303
- embedding batch tier, media understanding, and video graduate once the live workflow passes against
304
- each. The
306
+ Next, 2.11 separates where an agent's files live from where its code runs, with workspaces, and
307
+ completes telephony: a second provider, messaging, recording, conference, and transfer. Pinecone,
308
+ images, the Gemini embedding batch tier, media understanding, and video graduate once the live
309
+ workflow passes against each. The
305
310
  [roadmap](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/ROADMAP.md) lists what comes after, in the order
306
311
  applications ask for it.
307
312
 
@@ -1,5 +1,5 @@
1
1
  import type { RateLimitConfig } from '../types/config.js';
2
- import type { LifecycleConfig, LifecycleStage, OperationDescriptor, OperationLifecycleLike, OperationResultInfo, OperationStartOptions, OperationTicket, ProviderCallContext } from '../types/lifecycle.js';
2
+ import type { ProviderHealthOutcome, LifecycleConfig, LifecycleStage, OperationDescriptor, OperationLifecycleLike, OperationResultInfo, OperationStartOptions, OperationTicket, ProviderCallContext } from '../types/lifecycle.js';
3
3
  import type { AuditLogger } from '../ops/audit-logger.js';
4
4
  import type { MetricsCollector } from '../ops/metrics.js';
5
5
  import { type RateLimiter } from '../ops/rate-limiter.js';
@@ -45,6 +45,11 @@ export interface LifecycleRuntime {
45
45
  config?: LifecycleConfig;
46
46
  /** Default milliseconds an operation may run before its signal aborts. */
47
47
  timeoutMs?: number;
48
+ /** Where `reportHealth()` goes: the client's provider health and circuit breaker. */
49
+ providerHealth?: {
50
+ recordSuccess(provider: string, latencyMs: number): void;
51
+ recordFailure(provider: string, error: unknown): void;
52
+ };
48
53
  }
49
54
  /** The family's part of one operation: what each stage does for it. Every stage but `execute` is optional. */
50
55
  export interface OperationPlan<T> {
@@ -99,6 +104,11 @@ export declare class OperationLifecycle implements OperationLifecycleLike {
99
104
  }, plan: OperationPlan<T>, options?: OperationStartOptions & {
100
105
  audit?: Record<string, unknown>;
101
106
  }): Promise<T>;
107
+ /**
108
+ * Reports how a provider answered a call a family made itself, such as a realtime connection, to the
109
+ * client's provider health and circuit breaker.
110
+ */
111
+ reportHealth(provider: string, outcome: ProviderHealthOutcome): void;
102
112
  /** True when nothing is configured, so a caller may skip work that only feeds the lifecycle. */
103
113
  get inert(): boolean;
104
114
  private checkRateLimit;
@@ -91,6 +91,15 @@ export class OperationLifecycle {
91
91
  throw error;
92
92
  }
93
93
  }
94
+ reportHealth(provider, outcome) {
95
+ const health = this.runtime.providerHealth;
96
+ if (!health || !provider)
97
+ return;
98
+ if (outcome.ok)
99
+ health.recordSuccess(provider, outcome.latencyMs);
100
+ else
101
+ health.recordFailure(provider, outcome.error);
102
+ }
94
103
  get inert() {
95
104
  const config = this.runtime.config;
96
105
  return (!this.runtime.metrics?.enabled &&
@@ -75,6 +75,10 @@ export class NexusAI {
75
75
  rateLimiter: this.rateLimiter,
76
76
  rateLimit: this.config.rateLimit,
77
77
  config: this.config.lifecycle,
78
+ providerHealth: {
79
+ recordSuccess: (provider, latencyMs) => this.attemptHooks.onAttemptSuccess(provider, latencyMs),
80
+ recordFailure: (provider, error) => this.attemptHooks.onAttemptFailure(provider, error),
81
+ },
78
82
  });
79
83
  const familyRuntime = {
80
84
  metrics: this.metrics,
@@ -10,6 +10,10 @@
10
10
  * The helpers an agent creates are recorded on a thread of their own beside its own, before the call
11
11
  * that creates one returns, so an agent resumed in another process counts them against its `max` and
12
12
  * delegates to them again by name.
13
+ *
14
+ * A remote helper runs on another agent server, as a run that server's workers lease: when the worker
15
+ * running it stops, another takes it over. The agent starts it under a key made from the call that
16
+ * started it, so delegating again or collecting it, in this process or another, finds that same run.
13
17
  */
14
18
  import { type CreateAgentOptions } from '../agent/create-agent.js';
15
19
  import type { PermissionPolicyLike } from '../agent/permissions.js';
@@ -29,6 +33,33 @@ export interface DeepHelperLimits {
29
33
  /** How many helpers the agent may create in one thread. Defaults to 5. */
30
34
  max?: number;
31
35
  }
36
+ /** How a run on another agent server ended, as `createRemoteGraph()` reports it. */
37
+ export interface DeepRemoteRunResult {
38
+ /** The run's id on that server. */
39
+ runId: string;
40
+ /** Where it ended: `succeeded`, `failed`, `cancelled`, `expired`, or `awaiting_input`. */
41
+ status: string;
42
+ /** What it produced. */
43
+ output?: unknown;
44
+ /** Why it failed. */
45
+ error?: {
46
+ message?: string;
47
+ };
48
+ }
49
+ /**
50
+ * A helper on another agent server: what `createRemoteGraph()` returns, or anything that runs an input
51
+ * there and waits for it.
52
+ */
53
+ export interface DeepRemoteHelper {
54
+ /**
55
+ * Runs an input there and waits for it to end. A second call with the same `idempotencyKey` waits
56
+ * for the run the first started, as the agent server does, rather than starting another.
57
+ */
58
+ invoke(input: unknown, options: {
59
+ idempotencyKey: string;
60
+ signal?: AbortSignal;
61
+ }): Promise<DeepRemoteRunResult>;
62
+ }
32
63
  /** What the deep agent hands the helper tools. */
33
64
  export interface HelperSetup {
34
65
  /** The helpers the application declared. */
@@ -39,6 +70,7 @@ export interface HelperSetup {
39
70
  tools?: readonly string[];
40
71
  model?: string;
41
72
  maxIterations?: number;
73
+ remote?: DeepRemoteHelper;
42
74
  }>;
43
75
  /** Limits on created helpers, when the agent may create them. */
44
76
  create?: DeepHelperLimits;
@@ -59,6 +59,7 @@ export function helperTools(setup) {
59
59
  ...(helper.model ? { model: helper.model } : {}),
60
60
  maxIterations: helper.maxIterations ?? 20,
61
61
  created: false,
62
+ ...(helper.remote ? { remote: helper.remote } : {}),
62
63
  };
63
64
  }
64
65
  await loadCreated(thread);
@@ -144,9 +145,50 @@ export function helperTools(setup) {
144
145
  return { helper, ok: false, error: `The ${helper} helper stopped: ${why ?? 'it did not run'}` };
145
146
  };
146
147
  const thread = (parent, task) => `${parent ?? 'thread'}/helper/${task}`;
148
+ const remoteRun = async (helper, remote, task, key, signal) => {
149
+ const run = await remote.invoke(agentInput(`${helper.instructions}\n\n${task}`), {
150
+ idempotencyKey: key,
151
+ ...(signal ? { signal } : {}),
152
+ });
153
+ if (run.status === 'succeeded')
154
+ return { helper: helper.name, ok: true, answer: remoteAnswer(run.output) };
155
+ const why = run.status === 'awaiting_input' ? 'it stopped to ask for input' : (run.error?.message ?? run.status);
156
+ return { helper: helper.name, ok: false, error: `The ${helper.name} helper stopped: ${why}` };
157
+ };
158
+ const markRemote = async (helper, task, threadId) => {
159
+ await setup.checkpointer?.put({
160
+ version: 2,
161
+ id: `${threadId}:remote`,
162
+ threadId,
163
+ step: 0,
164
+ state: {},
165
+ next: [],
166
+ tasks: [],
167
+ status: 'running',
168
+ interrupts: [],
169
+ createdAt: new Date().toISOString(),
170
+ metadata: { [MARK]: { name: helper.name, created: false, remote: true, task } },
171
+ });
172
+ };
147
173
  const start = async (helper, task, threadId, context) => {
148
174
  if (live.has(threadId))
149
175
  return;
176
+ if (helper.remote) {
177
+ if (task !== undefined)
178
+ await markRemote(helper, task, threadId);
179
+ const entry = { helper: helper.name, settled: false, outcome: Promise.resolve(undefined) };
180
+ entry.outcome = remoteRun(helper, helper.remote, task ?? '', threadId)
181
+ .catch((error) => ({
182
+ helper: helper.name,
183
+ ok: false,
184
+ error: error instanceof Error ? error.message : String(error),
185
+ }))
186
+ .finally(() => {
187
+ entry.settled = true;
188
+ });
189
+ live.set(threadId, entry);
190
+ return;
191
+ }
150
192
  const runner = graph(helper, true);
151
193
  const stored = setup.checkpointer ? await runner.state(threadId) : undefined;
152
194
  let ready = () => { };
@@ -220,6 +262,12 @@ export function helperTools(setup) {
220
262
  const helper = await spec(String(args.agent ?? ''), typeof args.instructions === 'string' ? args.instructions : undefined, context.threadId ?? '');
221
263
  const task = String(args.task ?? '');
222
264
  const call = context.toolCallId ?? Math.random().toString(36).slice(2, 10);
265
+ if (args.background !== true && helper.remote) {
266
+ const ended = await remoteRun(helper, helper.remote, task, thread(context.threadId, `${helper.name}@${call}`), context.signal);
267
+ if (!ended.ok)
268
+ throw new Error(ended.error);
269
+ return ended.answer;
270
+ }
223
271
  if (args.background !== true) {
224
272
  const result = await graph(helper, false).invoke(agentInput(task), runOptions(context, `${helper.name}@${call}`, context.signal ? { signal: context.signal } : {}));
225
273
  return (result.state.answer || `The ${helper.name} helper finished without an answer (${result.state.stopReason}).`);
@@ -265,7 +313,7 @@ export function helperTools(setup) {
265
313
  const helper = mark?.created
266
314
  ? await spec(name, mark.instructions, context.threadId ?? '')
267
315
  : await spec(name, undefined, context.threadId ?? '');
268
- await start(helper, undefined, threadId, context);
316
+ await start(helper, mark?.remote ? (mark.task ?? '') : undefined, threadId, context);
269
317
  }
270
318
  const entry = live.get(threadId);
271
319
  const waitMs = Math.min(Math.max(Number(args.wait_seconds) || 0, 0), 120) * 1_000;
@@ -285,3 +333,16 @@ export function helperTools(setup) {
285
333
  };
286
334
  return [delegate, collect];
287
335
  }
336
+ function remoteAnswer(output) {
337
+ if (typeof output === 'string')
338
+ return output;
339
+ if (output && typeof output === 'object') {
340
+ const record = output;
341
+ if (typeof record.answer === 'string' && record.answer)
342
+ return record.answer;
343
+ const last = record.messages?.filter((message) => message.role === 'assistant').at(-1);
344
+ if (typeof last?.content === 'string' && last.content)
345
+ return last.content;
346
+ }
347
+ return JSON.stringify(output ?? null);
348
+ }
@@ -13,8 +13,8 @@ import { type ContextOffloadOptions } from '../agent/middleware/filesystem.js';
13
13
  import { type ToolSelectorOptions } from '../agent/middleware/tool-selector.js';
14
14
  import { type PermissionPolicyLike } from '../agent/permissions.js';
15
15
  import { type Sandbox } from '../agent/sandbox.js';
16
- import { type DeepHelperLimits } from './helpers.js';
17
- export type { DeepHelperLimits } from './helpers.js';
16
+ import { type DeepHelperLimits, type DeepRemoteHelper } from './helpers.js';
17
+ export type { DeepHelperLimits, DeepRemoteHelper, DeepRemoteRunResult } from './helpers.js';
18
18
  /** A helper the deep agent can hand a self-contained piece of work to, with a fresh context. */
19
19
  export interface DeepSubagent {
20
20
  /** Its name, as the agent delegates to it. */
@@ -33,6 +33,14 @@ export interface DeepSubagent {
33
33
  model?: string;
34
34
  /** Model calls it may make. Defaults to 20. */
35
35
  maxIterations?: number;
36
+ /**
37
+ * Runs it on another agent server instead, such as `createRemoteGraph()` reaches: it is sent its
38
+ * `instructions` and the task, and its run's output is its answer. The run is leased to the
39
+ * server's worker that runs it, so another worker takes it over when that one stops, and the agent
40
+ * finds the same run again by the call that started it. `tools`, `model`, and `maxIterations` are
41
+ * the remote assistant's own.
42
+ */
43
+ remote?: DeepRemoteHelper;
36
44
  }
37
45
  /** Instructions the agent loads only when a task needs them: a procedure, a checklist, a house style. */
38
46
  export interface DeepSkill {
@@ -110,6 +110,11 @@ export declare class CompiledGraph<S extends ChannelSchema, I extends keyof S =
110
110
  resumeInterruptsWith(threadId: string, answers: Record<string, unknown>, runOptions?: GraphRunOptions): Promise<GraphResult<S, O>>;
111
111
  /** Continues a thread that stopped for any other reason, such as the step limit. */
112
112
  continue(threadId: string, runOptions?: GraphRunOptions): AsyncIterable<GraphStepEvent<S>>;
113
+ /**
114
+ * Like `continue`, but returns the final result rather than the stream: what a replica calls to
115
+ * finish a run whose process stopped, from the last checkpoint it wrote.
116
+ */
117
+ continueWith(threadId: string, runOptions?: GraphRunOptions): Promise<GraphResult<S, O>>;
113
118
  /**
114
119
  * Rewinds to an earlier superstep and runs forward from there.
115
120
  *
@@ -253,6 +253,12 @@ export class CompiledGraph {
253
253
  return { ...checkpoint, status: 'running', error: undefined, drained: undefined };
254
254
  });
255
255
  }
256
+ async continueWith(threadId, runOptions = {}) {
257
+ let last;
258
+ for await (const event of this.continue(threadId, runOptions))
259
+ last = event;
260
+ return this.toResult(threadId, last);
261
+ }
256
262
  resumeFrom(threadId, step, runOptions = {}) {
257
263
  return this.run(threadId, runOptions, async () => {
258
264
  const stored = await this.checkpointer()?.get(threadId, step);
@@ -5,4 +5,4 @@
5
5
  */
6
6
  export { BudgetExceededError, LIFECYCLE_STAGES, type LifecycleRuntime, OperationDeniedError, OperationLifecycle, type OperationPlan, } from '../core/lifecycle.js';
7
7
  export { type BudgetLedgerOptions, type BudgetLedgerWithReport, type BudgetReservationChange, type BudgetUsageStore, budgetLedger, MemoryBudgetUsage, } from './budget.js';
8
- export type { BudgetLedger, BudgetPeriod, BudgetRecoverOptions, BudgetRecoveryReport, BudgetReservation, BudgetReservationOutcome, BudgetReservationRecord, BudgetReservationStatus, LifecycleConfig, LifecycleHooks, LifecycleStage, OperationDescriptor, OperationFamily, OperationLifecycleLike, OperationOutcome, OperationResultInfo, OperationStartOptions, OperationTicket, ProviderCallContext, } from '../types/lifecycle.js';
8
+ export type { BudgetLedger, BudgetPeriod, BudgetRecoverOptions, BudgetRecoveryReport, BudgetReservation, BudgetReservationOutcome, BudgetReservationRecord, BudgetReservationStatus, LifecycleConfig, LifecycleHooks, LifecycleStage, OperationDescriptor, OperationFamily, OperationLifecycleLike, OperationOutcome, OperationResultInfo, OperationStartOptions, OperationTicket, ProviderCallContext, ProviderHealthOutcome, } from '../types/lifecycle.js';
@@ -176,6 +176,33 @@ export interface A2aTaskRecord {
176
176
  push?: A2aPushNotificationConfig[];
177
177
  /** ISO-8601 time it was last written. */
178
178
  updatedAt: string;
179
+ /**
180
+ * Who is running the task, and until when, while it works. The replica running it renews the lease;
181
+ * once it lapses, another replica's `recover()` may take the task over.
182
+ */
183
+ lease?: A2aTaskLease;
184
+ /** What the working run was asked to do, so a replica that takes it over can carry it on. */
185
+ run?: A2aTaskRun;
186
+ /** Bumped by every claim of the lease, so two replicas claiming one task cannot both win. */
187
+ version?: number;
188
+ }
189
+ /** A replica's hold on a working task. */
190
+ export interface A2aTaskLease {
191
+ /** The replica holding it. */
192
+ owner: string;
193
+ /** ISO-8601 time it lapses unless renewed. */
194
+ expiresAt: string;
195
+ }
196
+ /** A working run, as kept with its task: started by a message, or answering the task's questions. */
197
+ export interface A2aTaskRun {
198
+ /** `invoke` for a run a message started, `resume` for one answering a paused task's questions. */
199
+ kind: 'invoke' | 'resume';
200
+ /** The message that started it. */
201
+ message: A2aMessage;
202
+ /** The answers it resumed with, by interrupt id. */
203
+ answers?: Record<string, unknown>;
204
+ /** Who it runs for. */
205
+ principal?: Principal;
179
206
  }
180
207
  /** Which tasks `A2aTaskStore.list()` returns. */
181
208
  export interface A2aTaskQuery {
@@ -209,6 +236,16 @@ export interface A2aTaskStore {
209
236
  conversation(contextId: string): Promise<Message[] | undefined> | Message[] | undefined;
210
237
  /** Writes a conversation's transcript. */
211
238
  saveConversation(contextId: string, messages: Message[]): Promise<void> | void;
239
+ /**
240
+ * Takes, or renews, the lease of a working task, or of one submitted and not yet started, in one
241
+ * step: when the task has no lease, its lease has lapsed by `lease.now`, or `lease.owner` already
242
+ * holds it. Resolves to the record as claimed,
243
+ * or `undefined` when another replica holds it. Optional; without it, a claim is a read and a write,
244
+ * and two replicas recovering at once may both take one task.
245
+ */
246
+ claim?(taskId: string, lease: A2aTaskLease & {
247
+ now: string;
248
+ }): Promise<A2aTaskRecord | undefined> | A2aTaskRecord | undefined;
212
249
  }
213
250
  /** Tasks and conversations in this process: the default, for one replica. */
214
251
  export declare class MemoryA2aTaskStore implements A2aTaskStore {
@@ -231,6 +268,10 @@ export declare class MemoryA2aTaskStore implements A2aTaskStore {
231
268
  conversation(contextId: string): Message[] | undefined;
232
269
  /** Writes a conversation's transcript. */
233
270
  saveConversation(contextId: string, messages: Message[]): void;
271
+ /** Takes or renews a working task's lease, in one step. */
272
+ claim(taskId: string, lease: A2aTaskLease & {
273
+ now: string;
274
+ }): A2aTaskRecord | undefined;
234
275
  }
235
276
  /**
236
277
  * Tasks and conversations in any long-term `Store`: the memory, Redis, Postgres, or SQLite store, so
@@ -293,9 +334,51 @@ export interface A2aServerOptions {
293
334
  extendedCard?: (principal: Principal) => Partial<A2aAgentCard> | Promise<Partial<A2aAgentCard>>;
294
335
  /** How often `SubscribeToTask` reads a task another replica runs, in milliseconds. Defaults to 1 second. */
295
336
  pollMs?: number;
337
+ /**
338
+ * The lease a replica holds on each task it runs: renewed every third of `ms` (30 seconds by
339
+ * default), and taken over by another replica's `recover()` once it lapses. `owner` names this
340
+ * replica; it defaults to a random id.
341
+ */
342
+ lease?: {
343
+ ms?: number;
344
+ owner?: string;
345
+ };
346
+ /**
347
+ * Runs `recover()` every this many milliseconds, so a task a stopped replica was working on is taken
348
+ * over without a caller asking. Off by default; `close()` stops it.
349
+ */
350
+ recoverEveryMs?: number;
296
351
  /** Receives push delivery failures, which never fail a task. */
297
352
  onError?: (error: unknown) => void;
298
353
  }
354
+ /** What one `recover()` did. */
355
+ export interface A2aRecoveryReport {
356
+ /** Tasks whose lease had lapsed, taken over and carried on here. */
357
+ tookOver: string[];
358
+ /** Tasks another replica claimed first, or that finished meanwhile. */
359
+ skipped: number;
360
+ }
361
+ /**
362
+ * An A2A agent's HTTP handler, with what keeps its tasks alive across replicas: `recover()` takes over
363
+ * the working tasks whose replica stopped renewing their lease, and `close()` stops the recovery timer.
364
+ */
365
+ export interface A2aHandler {
366
+ /** Answers one HTTP request: `GET` the card, `POST` JSON-RPC. */
367
+ (request: Request): Promise<Response>;
368
+ /** This replica's name in the leases it holds. */
369
+ readonly owner: string;
370
+ /**
371
+ * Takes over working tasks whose lease lapsed, up to `limit` (10), and carries each on: from the
372
+ * graph's last checkpoint, by answering its questions again when the run was a resume that never
373
+ * reached the graph, or from the start when the run left no checkpoint. Resolves once they are
374
+ * claimed, not finished.
375
+ */
376
+ recover(options?: {
377
+ limit?: number;
378
+ }): Promise<A2aRecoveryReport>;
379
+ /** Stops the recovery timer `recoverEveryMs` started. Tasks running here carry on. */
380
+ close(): void;
381
+ }
299
382
  /**
300
383
  * An HTTP handler that serves a graph as an A2A agent: `GET` the card, `POST` JSON-RPC. Mount it
301
384
  * at the card's `url`; it answers the well-known card path under the same origin too.
@@ -306,7 +389,7 @@ export interface A2aServerOptions {
306
389
  * });
307
390
  * ```
308
391
  */
309
- export declare function a2aHandler(graph: ProtocolGraph, options: A2aServerOptions): (request: Request) => Promise<Response>;
392
+ export declare function a2aHandler(graph: ProtocolGraph, options: A2aServerOptions): A2aHandler;
310
393
  /** Options for `a2aClient()`. */
311
394
  export interface A2aClientOptions {
312
395
  /** The agent: its card's URL, or its JSON-RPC endpoint, whose origin serves the card. */