@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.
- package/dist/lib/chunk-AgentService.mjs +189 -93
- package/dist/lib/chunk-AgentService.mjs.map +1 -1
- package/dist/lib/testing.mjs +3 -3
- package/dist/lib/testing.mjs.map +1 -1
- package/dist/types/src/agent-service/AgentService.d.ts +9 -0
- package/dist/types/src/agent-service/AgentService.d.ts.map +1 -1
- package/dist/types/src/agent-service/agent-process.d.ts +23 -5
- package/dist/types/src/agent-service/agent-process.d.ts.map +1 -1
- package/dist/types/src/agent-service/delegation-strategy.d.ts +5 -4
- package/dist/types/src/agent-service/delegation-strategy.d.ts.map +1 -1
- package/dist/types/src/agent-service/index.d.ts +1 -1
- package/dist/types/src/agent-service/index.d.ts.map +1 -1
- package/dist/types/src/agent-service/process-selection.test.d.ts +2 -0
- package/dist/types/src/agent-service/process-selection.test.d.ts.map +1 -0
- package/dist/types/src/agent-service/skill-hooks.test.d.ts +2 -0
- package/dist/types/src/agent-service/skill-hooks.test.d.ts.map +1 -0
- package/dist/types/src/testing/assistant-test-layer.d.ts +2 -2
- package/dist/types/src/testing/assistant-test-layer.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +17 -17
- package/src/agent-service/AgentService.test.ts +4 -4
- package/src/agent-service/AgentService.ts +63 -24
- package/src/agent-service/README.md +10 -10
- package/src/agent-service/agent-process.ts +201 -100
- package/src/agent-service/delegation-scripted.test.ts +4 -4
- package/src/agent-service/delegation-strategy.ts +9 -4
- package/src/agent-service/index.ts +1 -0
- package/src/agent-service/process-selection.test.ts +135 -0
- package/src/agent-service/queue-scripted.test.ts +39 -0
- package/src/agent-service/redelivery.test.ts +23 -1
- package/src/agent-service/skill-hooks.test.ts +182 -0
- 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 {
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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.
|
|
447
|
-
//
|
|
448
|
-
|
|
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
|
|
663
|
-
const
|
|
664
|
-
const exit = yield*
|
|
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
|
|
696
|
-
const attachExit = yield*
|
|
697
|
-
if (
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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
|
|
711
|
-
|
|
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
|
|
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
|
|
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(
|
|
1127
|
+
yield* toolCallManager.beginCall(handle.pid);
|
|
1035
1128
|
markWork('tool.call-recorded');
|
|
1036
|
-
log('invoked operation', { operationDef, input,
|
|
1129
|
+
log('invoked operation', { operationDef, input, pid: handle.pid });
|
|
1037
1130
|
|
|
1038
|
-
const awaitWithReport =
|
|
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(
|
|
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
|
|
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
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
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 {
|
|
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
|
|
74
|
-
|
|
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
|
|
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 `
|
|
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.
|
|
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<
|
|
30
|
+
readonly spawn: Effect.Effect<
|
|
31
|
+
Process.ID,
|
|
32
|
+
never,
|
|
33
|
+
Process.ManagerService | OperationHandlerSet.OperationHandlerProvider
|
|
34
|
+
>;
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
/**
|