@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
@@ -20,6 +20,7 @@ import {
20
20
  AiContext,
21
21
  Alarm,
22
22
  HarnessControl,
23
+ type HarnessControlRpcs,
23
24
  type PendingState,
24
25
  SessionStore,
25
26
  SkillHooks,
@@ -28,10 +29,11 @@ import {
28
29
  makeToolResolverFromOperations,
29
30
  } from '@dxos/assistant';
30
31
  import * as Chat from '@dxos/assistant/Chat';
31
- import { ProcessManager } from '@dxos/compute-runtime';
32
+ import { OperationProcess } from '@dxos/compute-runtime';
32
33
  import * as Credential from '@dxos/compute/Credential';
33
34
  import * as McpServer from '@dxos/compute/McpServer';
34
35
  import * as Operation from '@dxos/compute/Operation';
36
+ import * as OperationHandlerSet from '@dxos/compute/OperationHandlerSet';
35
37
  import * as Process from '@dxos/compute/Process';
36
38
  import * as Skill from '@dxos/compute/Skill';
37
39
  import * as StorageService from '@dxos/compute/StorageService';
@@ -61,6 +63,14 @@ export interface AgentProcessOptions {
61
63
  /** Model used when the chat the process is bound to has not selected one. */
62
64
  defaultModel?: DXN.DXN;
63
65
 
66
+ /**
67
+ * Stay resident (IDLE) once the work is done rather than succeeding, so the next prompt lands on this
68
+ * process instead of a new one. For a host where a spawn is expensive — on EDGE it is a fresh Durable
69
+ * Object that opens the space and loads the toolkit before the turn can start.
70
+ * @default false
71
+ */
72
+ resident?: boolean;
73
+
64
74
  /**
65
75
  * The catalog's shared model ids are served by several providers, so resolution needs the provider
66
76
  * alongside the id; without it a local model id cannot be claimed by any resolver.
@@ -120,6 +130,13 @@ export const makeInputMessage = (input: AgentInput): Message.Message => {
120
130
  return Message.make({ sender: { role: 'user', ...sender }, blocks, properties });
121
131
  };
122
132
 
133
+ /**
134
+ * A process that can run a chat in place of {@link AgentProcess}: it takes the same input and serves
135
+ * the same `HarnessControl` RPCs, so a session drives it without knowing which one it is. Its
136
+ * requirements are its own; the host resolves them when it spawns the process.
137
+ */
138
+ export type AgentProcessDefinition = Operation.Durable<AgentInput, void, any, HarnessControlRpcs>;
139
+
123
140
  /**
124
141
  * How long to wait before re-reading a queue that contradicts a write this process just made, and
125
142
  * how many times. A hosted runtime serves the read from an eventually-consistent index, and the lag
@@ -159,44 +176,44 @@ const MAX_UNSEEN_WRITE_WAKES = 20;
159
176
  * The process target is a queue DXN string.
160
177
  */
161
178
  export const AgentProcess = (options: AgentProcessOptions) =>
162
- Operation.makeDurable(
163
- {
164
- key: AGENT_PROCESS_KEY,
165
- input: AgentInput,
166
- output: Schema.Void,
167
- // The conversation's own data model. `SessionStore` reads the queue with a TYPED query
168
- // (`Filter.type(Message)`/`Filter.type(Alarm)`), so without these registered every read comes
169
- // back empty on a host that did not happen to register them itself: the prompt appends fine and
170
- // the agent then finds nothing to do. `Chat`/`Feed` are resolved by DXN at startup.
171
- // `AiContext.Binding` and `Skill` belong here for the same reason the rest do: the host
172
- // registers exactly these with the process's database, and a typed query for a type it does
173
- // not know matches nothing. Without them a hosted agent reads its own skill bindings back
174
- // empty and runs every turn with an EMPTY TOOLKIT — the model can only answer in prose.
175
- // `McpServer` and `AccessToken` are read each turn to connect the space's MCP servers.
176
- types: [
177
- Chat.Chat,
178
- Feed.Feed,
179
- Message.Message,
180
- Alarm.Alarm,
181
- AiContext.Binding,
182
- Skill.Skill,
183
- McpServer.McpServer,
184
- AccessToken.AccessToken,
185
- ],
186
- services: [
187
- Database.Service,
188
- OpaqueToolkit.OpaqueToolkitProvider,
189
- Operation.Service,
190
- Registry.Service,
191
- StorageService.StorageService,
192
- ProcessManager.ProcessOperationInvoker.Service,
193
- AiService.AiService,
194
- // Needed in the fiber's context — `Header.byokLayer`'s per-request callback reads it.
195
- Credential.CredentialsService,
196
- ],
197
- rpcs: HarnessControl,
198
- },
199
- (ctx) =>
179
+ Operation.makeDurable({
180
+ key: AGENT_PROCESS_KEY,
181
+ input: AgentInput,
182
+ output: Schema.Void,
183
+ // The conversation's own data model. `SessionStore` reads the queue with a TYPED query
184
+ // (`Filter.type(Message)`/`Filter.type(Alarm)`), so without these registered every read comes
185
+ // back empty on a host that did not happen to register them itself: the prompt appends fine and
186
+ // the agent then finds nothing to do. `Chat`/`Feed` are resolved by DXN at startup.
187
+ // `AiContext.Binding` and `Skill` belong here for the same reason the rest do: the host
188
+ // registers exactly these with the process's database, and a typed query for a type it does
189
+ // not know matches nothing. Without them a hosted agent reads its own skill bindings back
190
+ // empty and runs every turn with an EMPTY TOOLKIT — the model can only answer in prose.
191
+ // `McpServer` and `AccessToken` are read each turn to connect the space's MCP servers.
192
+ types: [
193
+ Chat.Chat,
194
+ Feed.Feed,
195
+ Message.Message,
196
+ Alarm.Alarm,
197
+ AiContext.Binding,
198
+ Skill.Skill,
199
+ McpServer.McpServer,
200
+ AccessToken.AccessToken,
201
+ ],
202
+ services: [
203
+ Database.Service,
204
+ OpaqueToolkit.OpaqueToolkitProvider,
205
+ Operation.Service,
206
+ Registry.Service,
207
+ StorageService.StorageService,
208
+ Process.ManagerService,
209
+ OperationHandlerSet.OperationHandlerProvider,
210
+ AiService.AiService,
211
+ // Needed in the fiber's context — `Header.byokLayer`'s per-request callback reads it.
212
+ Credential.CredentialsService,
213
+ ],
214
+ rpcs: HarnessControl,
215
+ }).pipe(
216
+ Operation.withDurableHandler((ctx) =>
200
217
  Effect.gen(function* () {
201
218
  const chatDxn = Annotation.getDictionary(ctx.params.annotations, Process.TargetAnnotation).pipe(
202
219
  Option.getOrUndefined,
@@ -282,6 +299,12 @@ export const AgentProcess = (options: AgentProcessOptions) =>
282
299
  // conversational agent.
283
300
  const strategy = Option.fromNullishOr(options.delegationStrategy);
284
301
  let delegations: Delegation[] = [...(yield* DelegationsCell.get)];
302
+ // Background end-request hooks still running as children; persisted so a rehydrated process
303
+ // still waits for them, and so it knows the request they belong to has already ended.
304
+ let asyncHooks: Process.ID[] = [...(yield* AsyncHooksCell.get)];
305
+ // Exits of children this process did not recognise yet: child events run concurrently with
306
+ // the handler, so a background hook can exit before its pid is registered below.
307
+ const untrackedExits = new Set<Process.ID>();
285
308
 
286
309
  // The chat's own selection wins: the process is bound to the chat, so the model it runs on is
287
310
  // recovered from the chat on rehydration like the instructions are.
@@ -290,7 +313,9 @@ export const AgentProcess = (options: AgentProcessOptions) =>
290
313
  provider: options.provider,
291
314
  });
292
315
 
293
- const operationInvoker = yield* ProcessManager.ProcessOperationInvoker.Service;
316
+ const childServices = yield* Effect.context<
317
+ Process.ManagerService | OperationHandlerSet.OperationHandlerProvider
318
+ >();
294
319
 
295
320
  // Fire end-request hooks declared by the bound skills (e.g. the planning plan-reminder).
296
321
  // Each hook runs as a child operation with `conversation` set, so it resolves the full
@@ -300,20 +325,26 @@ export const AgentProcess = (options: AgentProcessOptions) =>
300
325
  yield* SkillHooks.runHooks({
301
326
  skills: session.getSkills(),
302
327
  phase: 'end-request',
303
- invoke: (operation, input) =>
328
+ invoke: (operation, input, hook) =>
304
329
  Effect.gen(function* () {
305
- const fiber = yield* operationInvoker.invokeFiber(operation, input, {
330
+ const handle = yield* Process.spawn(OperationProcess.make(operation), input, {
306
331
  environment: { conversation: Obj.getURI(feed) },
307
332
  traceMeta: { conversation: Ref.make(feed) },
308
333
  });
309
- // `fiber.await` yields an Exit; surface a child failure into the Effect channel so
310
- // the outer `Effect.orDie` (and the hook runner's `catchAllCause`) handle it instead
311
- // of the failure being silently discarded.
312
- const exit = yield* fiber.await;
313
- if (Exit.isFailure(exit)) {
314
- return yield* Effect.failCause(exit.cause);
334
+ if (hook.async) {
335
+ if (untrackedExits.delete(handle.pid)) {
336
+ return;
337
+ }
338
+ // Not awaited, so the request settles now; the child is linked, so its exit wakes
339
+ // `onChildEvent`, which completes the process once every background hook is done.
340
+ asyncHooks.push(handle.pid);
341
+ yield* AsyncHooksCell.set(asyncHooks);
342
+ return;
315
343
  }
316
- }).pipe(Effect.asVoid, Effect.orDie),
344
+ // A child failure surfaces in the Effect channel, so the outer `Effect.orDie` (and the
345
+ // hook runner's `catchAllCause`) handle it instead of it being silently discarded.
346
+ yield* Process.awaitOutput(handle);
347
+ }).pipe(Effect.provide(childServices), Effect.asVoid, Effect.orDie),
317
348
  });
318
349
  });
319
350
 
@@ -325,6 +356,11 @@ export const AgentProcess = (options: AgentProcessOptions) =>
325
356
  // handle, leaving the reader with no reply and no error.
326
357
  let turnRan = false;
327
358
 
359
+ // Whether this request's end-request hooks have fired; reset by every turn, so a request
360
+ // continued by a hook gets its own hooks when it ends. A rehydrated process with background
361
+ // hooks still running has already fired them.
362
+ let endHooksFired = asyncHooks.length > 0;
363
+
328
364
  // Queue entries this incarnation wrote but has not yet read back. A hosted process's queue
329
365
  // read is served by the space INDEX, which is eventually consistent: the agent appends a
330
366
  // prompt and, milliseconds later, its own read returns empty — the index catches up a beat
@@ -368,7 +404,7 @@ export const AgentProcess = (options: AgentProcessOptions) =>
368
404
  return;
369
405
  }
370
406
 
371
- if (!turnRan) {
407
+ if (!turnRan && !endHooksFired) {
372
408
  // Idle, not done: stay resident so the prompt this process was spawned for can still
373
409
  // land. Ahead of the hooks below, which are end-of-REQUEST hooks — there has been no
374
410
  // request to end. Nothing is scheduled; the next `onInput` arms the alarm.
@@ -376,14 +412,29 @@ export const AgentProcess = (options: AgentProcessOptions) =>
376
412
  return;
377
413
  }
378
414
 
379
- // The hook may enqueue work (e.g. a plan continuation reminder) via HarnessService Tier B,
380
- // which appends to the feed queue; re-check before succeeding so the turn is not dropped.
381
- yield* runEndRequestHooks;
382
- const after = yield* sessionStore.loadPending(feed);
383
- if (pendingWork(after)) {
384
- log('agent work enqueued by end-request hook, continuing');
385
- yield* reconcileAlarmWith(after);
386
- yield* reportSleeping(after);
415
+ if (!endHooksFired) {
416
+ endHooksFired = true;
417
+ // The hook may enqueue work (e.g. a plan continuation reminder) via HarnessService Tier B,
418
+ // which appends to the feed queue; re-check before succeeding so the turn is not dropped.
419
+ yield* runEndRequestHooks;
420
+ const after = yield* sessionStore.loadPending(feed);
421
+ if (pendingWork(after)) {
422
+ log('agent work enqueued by end-request hook, continuing');
423
+ yield* reconcileAlarmWith(after);
424
+ yield* reportSleeping(after);
425
+ return;
426
+ }
427
+ }
428
+
429
+ if (asyncHooks.length > 0) {
430
+ // Resident rather than done: the linked children keep the process HYBERNATING, which
431
+ // `runUntilSettled` treats as settled, so the reader is not held up by them.
432
+ log('awaiting background end-request hooks', { count: asyncHooks.length });
433
+ return;
434
+ }
435
+
436
+ if (options.resident) {
437
+ log('agent work complete, staying resident');
387
438
  return;
388
439
  }
389
440
 
@@ -443,9 +494,13 @@ export const AgentProcess = (options: AgentProcessOptions) =>
443
494
  markWork('agent.wake');
444
495
 
445
496
  // Earliest point the agent can report to a reader who is already waiting: draining the
446
- // queue below reads the feed, which is itself part of the wait. An empty wake emits it
447
- // too, but that path returns in milliseconds and the turn settling clears the line.
448
- yield* Trace.emitRequestPhase('preparing');
497
+ // queue below reads the feed, which is itself part of the wait. Only for a wake with known
498
+ // work — the re-check after every turn finds none, and on a hosted agent that read takes
499
+ // long enough for the line to read as another request starting after the reply.
500
+ const announced = toolResults.length > 0 || unseenWriteIds.size > 0;
501
+ if (announced) {
502
+ yield* Trace.emitRequestPhase('preparing');
503
+ }
449
504
 
450
505
  for (const pid of dropReportedToolResults(toolResults, (pid) => toolCallManager.isReported(pid))) {
451
506
  log.info('skip tool result that was reported synchronously', { pid });
@@ -555,6 +610,10 @@ export const AgentProcess = (options: AgentProcessOptions) =>
555
610
  }
556
611
  }
557
612
 
613
+ if (!announced) {
614
+ yield* Trace.emitRequestPhase('preparing');
615
+ }
616
+
558
617
  // The MCP servers are read concurrently with the writes below: neither depends on the
559
618
  // other, and each is a round trip to the database that the turn would otherwise wait on in series.
560
619
  const [mcpServers] = yield* Effect.all(
@@ -604,6 +663,7 @@ export const AgentProcess = (options: AgentProcessOptions) =>
604
663
  );
605
664
  log('end request');
606
665
  turnRan = true;
666
+ endHooksFired = false;
607
667
  yield* ToolResultsCell.set(toolResults);
608
668
 
609
669
  // Ack only now: the turn is what the queue entry was for, so a process that dies before
@@ -651,6 +711,18 @@ export const AgentProcess = (options: AgentProcessOptions) =>
651
711
  ),
652
712
  onChildEvent: Effect.fnUntraced(function* (event) {
653
713
  log('childEvent', { event });
714
+ if (event._tag === 'exited' && asyncHooks.includes(event.pid)) {
715
+ asyncHooks = asyncHooks.filter((pid) => pid !== event.pid);
716
+ yield* AsyncHooksCell.set(asyncHooks);
717
+ const manager = yield* Process.ManagerService;
718
+ const exit = yield* manager.attach(event.pid).pipe(Effect.flatMap(Process.awaitOutput), Effect.exit);
719
+ // A failed background hook is reported and dropped, like a failed inline hook.
720
+ if (Exit.isFailure(exit)) {
721
+ log.warn('background end-request hook failed', { pid: event.pid });
722
+ }
723
+ yield* maybeComplete;
724
+ return;
725
+ }
654
726
  if (event._tag === 'exited') {
655
727
  // A delegated sub-agent finished: read its result and hand it to the strategy (which
656
728
  // updates the work item and notifies the user). Unlike tool results, this does not
@@ -659,9 +731,9 @@ export const AgentProcess = (options: AgentProcessOptions) =>
659
731
  if (delegation) {
660
732
  delegations = delegations.filter((other) => other.pid !== event.pid);
661
733
  yield* DelegationsCell.set(delegations);
662
- const operationInvoker = yield* ProcessManager.ProcessOperationInvoker.Service;
663
- const fiber = yield* operationInvoker.attachFiber(event.pid).pipe(Effect.orDie);
664
- const exit = yield* fiber.await;
734
+ const manager = yield* Process.ManagerService;
735
+ const child = yield* manager.attach(event.pid);
736
+ const exit = yield* Process.awaitOutput(child).pipe(Effect.exit);
665
737
  // Written beside `DelegationSpawned`, and for the same reason: the return is only
666
738
  // observable here. The child's own trace ends with its operation and says nothing
667
739
  // about reporting back, so this is what pairs an exit with the task it answers.
@@ -692,23 +764,24 @@ export const AgentProcess = (options: AgentProcessOptions) =>
692
764
  log('delegated work completed', { pid: event.pid, id: delegation.id, success: Exit.isSuccess(exit) });
693
765
  yield* maybeComplete;
694
766
  } else if (toolCallManager.isToolCall(event.pid)) {
695
- const operationInvoker = yield* ProcessManager.ProcessOperationInvoker.Service;
696
- const attachExit = yield* operationInvoker.attachFiber(event.pid).pipe(Effect.exit);
697
- if (Exit.isFailure(attachExit)) {
698
- // Completed tool children are not rehydrated on reload; the result is in the tool
699
- // result queue or was delivered synchronously before the interrupted turn.
700
- if (
701
- toolCallManager.isToolCall(event.pid) ||
702
- toolResults.some((item) => item.pid === event.pid) ||
703
- toolCallManager.isReported(event.pid)
704
- ) {
705
- log.verbose('childEvent skipped (process gone, result already handled)', { pid: event.pid });
706
- return;
707
- }
708
- return yield* Effect.failCause(attachExit.cause).pipe(Effect.orDie);
767
+ const manager = yield* Process.ManagerService;
768
+ const attachExit = yield* manager.attach(event.pid).pipe(Effect.exit);
769
+ if (
770
+ Exit.isFailure(attachExit) &&
771
+ isToolResultHandled(toolResults, event.pid, (pid) => toolCallManager.isReported(pid))
772
+ ) {
773
+ // Completed tool children are not rehydrated on reload; this result is already queued or
774
+ // was delivered before the interrupted turn.
775
+ log.verbose('childEvent skipped (process gone, result already handled)', { pid: event.pid });
776
+ return;
709
777
  }
710
- const fiber = attachExit.value;
711
- const result = yield* fiber.await.pipe(Effect.orDie).pipe(
778
+ const result = yield* (
779
+ Exit.isFailure(attachExit)
780
+ ? // Unreported and unreachable: answered as an error so the call does not stay pending forever.
781
+ Effect.failCause(attachExit.cause)
782
+ : Process.awaitOutput(attachExit.value)
783
+ ).pipe(
784
+ Effect.exit,
712
785
  Effect.map(
713
786
  Exit.match({
714
787
  onSuccess: (value): ToolResultEvent => ({
@@ -733,11 +806,13 @@ export const AgentProcess = (options: AgentProcessOptions) =>
733
806
  log('agent onChildEvent alarm scheduled', { depth: toolResults.length });
734
807
  } else {
735
808
  log.verbose('childEvent ignored non-tool call and not a delegation', { pid: event.pid });
809
+ untrackedExits.add(event.pid);
736
810
  }
737
811
  }
738
812
  }),
739
813
  };
740
814
  }),
815
+ ),
741
816
  );
742
817
 
743
818
  interface ToolExecutionServiceOptions {
@@ -784,6 +859,12 @@ const DelegationsCell = StorageService.cell(
784
859
  'delegations',
785
860
  ).pipe(StorageService.withDefault(() => []));
786
861
 
862
+ /** Pids of background ({@link Skill.Hook.async}) end-request hooks the process is waiting on. */
863
+ const AsyncHooksCell = StorageService.cell(
864
+ Schema.fromJsonString(Schema.Array(Process.ID).pipe(Schema.mutable)),
865
+ 'asyncHooks',
866
+ ).pipe(StorageService.withDefault(() => []));
867
+
787
868
  /**
788
869
  * Ids of queue entries this process has ACKED, held until the ack is visible in the feed read.
789
870
  *
@@ -922,6 +1003,16 @@ export const isAgentWorkPending = ({
922
1003
  delegations.length > 0 ||
923
1004
  toolCallManager.hasPendingToolResults();
924
1005
 
1006
+ /**
1007
+ * Whether a tool call's result is already accounted for — queued for the next turn or delivered to the
1008
+ * agent — so a finished child that can no longer be reattached needs no answer of its own.
1009
+ */
1010
+ export const isToolResultHandled = (
1011
+ queue: readonly ToolResultEvent[],
1012
+ pid: Process.ID,
1013
+ isReported: (pid: Process.ID) => boolean,
1014
+ ): boolean => queue.some((item) => item.pid === pid) || isReported(pid);
1015
+
925
1016
  /**
926
1017
  * Discards tool results at the head of the queue whose values already reached the agent.
927
1018
  *
@@ -1016,13 +1107,15 @@ const ToolExecutionService = ({
1016
1107
  }: ToolExecutionServiceOptions) =>
1017
1108
  Layer.unwrap(
1018
1109
  Effect.gen(function* () {
1019
- const operationInvoker = yield* ProcessManager.ProcessOperationInvoker.Service;
1110
+ const childServices = yield* Effect.context<
1111
+ Process.ManagerService | OperationHandlerSet.OperationHandlerProvider
1112
+ >();
1020
1113
  return makeToolExecutionService({
1021
1114
  invoke: (tool, input) =>
1022
1115
  Effect.gen(function* () {
1023
1116
  const operationDef = getOperationFromTool(tool).pipe(Option.getOrThrow);
1024
1117
  log('invoking operation', { operationDef, input });
1025
- const fiber = yield* operationInvoker.invokeFiber(operationDef, input, {
1118
+ const handle = yield* Process.spawn(OperationProcess.make(operationDef), input, {
1026
1119
  environment: {
1027
1120
  conversation: Obj.getURI(feed),
1028
1121
  },
@@ -1031,23 +1124,26 @@ const ToolExecutionService = ({
1031
1124
  },
1032
1125
  });
1033
1126
  markWork('tool.spawned');
1034
- yield* toolCallManager.beginCall(fiber.pid);
1127
+ yield* toolCallManager.beginCall(handle.pid);
1035
1128
  markWork('tool.call-recorded');
1036
- log('invoked operation', { operationDef, input, fiber });
1129
+ log('invoked operation', { operationDef, input, pid: handle.pid });
1037
1130
 
1038
- const awaitWithReport = fiber.await.pipe(Effect.tap(() => toolCallManager.markAsReported(fiber.pid)));
1131
+ const awaitWithReport = Process.awaitOutput(handle).pipe(
1132
+ Effect.exit,
1133
+ Effect.tap(() => toolCallManager.markAsReported(handle.pid)),
1134
+ );
1039
1135
  const result = enableBackgrounding
1040
1136
  ? yield* awaitWithReport.pipe(
1041
1137
  Effect.timeout(backgroundThreshold),
1042
1138
  Effect.catchTag('TimeoutError', () =>
1043
- Effect.succeed(Exit.succeed(toolIsRunningInBackgroundResponse(fiber.pid))),
1139
+ Effect.succeed(Exit.succeed(toolIsRunningInBackgroundResponse(handle.pid))),
1044
1140
  ),
1045
1141
  )
1046
1142
  : yield* awaitWithReport;
1047
1143
  markWork('tool.settled');
1048
1144
  log('result', { result });
1049
1145
  return yield* result;
1050
- }),
1146
+ }).pipe(Effect.provide(childServices)),
1051
1147
  });
1052
1148
  }),
1053
1149
  );
@@ -1081,23 +1177,28 @@ class AsynchronousExectionToolkit extends Toolkit.make(
1081
1177
  // TODO(dmaretskyi): Currently broken: polling a completed process returns interruped error.
1082
1178
  const AsynchronousExectionToolkitLayer = AsynchronousExectionToolkit.toLayer(
1083
1179
  Effect.gen(function* () {
1084
- const invoker = yield* ProcessManager.ProcessOperationInvoker.Service;
1180
+ const manager = yield* Process.ManagerService;
1085
1181
  return {
1086
1182
  'poll-tools': ({ ids, wait, timeout = 10_000 }) =>
1087
1183
  Effect.gen(function* () {
1088
1184
  return yield* Effect.forEach(ids, (pid) =>
1089
- invoker.attachFiber<unknown>(Process.ID.make(pid)).pipe(
1090
- Effect.flatMap((_) => _.await),
1091
- Effect.timeout(Duration.millis(timeout)),
1092
- Effect.flatMap(
1093
- Exit.match({
1094
- onSuccess: (value) => Effect.succeed(toolResultResponse(pid, value)),
1095
- onFailure: (cause) => Effect.succeed(toolErrorResponse(pid, Cause.pretty(cause))),
1096
- }),
1097
- ),
1098
- Effect.catchTag('ProcessNotFoundError', () => Effect.succeed(`Process not found: ${pid}`)),
1099
- Effect.catchTag('TimeoutError', () => Effect.succeed(`Process still running: ${pid}`)),
1100
- ),
1185
+ Effect.gen(function* () {
1186
+ const attached = yield* manager.attach<unknown, unknown>(Process.ID.make(pid)).pipe(Effect.exit);
1187
+ if (Exit.isFailure(attached)) {
1188
+ return `Process not found: ${pid}`;
1189
+ }
1190
+ return yield* Process.awaitOutput(attached.value).pipe(
1191
+ Effect.exit,
1192
+ Effect.timeout(Duration.millis(timeout)),
1193
+ Effect.map(
1194
+ Exit.match({
1195
+ onSuccess: (value) => toolResultResponse(pid, value),
1196
+ onFailure: (cause) => toolErrorResponse(pid, Cause.pretty(cause)),
1197
+ }),
1198
+ ),
1199
+ Effect.catchTag('TimeoutError', () => Effect.succeed(`Process still running: ${pid}`)),
1200
+ );
1201
+ }),
1101
1202
  );
1102
1203
  }),
1103
1204
  };
@@ -9,9 +9,10 @@ import * as Schema from 'effect/Schema';
9
9
  import { expect } from 'vitest';
10
10
 
11
11
  import { ScriptedLanguageModel } from '@dxos/ai/testing';
12
- import { ProcessManager } from '@dxos/compute-runtime';
12
+ import { OperationProcess } from '@dxos/compute-runtime';
13
13
  import * as Operation from '@dxos/compute/Operation';
14
14
  import * as OperationHandlerSet from '@dxos/compute/OperationHandlerSet';
15
+ import * as Process from '@dxos/compute/Process';
15
16
  import { TestHelpers } from '@dxos/effect/testing';
16
17
  import { DXN, EntityId } from '@dxos/keys';
17
18
 
@@ -70,9 +71,8 @@ const StubDelegationStrategy: DelegationStrategy = {
70
71
  .map((work) => ({
71
72
  id: work.id,
72
73
  spawn: Effect.gen(function* () {
73
- const invoker = yield* ProcessManager.ProcessOperationInvoker.Service;
74
- const fiber = yield* invoker.invokeFiber(DelegatedWork, work.input);
75
- return fiber.pid;
74
+ const handle = yield* Process.spawn(OperationProcess.make(DelegatedWork), work.input);
75
+ return handle.pid;
76
76
  }),
77
77
  })),
78
78
  ),
@@ -7,13 +7,13 @@ import type * as Effect from 'effect/Effect';
7
7
  import type * as Exit from 'effect/Exit';
8
8
 
9
9
  import type * as Chat from '@dxos/assistant/Chat';
10
- import { type ProcessManager } from '@dxos/compute-runtime';
10
+ import type * as OperationHandlerSet from '@dxos/compute/OperationHandlerSet';
11
11
  import type * as Process from '@dxos/compute/Process';
12
12
  import { type Database } from '@dxos/echo';
13
13
 
14
14
  /**
15
15
  * A unit of work the supervisor delegates to a linked child process. `spawn` is an existential over
16
- * the child operation's input/output types: the strategy constructs it (e.g. via `invokeFiber`)
16
+ * the child operation's input/output types: the strategy constructs it (e.g. via `Process.spawn`)
17
17
  * so {@link AgentProcess} can remain agnostic to the operation type and just track the returned pid.
18
18
  */
19
19
  export interface Delegation {
@@ -24,9 +24,14 @@ export interface Delegation {
24
24
 
25
25
  /**
26
26
  * Spawns the child for this delegation and returns its process id. Must be linked (non-blocking)
27
- * so the child's exit wakes the supervisor's `onChildEvent` — i.e. `ProcessOperationInvoker.invokeFiber`.
27
+ * so the child's exit wakes the supervisor's `onChildEvent` — i.e. spawned through the supervisor's own
28
+ * {@link Process.ManagerService}, which links its children by default.
28
29
  */
29
- readonly spawn: Effect.Effect<Process.ID, never, ProcessManager.ProcessOperationInvoker.Service>;
30
+ readonly spawn: Effect.Effect<
31
+ Process.ID,
32
+ never,
33
+ Process.ManagerService | OperationHandlerSet.OperationHandlerProvider
34
+ >;
30
35
  }
31
36
 
32
37
  /**
@@ -9,6 +9,7 @@ export {
9
9
  AGENT_PROCESS_KEY,
10
10
  AgentInput,
11
11
  AgentProcess,
12
+ type AgentProcessDefinition,
12
13
  type AgentProcessOptions,
13
14
  makeInputMessage,
14
15
  } from './agent-process.ts';