@dxos/agent-runtime 0.0.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 (66) hide show
  1. package/LICENSE +105 -0
  2. package/dist/lib/neutral/chunk-EZKG2ZGG.mjs +754 -0
  3. package/dist/lib/neutral/chunk-EZKG2ZGG.mjs.map +7 -0
  4. package/dist/lib/neutral/index.mjs +9 -0
  5. package/dist/lib/neutral/index.mjs.map +7 -0
  6. package/dist/lib/neutral/meta.json +1 -0
  7. package/dist/lib/neutral/testing/index.mjs +285 -0
  8. package/dist/lib/neutral/testing/index.mjs.map +7 -0
  9. package/dist/types/src/agent-service/AgentService.d.ts +45 -0
  10. package/dist/types/src/agent-service/AgentService.d.ts.map +1 -0
  11. package/dist/types/src/agent-service/AgentService.test.d.ts +2 -0
  12. package/dist/types/src/agent-service/AgentService.test.d.ts.map +1 -0
  13. package/dist/types/src/agent-service/agent-process.d.ts +457 -0
  14. package/dist/types/src/agent-service/agent-process.d.ts.map +1 -0
  15. package/dist/types/src/agent-service/agent-process.test.d.ts +2 -0
  16. package/dist/types/src/agent-service/agent-process.test.d.ts.map +1 -0
  17. package/dist/types/src/agent-service/delegation-strategy.d.ts +47 -0
  18. package/dist/types/src/agent-service/delegation-strategy.d.ts.map +1 -0
  19. package/dist/types/src/agent-service/index.d.ts +4 -0
  20. package/dist/types/src/agent-service/index.d.ts.map +1 -0
  21. package/dist/types/src/assistant-session-tests/format.test.d.ts +2 -0
  22. package/dist/types/src/assistant-session-tests/format.test.d.ts.map +1 -0
  23. package/dist/types/src/assistant-session-tests/request.test.d.ts +2 -0
  24. package/dist/types/src/assistant-session-tests/request.test.d.ts.map +1 -0
  25. package/dist/types/src/assistant-session-tests/session-ollama.test.d.ts +2 -0
  26. package/dist/types/src/assistant-session-tests/session-ollama.test.d.ts.map +1 -0
  27. package/dist/types/src/assistant-session-tests/session.test.d.ts +2 -0
  28. package/dist/types/src/assistant-session-tests/session.test.d.ts.map +1 -0
  29. package/dist/types/src/assistant-session-tests/xml-response.test.d.ts +2 -0
  30. package/dist/types/src/assistant-session-tests/xml-response.test.d.ts.map +1 -0
  31. package/dist/types/src/functions.test.d.ts +2 -0
  32. package/dist/types/src/functions.test.d.ts.map +1 -0
  33. package/dist/types/src/index.d.ts +2 -0
  34. package/dist/types/src/index.d.ts.map +1 -0
  35. package/dist/types/src/testing/ResearchService.d.ts +28 -0
  36. package/dist/types/src/testing/ResearchService.d.ts.map +1 -0
  37. package/dist/types/src/testing/assistant-test-layer.d.ts +70 -0
  38. package/dist/types/src/testing/assistant-test-layer.d.ts.map +1 -0
  39. package/dist/types/src/testing/index.d.ts +4 -0
  40. package/dist/types/src/testing/index.d.ts.map +1 -0
  41. package/dist/types/src/testing/trace-pretty-print.d.ts +16 -0
  42. package/dist/types/src/testing/trace-pretty-print.d.ts.map +1 -0
  43. package/dist/types/tsconfig.tsbuildinfo +1 -0
  44. package/package.json +70 -0
  45. package/src/agent-service/AgentService.conversations.json +1 -0
  46. package/src/agent-service/AgentService.test.ts +523 -0
  47. package/src/agent-service/AgentService.ts +239 -0
  48. package/src/agent-service/README.md +91 -0
  49. package/src/agent-service/agent-process.test.ts +206 -0
  50. package/src/agent-service/agent-process.ts +793 -0
  51. package/src/agent-service/delegation-strategy.ts +63 -0
  52. package/src/agent-service/index.ts +7 -0
  53. package/src/assistant-session-tests/format.test.ts +131 -0
  54. package/src/assistant-session-tests/request.conversations.json +1 -0
  55. package/src/assistant-session-tests/request.test.ts +190 -0
  56. package/src/assistant-session-tests/session-ollama.test.ts +80 -0
  57. package/src/assistant-session-tests/session.test.ts +56 -0
  58. package/src/assistant-session-tests/xml-response.conversations.json +1 -0
  59. package/src/assistant-session-tests/xml-response.test.ts +65 -0
  60. package/src/functions.conversations.json +1 -0
  61. package/src/functions.test.ts +87 -0
  62. package/src/index.ts +7 -0
  63. package/src/testing/ResearchService.ts +144 -0
  64. package/src/testing/assistant-test-layer.ts +299 -0
  65. package/src/testing/index.ts +7 -0
  66. package/src/testing/trace-pretty-print.ts +55 -0
