@dxos/agent-runtime 0.13.0 → 0.14.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 (32) hide show
  1. package/dist/lib/chunk-AgentService.mjs +189 -93
  2. package/dist/lib/chunk-AgentService.mjs.map +1 -1
  3. package/dist/lib/testing.mjs +3 -3
  4. package/dist/lib/testing.mjs.map +1 -1
  5. package/dist/types/src/agent-service/AgentService.d.ts +9 -0
  6. package/dist/types/src/agent-service/AgentService.d.ts.map +1 -1
  7. package/dist/types/src/agent-service/agent-process.d.ts +23 -5
  8. package/dist/types/src/agent-service/agent-process.d.ts.map +1 -1
  9. package/dist/types/src/agent-service/delegation-strategy.d.ts +5 -4
  10. package/dist/types/src/agent-service/delegation-strategy.d.ts.map +1 -1
  11. package/dist/types/src/agent-service/index.d.ts +1 -1
  12. package/dist/types/src/agent-service/index.d.ts.map +1 -1
  13. package/dist/types/src/agent-service/process-selection.test.d.ts +2 -0
  14. package/dist/types/src/agent-service/process-selection.test.d.ts.map +1 -0
  15. package/dist/types/src/agent-service/skill-hooks.test.d.ts +2 -0
  16. package/dist/types/src/agent-service/skill-hooks.test.d.ts.map +1 -0
  17. package/dist/types/src/testing/assistant-test-layer.d.ts +2 -2
  18. package/dist/types/src/testing/assistant-test-layer.d.ts.map +1 -1
  19. package/dist/types/tsconfig.tsbuildinfo +1 -1
  20. package/package.json +17 -17
  21. package/src/agent-service/AgentService.test.ts +4 -4
  22. package/src/agent-service/AgentService.ts +63 -24
  23. package/src/agent-service/README.md +10 -10
  24. package/src/agent-service/agent-process.ts +201 -100
  25. package/src/agent-service/delegation-scripted.test.ts +4 -4
  26. package/src/agent-service/delegation-strategy.ts +9 -4
  27. package/src/agent-service/index.ts +1 -0
  28. package/src/agent-service/process-selection.test.ts +135 -0
  29. package/src/agent-service/queue-scripted.test.ts +39 -0
  30. package/src/agent-service/redelivery.test.ts +23 -1
  31. package/src/agent-service/skill-hooks.test.ts +182 -0
  32. package/src/testing/assistant-test-layer.ts +3 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/agent-runtime",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Local agent process runtime (agent-service) and assistant-coupled test helpers.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -39,29 +39,29 @@
39
39
  ],
40
40
  "dependencies": {
41
41
  "effect": "4.0.0",
42
- "@dxos/ai": "0.13.0",
43
- "@dxos/compute": "0.13.0",
44
- "@dxos/compute-runtime": "0.13.0",
45
- "@dxos/echo": "0.13.0",
46
- "@dxos/echo-client": "0.13.0",
47
- "@dxos/effect": "0.13.0",
48
- "@dxos/keys": "0.13.0",
49
- "@dxos/link": "0.13.0",
50
- "@dxos/log": "0.13.0",
51
- "@dxos/mcp-client": "0.13.0",
52
- "@dxos/schema": "0.13.0",
53
- "@dxos/types": "0.13.0",
54
- "@dxos/util": "0.13.0"
42
+ "@dxos/ai": "0.14.0",
43
+ "@dxos/compute": "0.14.0",
44
+ "@dxos/compute-runtime": "0.14.0",
45
+ "@dxos/echo": "0.14.0",
46
+ "@dxos/echo-client": "0.14.0",
47
+ "@dxos/effect": "0.14.0",
48
+ "@dxos/keys": "0.14.0",
49
+ "@dxos/link": "0.14.0",
50
+ "@dxos/log": "0.14.0",
51
+ "@dxos/mcp-client": "0.14.0",
52
+ "@dxos/schema": "0.14.0",
53
+ "@dxos/types": "0.14.0",
54
+ "@dxos/util": "0.14.0"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@effect/vitest": "4.0.0",
58
58
  "vitest": "4.1.10",
59
- "@dxos/assistant": "0.13.0",
60
- "@dxos/errors": "0.13.0"
59
+ "@dxos/assistant": "0.14.0",
60
+ "@dxos/errors": "0.14.0"
61
61
  },