@@ -0,0 +1,793 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Tool from '@effect/ai/Tool';
6
+ import * as Toolkit from '@effect/ai/Toolkit';
7
+ import * as Cause from 'effect/Cause';
8
+ import * as DateTime from 'effect/DateTime';
9
+ import * as Duration from 'effect/Duration';
10
+ import * as Effect from 'effect/Effect';
11
+ import * as Exit from 'effect/Exit';
12
+ import * as Layer from 'effect/Layer';
13
+ import * as Match from 'effect/Match';
14
+ import * as Option from 'effect/Option';
15
+ import * as Schema from 'effect/Schema';
16
+
17
+ import { AiService, OpaqueToolkit } from '@dxos/ai';
18
+ import {
19
+ AgentRequestBegin,
20
+ AgentRequestEnd,
21
+ AiSession,
22
+ HarnessControl,
23
+ SkillHooks,
24
+ getOperationFromTool,
25
+ makeToolExecutionService,
26
+ makeToolResolverFromOperations,
27
+ } from '@dxos/assistant';
28
+ import { Credential, McpServer, Operation, Trace } from '@dxos/compute';
29
+ import { Process } from '@dxos/compute';
30
+ import { ProcessManager } from '@dxos/compute-runtime';
31
+ import * as StorageService from '@dxos/compute/StorageService';
32
+ import { Annotation, Database, Feed, Obj, Ref, Registry } from '@dxos/echo';
33
+ import { EffectEx } from '@dxos/effect';
34
+ import { DXN } from '@dxos/keys';
35
+ import { log } from '@dxos/log';
36
+ import { ContentBlock } from '@dxos/types';
37
+ import { trim } from '@dxos/util';
38
+
39
+ import { type DelegationStrategy } from './delegation-strategy';
40
+
41
+ interface AgentProcessOptions {
42
+ systemPrompt?: string;
43
+ model?: DXN.DXN;
44
+ // The catalog's shared model ids are served by several providers, so resolution needs the provider
45
+ // alongside the id; without it a local model id cannot be claimed by any resolver.
46
+ provider?: DXN.DXN;
47
+
48
+ /**
49
+ * Provider for space-level MCP server configs, called on each turn.
50
+ */
51
+ getMcpServers?: () => McpServer.McpServer[];
52
+
53
+ /**
54
+ * If true, long-running tool calls are moved to the background after `backgroundThreshold`
55
+ * and the agent is notified asynchronously when they complete.
56
+ *
57
+ * Currently unstable — disabled by default.
58
+ *
59
+ * @default false
60
+ */
61
+ enableToolBackgrounding?: boolean;
62
+
63
+ /**
64
+ * When provided, the agent acts as a supervisor: after each turn it delegates outstanding work to
65
+ * linked child processes and folds their results back into the conversation on completion. Absent
66
+ * (the default) the process behaves as a plain conversational agent.
67
+ */
68
+ delegationStrategy?: DelegationStrategy;
69
+ }
70
+
71
+ export const AGENT_PROCESS_KEY = 'org.dxos.testing.process.agent';
72
+
73
+ /**
74
+ * Hosts a persistent, suspendible AiAgent that can process a number of prompts.
75
+ * The process target is a queue DXN string.
76
+ */
77
+ export const AgentProcess = (options: AgentProcessOptions) =>
78
+ Process.make(
79
+ {
80
+ key: AGENT_PROCESS_KEY,
81
+ input: Schema.String,
82
+ output: Schema.Void,
83
+ services: [
84
+ Database.Service,
85
+ OpaqueToolkit.OpaqueToolkitProvider,
86
+ Operation.Service,
87
+ Registry.Service,
88
+ StorageService.StorageService,
89
+ ProcessManager.ProcessOperationInvoker.Service,
90
+ AiService.AiService,
91
+ // Needed in the fiber's context — `Header.byokLayer`'s per-request callback reads it.
92
+ Credential.CredentialsService,
93
+ ],
94
+ rpcs: HarnessControl,
95
+ },
96
+ (ctx) =>
97
+ Effect.gen(function* () {
98
+ const feedDxn = Annotation.getDictionary(ctx.params.annotations, Process.TargetAnnotation).pipe(
99
+ Option.getOrUndefined,
100
+ );
101
+ if (feedDxn == null) {
102
+ return yield* Effect.die(new Error('Agent executable requires spawn options.target set to a queue DXN.'));
103
+ }
104
+ const feed = yield* Database.resolve(feedDxn, Feed.Feed).pipe(Effect.orDie);
105
+ const runtime = yield* Effect.runtime<Database.Service>();
106
+ const session = yield* EffectEx.acquireReleaseResource(() => new AiSession.Session({ feed, runtime }));
107
+ let inputQueue: AgentEvent[] = [...(yield* AgentEventsCell.get)];
108
+ const storageService = yield* StorageService.StorageService;
109
+ const toolCallManager = new ToolCallManager(storageService);
110
+ yield* toolCallManager.load();
111
+
112
+ // Read time from the ambient Effect Clock so alarm scheduling and the due-check stay
113
+ // consistent (and both honor a TestClock under tests).
114
+ const clock = yield* Effect.clock;
115
+ const alarmManager = new AlarmManager({
116
+ storageService,
117
+ setAlarm: (timeout) => ctx.setAlarm(timeout),
118
+ now: () => clock.unsafeCurrentTimeMillis(),
119
+ });
120
+ yield* alarmManager.load();
121
+ // Queued tool results were never consumed by onAlarm — reported flags from the synchronous
122
+ // execution path are stale after reload and would cause onAlarm to drop them.
123
+ yield* toolCallManager.reconcileWithInputQueue(inputQueue);
124
+
125
+ // Optional supervisor behaviour: when a strategy is provided, the agent reconciles
126
+ // outstanding work into linked child processes after each turn and folds their results back
127
+ // into the conversation on completion. Absent (the default), the process behaves as a plain
128
+ // conversational agent.
129
+ const strategy = Option.fromNullable(options.delegationStrategy);
130
+ let delegations: Delegation[] = [...(yield* DelegationsCell.get)];
131
+
132
+ const requestModelLayer = AiService.model(
133
+ options.model ? DXN.getName(options.model) : 'com.anthropic.model.claude-opus-4-8.default',
134
+ {
135
+ provider: options.provider,
136
+ },
137
+ );
138
+
139
+ const operationInvoker = yield* ProcessManager.ProcessOperationInvoker.Service;
140
+
141
+ // Fire end-request hooks declared by the bound skills (e.g. the planning plan-reminder).
142
+ // Each hook runs as a child operation with `conversation` set, so it resolves the full
143
+ // HarnessService (Tier B) and can enqueue a continuation back onto this host's queue — which
144
+ // keeps the process alive past this turn.
145
+ const runEndRequestHooks = Effect.gen(function* () {
146
+ yield* SkillHooks.runHooks({
147
+ skills: session.context.getSkills(),
148
+ phase: 'end-request',
149
+ invoke: (operation, input) =>
150
+ Effect.gen(function* () {
151
+ const fiber = yield* operationInvoker.invokeFiber(operation, input, {
152
+ environment: { conversation: Obj.getURI(feed) },
153
+ traceMeta: { conversation: Ref.make(feed) },
154
+ });
155
+ // `fiber.await` yields an Exit; surface a child failure into the Effect channel so
156
+ // the outer `Effect.orDie` (and the hook runner's `catchAllCause`) handle it instead
157
+ // of the failure being silently discarded.
158
+ const exit = yield* fiber.await;
159
+ if (Exit.isFailure(exit)) {
160
+ yield* Effect.failCause(exit.cause);
161
+ }
162
+ }).pipe(Effect.asVoid, Effect.orDie),
163
+ });
164
+ });
165
+
166
+ const maybeComplete = Effect.gen(function* () {
167
+ if (isAgentWorkPending({ inputQueue, alarmManager, delegations, toolCallManager })) {
168
+ return;
169
+ }
170
+
171
+ // The hook may enqueue work (e.g. a plan continuation reminder) via HarnessService Tier B,
172
+ // which pushes onto `inputQueue`; re-check before succeeding so the turn is not dropped.
173
+ yield* runEndRequestHooks;
174
+ if (isAgentWorkPending({ inputQueue, alarmManager, delegations, toolCallManager })) {
175
+ log('agent work enqueued by end-request hook, continuing');
176
+ alarmManager.reconcile(true);
177
+ return;
178
+ }
179
+
180
+ log('agent work complete, succeeding');
181
+ ctx.succeed();
182
+ });
183
+
184
+ return {
185
+ // Control plane (§4.3): handlers run on the host process's server fiber, closing over the
186
+ // live `alarmManager`/`inputQueue` and persisting their durable effect inline.
187
+ rpcHandlers: yield* HarnessControl.toHandlersContext({
188
+ setAlarm: Effect.fn(function* ({ at, message }) {
189
+ yield* alarmManager.setWakeAt(DateTime.toEpochMillis(at), message);
190
+ alarmManager.reconcile(inputQueue.length > 0);
191
+ }),
192
+ enqueueMessage: Effect.fn(function* ({ content }) {
193
+ inputQueue.push({ _tag: 'prompt', content });
194
+ yield* AgentEventsCell.set(inputQueue);
195
+ alarmManager.reconcile(true);
196
+ }),
197
+ }),
198
+ onInput: Effect.fnUntraced(function* (prompt: string) {
199
+ log('agent onInput received', { promptLength: prompt.length, backlog: inputQueue.length });
200
+ inputQueue.push({ _tag: 'prompt', content: [ContentBlock.Text.make({ text: prompt })] });
201
+ log('agent onInput persisting queue', { depth: inputQueue.length });
202
+ yield* AgentEventsCell.set(inputQueue);
203
+ log('agent onInput persisted', { depth: inputQueue.length });
204
+ alarmManager.reconcile(true);
205
+ log('agent onInput alarm scheduled');
206
+ }),
207
+ onAlarm: Effect.fnUntraced(
208
+ function* () {
209
+ log('agent onAlarm fired', { pending: inputQueue.length });
210
+
211
+ // If the agent scheduled a self-wake that has come due, enqueue a wake-up prompt.
212
+ const fired = yield* alarmManager.takeFiredAlarm();
213
+ if (fired != null) {
214
+ log('agent onAlarm self-wake', { firedAt: fired.firedAt });
215
+ inputQueue.push({ _tag: 'alarm', firedAt: fired.firedAt, message: fired.message });
216
+ yield* AgentEventsCell.set(inputQueue);
217
+ }
218
+
219
+ // Skip reported tool results at head of queue (stale after reload).
220
+ while (inputQueue.length > 0) {
221
+ const head = inputQueue[0];
222
+ if (head._tag === 'tool_result' && toolCallManager.isReported(head.pid)) {
223
+ inputQueue.shift();
224
+ log.info('skip tool result that was reported synchronously', { pid: head.pid });
225
+ continue;
226
+ }
227
+ break;
228
+ }
229
+
230
+ const item = inputQueue.shift();
231
+ if (!item) {
232
+ log('agent onAlarm empty queue', {});
233
+ alarmManager.reconcile(false);
234
+ yield* AgentEventsCell.set(inputQueue);
235
+ yield* maybeComplete;
236
+ return;
237
+ }
238
+
239
+ log('agent onAlarm handling', { tag: item._tag });
240
+
241
+ const prompt: ContentBlock.Any[] = Match.value(item).pipe(
242
+ Match.tag('prompt', (item) => [...item.content]),
243
+ Match.tag('tool_result', (item) =>
244
+ item.isError
245
+ ? [
246
+ ContentBlock.Text.make({
247
+ text: toolErrorResponse(item.pid, item.result as string),
248
+ disposition: 'synthetic',
249
+ }),
250
+ ]
251
+ : [
252
+ ContentBlock.Text.make({
253
+ text: toolResultResponse(item.pid, item.result),
254
+ disposition: 'synthetic',
255
+ }),
256
+ ],
257
+ ),
258
+ Match.tag('alarm', (item) => [
259
+ ContentBlock.Text.make({ text: wakeUpPrompt(item.firedAt, item.message), disposition: 'synthetic' }),
260
+ ]),
261
+ Match.exhaustive,
262
+ );
263
+
264
+ log('begin request', { prompt });
265
+ log('trace agent request begin');
266
+ yield* Trace.write(AgentRequestBegin, {});
267
+ yield* session
268
+ .createRequest({
269
+ prompt,
270
+ // TODO(dmaretskyi): Polling currently broken, agent relies on completion notifications being delivered.
271
+ // toolkit: AsynchronousExectionToolkit,
272
+ // The alarm tools (set-alarm/get-current-date) now arrive as a bound blueprint whose
273
+ // operations reach this host's AlarmManager over HarnessService Tier B.
274
+ system: options.systemPrompt,
275
+ mcpServers: options.getMcpServers?.(),
276
+ })
277
+ .pipe(
278
+ Effect.onExit((exit) =>
279
+ Trace.write(AgentRequestEnd, {
280
+ status: Exit.isSuccess(exit) ? 'success' : Exit.isInterrupted(exit) ? 'interrupted' : 'error',
281
+ error: Exit.isFailure(exit) ? Cause.pretty(exit.cause) : undefined,
282
+ }),
283
+ ),
284
+ );
285
+ log('end request');
286
+ yield* AgentEventsCell.set(inputQueue);
287
+
288
+ // Reconcile outstanding work into linked child processes (supervisor behaviour). The
289
+ // children are linked, so their exits wake `onChildEvent` below.
290
+ if (Option.isSome(strategy)) {
291
+ const activeIds = new Set(delegations.map((delegation) => delegation.id));
292
+ const pending = yield* strategy.value.reconcile(feed, activeIds);
293
+ for (const delegation of pending) {
294
+ const pid = yield* delegation.spawn;
295
+ delegations.push({ pid, id: delegation.id });
296
+ log('delegated work', { pid, id: delegation.id });
297
+ }
298
+ if (pending.length > 0) {
299
+ yield* DelegationsCell.set(delegations);
300
+ }
301
+ }
302
+
303
+ // Reconcile so a pending agent self-wake (or remaining queue work) is rescheduled.
304
+ alarmManager.reconcile(inputQueue.length > 0);
305
+ yield* maybeComplete;
306
+ },
307
+ Effect.orDie,
308
+ Effect.provide(
309
+ Layer.mergeAll(
310
+ makeToolResolverFromOperations(),
311
+ ToolExecutionService({
312
+ toolCallManager,
313
+ feed,
314
+ enableBackgrounding: options.enableToolBackgrounding ?? false,
315
+ }),
316
+ AsynchronousExectionToolkitLayer,
317
+ requestModelLayer,
318
+ ).pipe(Layer.orDie),
319
+ ),
320
+ ),
321
+ onChildEvent: Effect.fnUntraced(function* (event) {
322
+ log('childEvent', { event });
323
+ if (event._tag === 'exited') {
324
+ // A delegated sub-agent finished: read its result and hand it to the strategy (which
325
+ // updates the work item and notifies the user). Unlike tool results, this does not
326
+ // re-enter the turn — the supervisor folds it in out of band.
327
+ const delegation = delegations.find((delegation) => delegation.pid === event.pid);
328
+ if (delegation) {
329
+ delegations = delegations.filter((other) => other.pid !== event.pid);
330
+ yield* DelegationsCell.set(delegations);
331
+ const operationInvoker = yield* ProcessManager.ProcessOperationInvoker.Service;
332
+ const fiber = yield* operationInvoker.attachFiber(event.pid).pipe(Effect.orDie);
333
+ const exit = yield* fiber.await;
334
+ if (Option.isSome(strategy)) {
335
+ yield* strategy.value.onComplete(feed, delegation.id, exit);
336
+ }
337
+ log('delegated work completed', { pid: event.pid, id: delegation.id, success: Exit.isSuccess(exit) });
338
+ yield* maybeComplete;
339
+ } else if (toolCallManager.isToolCall(event.pid)) {
340
+ const operationInvoker = yield* ProcessManager.ProcessOperationInvoker.Service;
341
+ const attachExit = yield* operationInvoker.attachFiber(event.pid).pipe(Effect.exit);
342
+ if (Exit.isFailure(attachExit)) {
343
+ // Completed tool children are not rehydrated on reload; the result is in inputQueue or was
344
+ // delivered synchronously before the interrupted turn.
345
+ if (
346
+ toolCallManager.isToolCall(event.pid) ||
347
+ inputQueue.some((item) => item._tag === 'tool_result' && item.pid === event.pid) ||
348
+ toolCallManager.isReported(event.pid)
349
+ ) {
350
+ log.verbose('childEvent skipped (process gone, result already handled)', { pid: event.pid });
351
+ return;
352
+ }
353
+ return yield* Effect.failCause(attachExit.cause).pipe(Effect.orDie);
354
+ }
355
+ const fiber = attachExit.value;
356
+ const result = yield* fiber.await.pipe(Effect.orDie).pipe(
357
+ Effect.map(
358
+ Exit.match({
359
+ onSuccess: (value): AgentEvent => ({
360
+ _tag: 'tool_result',
361
+ pid: event.pid,
362
+ result: value,
363
+ isError: false,
364
+ }),
365
+ onFailure: (cause): AgentEvent => ({
366
+ _tag: 'tool_result',
367
+ pid: event.pid,
368
+ result: Cause.pretty(cause),
369
+ isError: true,
370
+ }),
371
+ }),
372
+ ),
373
+ );
374
+ inputQueue.push(result);
375
+ log('agent onChildEvent persisted tool result', { depth: inputQueue.length, childPid: event.pid });
376
+ yield* AgentEventsCell.set(inputQueue);
377
+ alarmManager.reconcile(true);
378
+ log('agent onChildEvent alarm scheduled', { depth: inputQueue.length });
379
+ } else {
380
+ log.verbose('childEvent ignored non-tool call and not a delegation', { pid: event.pid });
381
+ }
382
+ }
383
+ }),
384
+ };
385
+ }),
386
+ );
387
+
388
+ interface ToolExecutionServiceOptions {
389
+ /**
390
+ * If true, tool calls that exceed `backgroundThreshold` are detached and the agent is told
391
+ * the call is running in the background. If false, the executor always blocks on the call.
392
+ */
393
+ enableBackgrounding: boolean;
394
+
395
+ /**
396
+ * Threshold after which the tool execution is placed in the background.
397
+ * Ignored when `enableBackgrounding` is false.
398
+ */
399
+ // TODO(dmaretskyi): Tool annotation to never run in background.
400
+ backgroundThreshold?: Duration.Duration;
401
+
402
+ toolCallManager: ToolCallManager;
403
+
404
+ feed: Feed.Feed;
405
+ }
406
+
407
+ const AgentEvent = Schema.Union(
408
+ Schema.TaggedStruct('prompt', {
409
+ content: Schema.Array(ContentBlock.Any),
410
+ }),
411
+ Schema.TaggedStruct('tool_result', {
412
+ pid: Process.ID,
413
+ result: Schema.Unknown,
414
+ isError: Schema.Boolean,
415
+ }),
416
+ Schema.TaggedStruct('alarm', {
417
+ firedAt: Schema.Number,
418
+ // Optional reminder carried from the self-wake; surfaced to the agent when the alarm fires.
419
+ message: Schema.NullOr(Schema.String),
420
+ }),
421
+ );
422
+ type AgentEvent = Schema.Schema.Type<typeof AgentEvent>;
423
+
424
+ const AgentEventsCell = StorageService.cell(
425
+ Schema.parseJson(Schema.Array(AgentEvent).pipe(Schema.mutable)),
426
+ 'inputQueue',
427
+ ).pipe(StorageService.withDefault(() => []));
428
+
429
+ /**
430
+ * Tracks delegated sub-agent child processes (pid -> correlation id) so that, after a hibernation,
431
+ * a delegated child's exit can be matched back to the work it was fulfilling.
432
+ */
433
+ const Delegation = Schema.Struct({ pid: Process.ID, id: Schema.String }).pipe(Schema.mutable);
434
+ type Delegation = Schema.Schema.Type<typeof Delegation>;
435
+
436
+ const DelegationsCell = StorageService.cell(
437
+ Schema.parseJson(Schema.Array(Delegation).pipe(Schema.mutable)),
438
+ 'delegations',
439
+ ).pipe(StorageService.withDefault(() => []));
440
+
441
+ const ToolCallState = Schema.Struct({
442
+ activeCalls: Schema.Array(
443
+ Schema.Struct({
444
+ pid: Process.ID,
445
+ // Whether the result was reported to the agent.
446
+ reported: Schema.Boolean,
447
+ }).pipe(Schema.mutable),
448
+ ).pipe(Schema.mutable),
449
+ });
450
+ interface ToolCallState extends Schema.Schema.Type<typeof ToolCallState> {}
451
+
452
+ // Id's of processes who's results were already submitted to the agent.
453
+ const ToolCallStateCell = StorageService.cell(
454
+ Schema.parseJson(ToolCallState.pipe(Schema.mutable)),
455
+ 'toolCallState',
456
+ ).pipe(StorageService.withDefault(() => ({ activeCalls: [] })));
457
+
458
+ class ToolCallManager {
459
+ #storageService: StorageService.Service;
460
+ #state: ToolCallState = { activeCalls: [] };
461
+
462
+ constructor(storageService: StorageService.Service) {
463
+ this.#storageService = storageService;
464
+ }
465
+
466
+ load() {
467
+ return Effect.gen(this, function* () {
468
+ this.#state = yield* ToolCallStateCell.get;
469
+ }).pipe(Effect.provideService(StorageService.StorageService, this.#storageService));
470
+ }
471
+
472
+ beginCall(pid: Process.ID) {
473
+ return Effect.gen(this, function* () {
474
+ this.#state.activeCalls.push({ pid, reported: false });
475
+ yield* ToolCallStateCell.set(this.#state);
476
+ }).pipe(Effect.provideService(StorageService.StorageService, this.#storageService));
477
+ }
478
+
479
+ markAsReported(pid: Process.ID) {
480
+ return Effect.gen(this, function* () {
481
+ const call = this.#state.activeCalls.find((call) => call.pid === pid);
482
+ if (!call) {
483
+ return;
484
+ }
485
+ call.reported = true;
486
+ yield* ToolCallStateCell.set(this.#state);
487
+ }).pipe(Effect.provideService(StorageService.StorageService, this.#storageService));
488
+ }
489
+
490
+ isToolCall(pid: Process.ID): boolean {
491
+ return this.#state.activeCalls.some((call) => call.pid === pid);
492
+ }
493
+
494
+ isReported(pid: Process.ID) {
495
+ return this.#state.activeCalls.some((call) => call.pid === pid && call.reported);
496
+ }
497
+
498
+ /** True while a tool-call result has not yet been delivered back to the agent turn. */
499
+ hasPendingToolResults(): boolean {
500
+ return this.#state.activeCalls.some((call) => !call.reported);
501
+ }
502
+
503
+ /**
504
+ * Clears reported flags for tool calls that still have a pending queue entry.
505
+ * After reload the in-flight createRequest is gone, so those results must be redelivered via onAlarm.
506
+ */
507
+ reconcileWithInputQueue(queue: readonly AgentEvent[]) {
508
+ return Effect.gen(this, function* () {
509
+ let changed = false;
510
+ for (const item of queue) {
511
+ if (item._tag !== 'tool_result') {
512
+ continue;
513
+ }
514
+ const call = this.#state.activeCalls.find((entry) => entry.pid === item.pid);
515
+ if (call?.reported) {
516
+ call.reported = false;
517
+ changed = true;
518
+ log('reconcile queued tool result', { pid: item.pid });
519
+ }
520
+ }
521
+ if (changed) {
522
+ yield* ToolCallStateCell.set(this.#state);
523
+ }
524
+ }).pipe(Effect.provideService(StorageService.StorageService, this.#storageService));
525
+ }
526
+ }
527
+
528
+ export type AgentIdleSnapshot = {
529
+ inputQueue: readonly AgentEvent[];
530
+ alarmManager: AlarmManager;
531
+ delegations: readonly Delegation[];
532
+ // Only the pending-results check is consulted, so the predicate stays decoupled from the rest of
533
+ // the ToolCallManager surface (and is trivially stubbable in tests).
534
+ toolCallManager: Pick<ToolCallManager, 'hasPendingToolResults'>;
535
+ };
536
+
537
+ /** True while the agent still has queued work, a self-wake, subprocesses, or undelivered tool results. */
538
+ export const isAgentWorkPending = ({
539
+ inputQueue,
540
+ alarmManager,
541
+ delegations,
542
+ toolCallManager,
543
+ }: AgentIdleSnapshot): boolean =>
544
+ inputQueue.length > 0 ||
545
+ alarmManager.wakeAt != null ||
546
+ delegations.length > 0 ||
547
+ toolCallManager.hasPendingToolResults();
548
+
549
+ //
550
+ // Alarms.
551
+ //
552
+
553
+ /**
554
+ * Persisted next agent-scheduled self-wake: the UNIX timestamp (ms) and an optional reminder
555
+ * message delivered when it fires, or `null` when no self-wake is set.
556
+ */
557
+ const AgentAlarmCell = StorageService.cell(
558
+ Schema.parseJson(Schema.NullOr(Schema.Struct({ wakeAt: Schema.Number, message: Schema.NullOr(Schema.String) }))),
559
+ 'agentAlarm',
560
+ ).pipe(StorageService.withDefault(() => null));
561
+
562
+ /**
563
+ * Computes the timeout to pass to `ctx.setAlarm`, reconciling pending queue work with the agent's
564
+ * self-wake alarm. Returns `null` when no alarm should be scheduled (process can go idle).
565
+ */
566
+ export const computeAlarmDelay = ({
567
+ hasPendingWork,
568
+ wakeAt,
569
+ now,
570
+ }: {
571
+ hasPendingWork: boolean;
572
+ wakeAt: number | null;
573
+ now: number;
574
+ }): number | null => {
575
+ if (hasPendingWork) {
576
+ return 0;
577
+ }
578
+ if (wakeAt != null) {
579
+ return Math.max(0, wakeAt - now);
580
+ }
581
+ return null;
582
+ };
583
+
584
+ interface AlarmManagerOptions {
585
+ storageService: StorageService.Service;
586
+ setAlarm: (timeout?: number) => void;
587
+
588
+ /**
589
+ * Source of the current time. Injectable for deterministic tests.
590
+ * @default () => Date.now()
591
+ */
592
+ now?: () => number;
593
+ }
594
+
595
+ /**
596
+ * Tracks the next agent-scheduled self-wake and keeps it in sync with the process alarm
597
+ * (`ctx.setAlarm`). The agent sets alarms via the alarm blueprint, which dispatches to this manager
598
+ * through the `HarnessControl` RPC surface; the process reconciles the underlying single-shot alarm
599
+ * timer to fire at the earliest of pending work or the self-wake.
600
+ */
601
+ export class AlarmManager {
602
+ readonly #storageService: StorageService.Service;
603
+ readonly #setAlarm: (timeout?: number) => void;
604
+ readonly #now: () => number;
605
+ #wakeAt: number | null = null;
606
+ #message: string | null = null;
607
+
608
+ constructor({ storageService, setAlarm, now = () => Date.now() }: AlarmManagerOptions) {
609
+ this.#storageService = storageService;
610
+ this.#setAlarm = setAlarm;
611
+ this.#now = now;
612
+ }
613
+
614
+ /** Currently scheduled self-wake timestamp (ms), or `null` when none is set. */
615
+ get wakeAt(): number | null {
616
+ return this.#wakeAt;
617
+ }
618
+
619
+ /** Reminder message delivered when the current self-wake fires, or `null` when none is set. */
620
+ get message(): string | null {
621
+ return this.#message;
622
+ }
623
+
624
+ now(): number {
625
+ return this.#now();
626
+ }
627
+
628
+ /** Restores the persisted alarm state. */
629
+ load(): Effect.Effect<void> {
630
+ return Effect.gen(this, function* () {
631
+ const persisted = yield* AgentAlarmCell.get;
632
+ this.#wakeAt = persisted?.wakeAt ?? null;
633
+ this.#message = persisted?.message ?? null;
634
+ }).pipe(Effect.provideService(StorageService.StorageService, this.#storageService));
635
+ }
636
+
637
+ /** Records a new self-wake target (with an optional reminder message) and persists it. */
638
+ setWakeAt(wakeAt: number, message: string | null = null): Effect.Effect<void> {
639
+ return Effect.gen(this, function* () {
640
+ this.#wakeAt = wakeAt;
641
+ this.#message = message;
642
+ yield* AgentAlarmCell.set({ wakeAt, message });
643
+ }).pipe(Effect.provideService(StorageService.StorageService, this.#storageService));
644
+ }
645
+
646
+ /**
647
+ * Clears the self-wake alarm if it is due, returning the timestamp it was scheduled for and the
648
+ * reminder message it carried (or `null` if no alarm was due).
649
+ */
650
+ takeFiredAlarm(): Effect.Effect<{ firedAt: number; message: string | null } | null> {
651
+ return Effect.gen(this, function* () {
652
+ if (this.#wakeAt == null || this.#now() < this.#wakeAt) {
653
+ return null;
654
+ }
655
+ const firedAt = this.#wakeAt;
656
+ const message = this.#message;
657
+ this.#wakeAt = null;
658
+ this.#message = null;
659
+ yield* AgentAlarmCell.set(null);
660
+ return { firedAt, message };
661
+ }).pipe(Effect.provideService(StorageService.StorageService, this.#storageService));
662
+ }
663
+
664
+ /**
665
+ * Reconciles the process alarm timer with pending work and the tracked self-wake, scheduling the
666
+ * earliest of the two. Does nothing when there is neither pending work nor a self-wake.
667
+ */
668
+ reconcile(hasPendingWork: boolean): void {
669
+ const delay = computeAlarmDelay({ hasPendingWork, wakeAt: this.#wakeAt, now: this.#now() });
670
+ if (delay != null) {
671
+ this.#setAlarm(delay);
672
+ }
673
+ }
674
+ }
675
+
676
+ /**
677
+ * Prompt delivered to the agent when a self-scheduled alarm fires. When the alarm carried a
678
+ * reminder message it is surfaced verbatim, otherwise a generic continuation prompt is used.
679
+ */
680
+ const wakeUpPrompt = (firedAt: number, message: string | null): string =>
681
+ message != null
682
+ ? trim`
683
+ Your scheduled alarm fired (it was set for ${new Date(firedAt).toISOString()}).
684
+ ${message}
685
+ `
686
+ : trim`
687
+ Your scheduled alarm fired (it was set for ${new Date(firedAt).toISOString()}).
688
+ Continue with whatever you intended to do when you scheduled this wake-up.
689
+ `;
690
+
691
+ const ToolExecutionService = ({
692
+ enableBackgrounding,
693
+ backgroundThreshold = Duration.seconds(1),
694
+ toolCallManager,
695
+ feed,
696
+ }: ToolExecutionServiceOptions) =>
697
+ Layer.unwrapEffect(
698
+ Effect.gen(function* () {
699
+ const operationInvoker = yield* ProcessManager.ProcessOperationInvoker.Service;
700
+ return makeToolExecutionService({
701
+ invoke: (tool, input) =>
702
+ Effect.gen(function* () {
703
+ const operationDef = getOperationFromTool(tool).pipe(Option.getOrThrow);
704
+ log('invoking operation', { operationDef, input });
705
+ const fiber = yield* operationInvoker.invokeFiber(operationDef, input, {
706
+ environment: {
707
+ conversation: Obj.getURI(feed),
708
+ },
709
+ traceMeta: {
710
+ conversation: Ref.make(feed),
711
+ },
712
+ });
713
+ yield* toolCallManager.beginCall(fiber.pid);
714
+ log('invoked operation', { operationDef, input, fiber });
715
+
716
+ const awaitWithReport = fiber.await.pipe(Effect.tap(() => toolCallManager.markAsReported(fiber.pid)));
717
+ const result = enableBackgrounding
718
+ ? yield* awaitWithReport.pipe(
719
+ Effect.timeout(backgroundThreshold),
720
+ Effect.catchTag('TimeoutException', () =>
721
+ Effect.succeed(Exit.succeed(toolIsRunningInBackgroundResponse(fiber.pid))),
722
+ ),
723
+ )
724
+ : yield* awaitWithReport;
725
+ log('result', { result });
726
+ return yield* result;
727
+ }),
728
+ });
729
+ }),
730
+ );
731
+
732
+ class AsynchronousExectionToolkit extends Toolkit.make(
733
+ Tool.make('poll-tools', {
734
+ description: trim`
735
+ Poll tool calls running in the background.
736
+ Set wait to true to wait for the tool call to complete before returning.
737
+ Only set wait to true if you dont have other tasks to perform in parallel.
738
+ Set an appropriate timeout to avoid waiting forever.
739
+ You will also be notified about the job completion separatelly, so you do not always need to inspect the job if you dont need the result right now.
740
+ `,
741
+ parameters: {
742
+ ids: Schema.Array(Schema.String).annotations({
743
+ description: 'The IDs of the jobs to inspect.',
744
+ }),
745
+ wait: Schema.optional(Schema.Boolean).annotations({
746
+ description: 'Whether to wait for the tool call to complete before returning.',
747
+ default: false,
748
+ }),
749
+ timeout: Schema.optional(Schema.Number).annotations({
750
+ description:
751
+ 'Maximum time to wait for the job to complete. If the job does not complete within the timeout, the current state is returned.',
752
+ default: 10_000,
753
+ }),
754
+ },
755
+ }),
756
+ ) {}
757
+
758
+ // TODO(dmaretskyi): Currently broken: polling a completed process returns interruped error.
759
+ const AsynchronousExectionToolkitLayer = AsynchronousExectionToolkit.toLayer(
760
+ Effect.gen(function* () {
761
+ const invoker = yield* ProcessManager.ProcessOperationInvoker.Service;
762
+ return {
763
+ 'poll-tools': ({ ids, wait, timeout = 10_000 }) =>
764
+ Effect.gen(function* () {
765
+ return yield* Effect.forEach(ids, (pid) =>
766
+ invoker.attachFiber<unknown>(Process.ID.make(pid)).pipe(
767
+ Effect.flatMap((_) => _.await),
768
+ Effect.timeout(Duration.millis(timeout)),
769
+ Effect.flatMap(
770
+ Exit.match({
771
+ onSuccess: (value) => Effect.succeed(toolResultResponse(pid, value)),
772
+ onFailure: (cause) => Effect.succeed(toolErrorResponse(pid, Cause.pretty(cause))),
773
+ }),
774
+ ),
775
+ Effect.catchTag('ProcessNotFoundError', () => Effect.succeed(`Process not found: ${pid}`)),
776
+ Effect.catchTag('TimeoutException', () => Effect.succeed(`Process still running: ${pid}`)),
777
+ ),
778
+ );
779
+ }),
780
+ };
781
+ }),
782
+ );
783
+
784
+ /**
785
+ * Instructs model that the tool is running in the background.
786
+ */
787
+ const toolIsRunningInBackgroundResponse = (pid: Process.ID) =>
788
+ `Tool is running in the background (id=${pid}); wait for the completion notification to get the result.`;
789
+ // `Tool is running in the background (id=${pid}); use ${AsynchronousExectionToolkit.tools['poll-tools'].name} to get the result.`;
790
+
791
+ const toolResultResponse = (pid: string, value: unknown) => `<result pid=${pid}>${JSON.stringify(value)}</result>`;
792
+
793
+ const toolErrorResponse = (pid: string, cause: string) => `<error pid=${pid}>${cause}</error>`;