62
62
  "peerDependencies": {
63
63
  "effect": "4.0.0",
64
- "@dxos/assistant": "^0.13.0"
64
+ "@dxos/assistant": "^0.14.0"
65
65
  },
66
66
  "publishConfig": {
67
67
  "access": "public"
@@ -22,6 +22,7 @@ import { LanguageModelFixture } from '@dxos/ai/testing';
22
22
  import { type HarnessControlRpcs, SessionLink } from '@dxos/assistant';
23
23
  import * as Chat from '@dxos/assistant/Chat';
24
24
  import { ProcessManager } from '@dxos/compute-runtime';
25
+ import { OperationProcess } from '@dxos/compute-runtime';
25
26
  import * as ComputeAgentService from '@dxos/compute/AgentService';
26
27
  import * as Instructions from '@dxos/compute/Instructions';
27
28
  import * as Operation from '@dxos/compute/Operation';
@@ -213,9 +214,8 @@ const StubDelegationStrategy: DelegationStrategy = {
213
214
  .map((work) => ({
214
215
  id: work.id,
215
216
  spawn: Effect.gen(function* () {
216
- const invoker = yield* ProcessManager.ProcessOperationInvoker.Service;
217
- const fiber = yield* invoker.invokeFiber(DelegatedWork, work.input);
218
- return fiber.pid;
217
+ const handle = yield* Process.spawn(OperationProcess.make(DelegatedWork), work.input);
218
+ return handle.pid;
219
219
  }),
220
220
  })),
221
221
  ),
@@ -597,7 +597,7 @@ describe('Agent Service', { tags: ['model-fixture'] }, () => {
597
597
  const target = Obj.getURI(session.chat);
598
598
  // `list` erases the RPC group to `any`, which Effect 4 resolves to an `unknown` requirement
599
599
  // on every call; naming the group restores it.
600
- const handles: readonly Process.Handle<string | readonly ContentBlock.Any[], void, HarnessControlRpcs>[] =
600
+ const handles: readonly Process.Process<string | readonly ContentBlock.Any[], void, HarnessControlRpcs>[] =
601
601
  yield* processManager.list({
602
602
  target,
603
603
  key: AGENT_PROCESS_KEY,
@@ -10,10 +10,9 @@ import * as Layer from 'effect/Layer';
10
10
  import * as Atom from 'effect/reactivity/Atom';
11
11
  import * as Semaphore from 'effect/Semaphore';
12
12
 
13
- import { AiContext } from '@dxos/assistant';
13
+ import { AiContext, type HarnessControlRpcs } from '@dxos/assistant';
14
14
  import * as Chat from '@dxos/assistant/Chat';
15
15
  import * as AgentService from '@dxos/compute/AgentService';
16
- import * as Operation from '@dxos/compute/Operation';
17
16
  import * as Process from '@dxos/compute/Process';
18
17
  import * as Skill from '@dxos/compute/Skill';
19
18
  import { Annotation, Database, Feed, Obj, Ref, Registry } from '@dxos/echo';
@@ -22,24 +21,12 @@ import { DXN, EID, type SpaceId } from '@dxos/keys';
22
21
  import { log } from '@dxos/log';
23
22
  import type { ContentBlock } from '@dxos/types';
24
23
 
25
- import { AGENT_PROCESS_KEY, AgentProcess } from './agent-process.ts';
24
+ import { AGENT_PROCESS_KEY, type AgentInput, AgentProcess, type AgentProcessDefinition } from './agent-process.ts';
26
25
  import { type DelegationStrategy } from './delegation-strategy.ts';
27
26
  import { type MakeTurnProducer } from './turn-producer.ts';
28
27
 
29
- /** The RPC control surface declared by {@link AgentProcess}, recovered from the executable type. */
30
- type AgentRpcs = ReturnType<typeof AgentProcess> extends Operation.Durable<any, any, any, infer Rpcs> ? Rpcs : never;
31
-
32
- /**
33
- * Live handle to a spawned {@link AgentProcess}, carrying its `HarnessControl` RPC surface.
34
- *
35
- * Derived from the definition rather than restated: `Process` exposes its input/output codecs, which
36
- * puts `_Input` in an invariant position, so a hand-written `ContentBlock.Any[]` no longer relates to
37
- * the `readonly` array the process's own schema yields.
38
- */
39
- type AgentHandle =
40
- ReturnType<typeof AgentProcess> extends Operation.Durable<infer Input, infer Output, any, infer Rpcs>
41
- ? Process.Handle<Input, Output, Rpcs>
42
- : never;
28
+ /** Live handle to a spawned agent process, carrying its `HarnessControl` RPC surface. */
29
+ type AgentHandle = Process.Process<AgentInput, void, HarnessControlRpcs>;
43
30
 
44
31
  // TODO(burdon): Agent identity?
45
32
  export interface CreateSessionOptions {
@@ -111,6 +98,15 @@ export interface Options {
111
98
  * child processes and folds their results back into the conversation. Absent — a plain agent.
112
99
  */
113
100
  delegationStrategy?: DelegationStrategy;
101
+
102
+ /**
103
+ * Process definitions a chat can name in `chat.session.process` to run on instead of
104
+ * {@link AgentProcess}. Read per session, so a definition contributed after the layer was built
105
+ * is still found.
106
+ */
107
+ processes?: () => readonly AgentProcessDefinition[];
108
+ /** Keep each session's process resident between turns (see `AgentProcessOptions.resident`). */
109
+ resident?: boolean;
114
110
  }
115
111
 
116
112
  /**
@@ -159,6 +155,7 @@ export const layer = (opts?: Options): Layer.Layer<AgentService.AgentService, ne
159
155
  provider: DXN.DXN | undefined;
160
156
  instructions: string | undefined;
161
157
  location: AgentService.AgentLocation;
158
+ process: string;
162
159
  handle: AgentHandle;
163
160
  session: AgentService.Session;
164
161
  }
@@ -187,25 +184,52 @@ export const layer = (opts?: Options): Layer.Layer<AgentService.AgentService, ne
187
184
  provider: provider ?? opts?.provider,
188
185
  enableToolBackgrounding: opts?.enableToolBackgrounding,
189
186
  delegationStrategy: opts?.delegationStrategy,
187
+ resident: opts?.resident,
190
188
  });
191
189
 
190
+ /**
191
+ * The process a chat runs on: the one its session names, or ours. A name nothing contributed
192
+ * (its plugin was removed) falls back to ours rather than leaving the chat unable to run.
193
+ */
194
+ const executableFor = (chat: AgentService.Conversation, provider?: DXN.DXN): AgentProcessDefinition => {
195
+ const key = chat.session?.process;
196
+ if (key === undefined || key === AGENT_PROCESS_KEY) {
197
+ return makeExecutable(provider);
198
+ }
199
+ const definition = opts?.processes?.().find((candidate) => candidate.key === key);
200
+ if (!definition) {
201
+ log.warn('agent process not contributed; running the default', { key });
202
+ return makeExecutable(provider);
203
+ }
204
+ return definition;
205
+ };
206
+
192
207
  const hydrateAgents = Effect.fnUntraced(function* () {
193
208
  // Handles cached before shutdown are suspended and no longer registered with the manager.
194
209
  sessionCache.clear();
195
210
 
211
+ // Contributed processes run only locally (see `getSession`), so only ours is looked for on edge.
212
+ const contributed = (opts?.processes?.() ?? []).filter(({ key }) => key !== AGENT_PROCESS_KEY);
196
213
  const executable = makeExecutable();
197
214
  // Local, plus every space a session has already been opened on this run. A fresh client knows
198
215
  // no edge spaces yet and cannot enumerate them (one manager spans them all), but an edge agent
199
216
  // does not need the pre-warm: `getSession` reattaches to a process still running for its
200
217
  // chat, which is the path opening one takes.
201
218
  const agents = [
202
- ...(yield* processManager.handles({ key: AGENT_PROCESS_KEY })),
219
+ ...(yield* processManager.handles({ key: AGENT_PROCESS_KEY })).map((agent) => ({ agent, executable })),
220
+ ...(yield* Effect.forEach(contributed, (definition) =>
221
+ processManager
222
+ .handles({ key: definition.key })
223
+ .pipe(Effect.map((agents) => agents.map((agent) => ({ agent, executable: definition })))),
224
+ )).flat(),
203
225
  ...(yield* Effect.forEach([...remoteSpaces], (space) =>
204
226
  processManager.handles({ key: AGENT_PROCESS_KEY, location: { kind: 'edge', space } }),
205
- )).flat(),
227
+ ))
228
+ .flat()
229
+ .map((agent) => ({ agent, executable })),
206
230
  ];
207
231
  log('agent hydrate', { count: agents.length });
208
- for (const agent of agents) {
232
+ for (const { agent, executable } of agents) {
209
233
  yield* agent
210
234
  .hydrate(executable)
211
235
  .pipe(
@@ -227,6 +251,13 @@ export const layer = (opts?: Options): Layer.Layer<AgentService.AgentService, ne
227
251
  const model = chat.session?.model;
228
252
  const instructions = chat.instructions?.uri;
229
253
  const location: AgentService.AgentLocation = options?.location ?? 'local';
254
+ const executable = executableFor(chat, provider);
255
+ if (location === 'edge' && executable.key !== AGENT_PROCESS_KEY) {
256
+ // EDGE spawns by key from its own registry, which holds no process a plugin contributed.
257
+ return yield* Effect.die(
258
+ new Error(`Agent process ${executable.key} runs only locally, but the chat asked for edge.`),
259
+ );
260
+ }
230
261
  const cached = sessionCache.get(chat.id);
231
262
  if (cached) {
232
263
  if (
@@ -234,13 +265,14 @@ export const layer = (opts?: Options): Layer.Layer<AgentService.AgentService, ne
234
265
  cached.provider === provider &&
235
266
  cached.instructions === instructions &&
236
267
  cached.location === location &&
268
+ cached.process === executable.key &&
237
269
  !Process.isTerminal(cached.handle.status.state)
238
270
  ) {
239
271
  return cached.session;
240
272
  }
241
273
 
242
274
  if (!Process.isTerminal(cached.handle.status.state)) {
243
- // Model, provider, steering instructions or location changed (e.g. the user
275
+ // Model, provider, steering instructions, location or process changed (e.g. the user
244
276
  // toggled online/offline, or moved the chat to the cloud): terminate the
245
277
  // existing process so the conversation continues on a fresh process bound to the new
246
278
  // configuration. Conversation history is preserved via the feed, which the new
@@ -255,7 +287,6 @@ export const layer = (opts?: Options): Layer.Layer<AgentService.AgentService, ne
255
287
  const parsedEchoUri = EID.tryParse(target);
256
288
  const spaceId = parsedEchoUri ? EID.getSpaceId(parsedEchoUri) : undefined;
257
289
  const processLocation = locationFor(options?.location, spaceId);
258
- const executable = makeExecutable(provider);
259
290
 
260
291
  // Reuse a still-running process for this feed only when there was no cached session
261
292
  // (e.g. after the UI remounted). A process adopted this way re-reads the chat when it
@@ -330,7 +361,15 @@ export const layer = (opts?: Options): Layer.Layer<AgentService.AgentService, ne
330
361
  Effect.provide(databaseContext),
331
362
  );
332
363
  const session = makeSession(handle, chat, feed, releaseSession, isFinished, resubmit);
333
- sessionCache.set(chat.id, { model, provider, instructions, location, handle, session });
364
+ sessionCache.set(chat.id, {
365
+ model,
366
+ provider,
367
+ instructions,
368
+ location,
369
+ process: executable.key,
370
+ handle,
371
+ session,
372
+ });
334
373
  return session;
335
374
  }),
336
375
  ),
@@ -390,5 +429,5 @@ const makeSession = (
390
429
  // turn open, so anything it did afterwards would land after the reader's next prompt. The next
391
430
  // process to spawn on this feed discards what it inherits instead (see `onSpawn` in agent-process).
392
431
  terminate: () => process.terminate().pipe(Effect.tap(() => Effect.sync(releaseSession))),
393
- subscribeEphemeral: () => process.subscribeEphemeral(),
432
+ subscribeEphemeral: (options) => process.subscribeEphemeral(options),
394
433
  });
@@ -14,8 +14,8 @@ the agent/plan ECHO types (it cannot depend on `@dxos/assistant-toolkit`).
14
14
 
15
15
  ```mermaid
16
16
  flowchart TB
17
- subgraph rt["@dxos/compute-runtime"]
18
- POI["ProcessOperationInvoker<br/>invokeFiber() / attachFiber()<br/><i>(linked child)</i>"]
17
+ subgraph rt["@dxos/compute"]
18
+ POI["Process.ManagerService<br/>Process.spawn() / attach()<br/><i>(linked child)</i>"]
19
19
  end
20
20
 
21
21
  subgraph fr["@dxos/functions-runtime · agent-service"]
@@ -40,9 +40,9 @@ flowchart TB
40
40
  IMPL --> CAP
41
41
  ```
42
42
 
43
- - **`ProcessOperationInvoker`** (runtime primitive, no AI): `invokeFiber` spawns a linked,
44
- non-blocking child and returns a fiber with `pid`; `attachFiber` + `fiber.await` reads the
45
- finished child's `Exit`.
43
+ - **`Process.ManagerService`** (runtime primitive, no AI): the process's own manager links every child
44
+ it spawns to the process. `Process.spawn` spawns an operation as such a child and returns its
45
+ `Process.Process`; `attach(pid)` + `Process.awaitOutput` reads the finished child's result.
46
46
  - **`DelegationStrategy`** (this dir, type-only): the pluggable policy `AgentProcess` calls —
47
47
  `reconcile` (what to delegate) and `onComplete` (how to fold a result back). Absent → plain chat.
48
48
  - **`makeDelegationStrategy()`** (assistant-toolkit): the concrete, agent/plan-aware implementation,
@@ -51,7 +51,7 @@ flowchart TB
51
51
  ## Delegation lifecycle
52
52
 
53
53
  The child's exit is only a **wake signal** — its output value is read separately via
54
- `attachFiber` + `fiber.await` (the `ChildEvent` does not carry the payload). `DelegationsKey` persists the
54
+ `attach(pid)` + `Process.awaitOutput` (the `ChildEvent` does not carry the payload). `DelegationsKey` persists the
55
55
  `pid → id` map so a child that exits after the supervisor hibernates can still be correlated.
56
56
 
57
57
  ```mermaid
@@ -59,7 +59,7 @@ sequenceDiagram
59
59
  participant U as User
60
60
  participant AP as AgentProcess (supervisor)
61
61
  participant ST as DelegationStrategy
62
- participant POI as ProcessOperationInvoker
62
+ participant POI as Process.ManagerService
63
63
  participant CH as Sub-agent (child process)
64
64
 
65
65
  U->>AP: submitInput(prompt)
@@ -67,7 +67,7 @@ sequenceDiagram
67
67
  AP->>ST: reconcile(chat, activeIds)
68
68
  ST-->>AP: Delegation[] { id, spawn }
69
69
  loop per delegation
70
- AP->>POI: invokeFiber(op, input)
70
+ AP->>POI: Process.spawn(op, input)
71
71
  POI->>CH: linked child (non-blocking)
72
72
  POI-->>AP: pid
73
73
  AP->>AP: DelegationsKey.set(pid → id)
@@ -76,7 +76,7 @@ sequenceDiagram
76
76
 
77
77
  CH-->>AP: exit (wake only — no payload)
78
78
  AP->>AP: onChildEvent: match pid → id, drop from DelegationsKey
79
- AP->>POI: attachFiber(pid) + await
79
+ AP->>POI: attach(pid) + Process.awaitOutput
80
80
  POI-->>AP: Exit of output value
81
81
  AP->>ST: onComplete(chat, id, exit)
82
82
  ST->>U: update Task status + post message (reference blocks → dx-anchor)
@@ -89,5 +89,5 @@ sequenceDiagram
89
89
  | `AgentService` / `layer` | `AgentService.ts` | Per-chat session cache (model-aware); wires `delegationStrategy` into `AgentProcess`. |
90
90
  | `AgentProcess` | `agent-process.ts` | Turn loop (`onAlarm`) + child-exit wake (`onChildEvent`); owns `DelegationsKey`. |
91
91
  | `DelegationStrategy`, `Delegation` | `delegation-strategy.ts` | Type-only seam: `reconcile` / `onComplete`; `Delegation = { id, spawn }`. |
92
- | `ProcessOperationInvoker.invokeFiber` / `attachFiber` | `@dxos/compute-runtime` | Linked-child spawn + result read. |
92
+ | `Process.spawn` / `Process.Manager.attach` | `@dxos/compute` | Linked-child spawn + result read. |
93
93
  | `makeDelegationStrategy()` | `@dxos/assistant-toolkit` | Concrete agent/plan-aware strategy. |