@dxos/agent-runtime 0.0.0 → 0.11.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 +789 -0
- package/dist/lib/chunk-AgentService.mjs.map +1 -0
- package/dist/lib/index.mjs +2 -0
- package/dist/lib/testing.mjs +294 -0
- package/dist/lib/testing.mjs.map +1 -0
- package/dist/types/src/agent-service/AgentService.d.ts +4 -4
- package/dist/types/src/agent-service/AgentService.d.ts.map +1 -1
- package/dist/types/src/agent-service/agent-process.d.ts +173 -5
- package/dist/types/src/agent-service/agent-process.d.ts.map +1 -1
- package/dist/types/src/agent-service/delegation-scripted.test.d.ts +2 -0
- package/dist/types/src/agent-service/delegation-scripted.test.d.ts.map +1 -0
- package/dist/types/src/assistant-session-tests/scripted-loop.test.d.ts +2 -0
- package/dist/types/src/assistant-session-tests/scripted-loop.test.d.ts.map +1 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/testing/assistant-test-layer.d.ts +6 -2
- package/dist/types/src/testing/assistant-test-layer.d.ts.map +1 -1
- package/dist/types/src/testing/index.d.ts +2 -0
- package/dist/types/src/testing/index.d.ts.map +1 -1
- package/dist/types/src/testing/test-agent.d.ts +70 -0
- package/dist/types/src/testing/test-agent.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +17 -16
- package/src/agent-service/AgentService.conversations.json +1 -1
- package/src/agent-service/AgentService.test.ts +115 -58
- package/src/agent-service/AgentService.ts +44 -15
- package/src/agent-service/README.md +4 -4
- package/src/agent-service/agent-process.ts +31 -12
- package/src/agent-service/delegation-scripted.test.ts +123 -0
- package/src/assistant-session-tests/request.conversations.json +1 -1
- package/src/assistant-session-tests/request.test.ts +2 -2
- package/src/assistant-session-tests/scripted-loop.test.ts +119 -0
- package/src/assistant-session-tests/xml-response.conversations.json +1 -1
- package/src/assistant-session-tests/xml-response.test.ts +2 -2
- package/src/functions.conversations.json +1 -1
- package/src/functions.test.ts +2 -2
- package/src/index.ts +0 -2
- package/src/testing/assistant-test-layer.ts +32 -1
- package/src/testing/index.ts +3 -0
- package/src/testing/test-agent.ts +104 -0
- package/dist/lib/neutral/chunk-EZKG2ZGG.mjs +0 -754
- package/dist/lib/neutral/chunk-EZKG2ZGG.mjs.map +0 -7
- package/dist/lib/neutral/index.mjs +0 -9
- package/dist/lib/neutral/index.mjs.map +0 -7
- package/dist/lib/neutral/meta.json +0 -1
- package/dist/lib/neutral/testing/index.mjs +0 -285
- package/dist/lib/neutral/testing/index.mjs.map +0 -7
|
@@ -16,15 +16,16 @@ import { expect } from 'vitest';
|
|
|
16
16
|
|
|
17
17
|
import { MemoizedAiService } from '@dxos/ai/testing';
|
|
18
18
|
import { PartialBlock, SessionLink } from '@dxos/assistant';
|
|
19
|
-
import { Operation, OperationHandlerSet, Process, ServiceResolver, Skill, Trace } from '@dxos/compute';
|
|
19
|
+
import { Instructions, Operation, OperationHandlerSet, Process, ServiceResolver, Skill, Trace } from '@dxos/compute';
|
|
20
20
|
import { ProcessManager } from '@dxos/compute-runtime';
|
|
21
21
|
import { getSession, hydrate } from '@dxos/compute/AgentService';
|
|
22
|
-
import { Annotation, Feed, Filter, Obj, Ref } from '@dxos/echo';
|
|
22
|
+
import { Annotation, Database, Feed, Filter, Obj, Ref } from '@dxos/echo';
|
|
23
23
|
import { TestHelpers } from '@dxos/effect/testing';
|
|
24
24
|
import { DXN, EntityId } from '@dxos/keys';
|
|
25
|
+
import { Text } from '@dxos/schema';
|
|
25
26
|
import { Message, Organization } from '@dxos/types';
|
|
26
27
|
|
|
27
|
-
import { AssistantTestLayer } from '../testing';
|
|
28
|
+
import { AssistantTestLayer, runMemoizedTests } from '../testing';
|
|
28
29
|
import * as ResearchService from '../testing/ResearchService';
|
|
29
30
|
import { AGENT_PROCESS_KEY } from './agent-process';
|
|
30
31
|
import * as AgentService from './AgentService';
|
|
@@ -85,7 +86,7 @@ const ResearchSkill = Skill.make({
|
|
|
85
86
|
});
|
|
86
87
|
|
|
87
88
|
const assistantTestLayerOptions = {
|
|
88
|
-
types: [Organization.Organization, Feed.Feed, Skill.Skill],
|
|
89
|
+
types: [Organization.Organization, Feed.Feed, Skill.Skill, Instructions.Instructions, Text.Text],
|
|
89
90
|
tracing: 'pretty' as const,
|
|
90
91
|
aiServicePreset: 'edge-remote' as const,
|
|
91
92
|
operationHandlers: [handlers],
|
|
@@ -144,16 +145,16 @@ const DelegationTestLayer = AssistantTestLayer({
|
|
|
144
145
|
agent: { delegationStrategy: StubDelegationStrategy },
|
|
145
146
|
});
|
|
146
147
|
|
|
147
|
-
describe('Agent Service', () => {
|
|
148
|
+
describe.skipIf(!runMemoizedTests())('Agent Service', () => {
|
|
148
149
|
it.effect(
|
|
149
150
|
'can answer a question',
|
|
150
151
|
Effect.fnUntraced(
|
|
151
152
|
function* (_) {
|
|
152
|
-
const
|
|
153
|
-
yield*
|
|
154
|
-
yield*
|
|
153
|
+
const session = yield* AgentService.createSession();
|
|
154
|
+
yield* session.submitPrompt('What is the capital of France?');
|
|
155
|
+
yield* session.waitForCompletion();
|
|
155
156
|
|
|
156
|
-
const messages = yield* Feed.query(
|
|
157
|
+
const messages = yield* Feed.query(session.feed, Filter.type(Message.Message)).run;
|
|
157
158
|
const text = messages.map(Message.extractText).join('\n');
|
|
158
159
|
expect(text.toLocaleLowerCase()).toContain('paris');
|
|
159
160
|
},
|
|
@@ -167,15 +168,13 @@ describe('Agent Service', () => {
|
|
|
167
168
|
'tool call',
|
|
168
169
|
Effect.fnUntraced(
|
|
169
170
|
function* (_) {
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
});
|
|
173
|
-
yield* agent.submitPrompt(`Research ${JSON.stringify(ResearchService.getTestData().organizations[0])}`);
|
|
171
|
+
const session = yield* AgentService.createSession({ skills: [ResearchSkill] });
|
|
172
|
+
yield* session.submitPrompt(`Research ${JSON.stringify(ResearchService.getTestData().organizations[0])}`);
|
|
174
173
|
|
|
175
174
|
const researchService = yield* ServiceResolver.resolve(ResearchService.ResearchService, {});
|
|
176
175
|
yield* researchService.waitForTaskToAppear();
|
|
177
176
|
yield* researchService.completeOneTask();
|
|
178
|
-
yield*
|
|
177
|
+
yield* session.waitForCompletion();
|
|
179
178
|
},
|
|
180
179
|
Effect.provide(TestLayer()),
|
|
181
180
|
TestHelpers.provideTestContext,
|
|
@@ -187,14 +186,12 @@ describe('Agent Service', () => {
|
|
|
187
186
|
'can be stopped while waiting for a tool call',
|
|
188
187
|
Effect.fnUntraced(
|
|
189
188
|
function* (_) {
|
|
190
|
-
let
|
|
191
|
-
|
|
192
|
-
});
|
|
193
|
-
yield* agent.submitPrompt(`Research ${JSON.stringify(ResearchService.getTestData().organizations[0])}`);
|
|
189
|
+
let session = yield* AgentService.createSession({ skills: [ResearchSkill] });
|
|
190
|
+
yield* session.submitPrompt(`Research ${JSON.stringify(ResearchService.getTestData().organizations[0])}`);
|
|
194
191
|
const researchService = yield* ServiceResolver.resolve(ResearchService.ResearchService, {});
|
|
195
192
|
yield* researchService.waitForTaskToAppear();
|
|
196
193
|
|
|
197
|
-
yield*
|
|
194
|
+
yield* session.terminate();
|
|
198
195
|
expect(researchService.getTasks().map((task) => task.state)).toEqual(['interrupted']);
|
|
199
196
|
},
|
|
200
197
|
Effect.provide(TestLayer()),
|
|
@@ -206,10 +203,8 @@ describe('Agent Service', () => {
|
|
|
206
203
|
'restart during tool call',
|
|
207
204
|
Effect.fnUntraced(
|
|
208
205
|
function* (_) {
|
|
209
|
-
let
|
|
210
|
-
|
|
211
|
-
});
|
|
212
|
-
yield* agent.submitPrompt(`Research ${JSON.stringify(ResearchService.getTestData().organizations[0])}`);
|
|
206
|
+
let session = yield* AgentService.createSession({ skills: [ResearchSkill] });
|
|
207
|
+
yield* session.submitPrompt(`Research ${JSON.stringify(ResearchService.getTestData().organizations[0])}`);
|
|
213
208
|
|
|
214
209
|
const researchService = yield* ServiceResolver.resolve(ResearchService.ResearchService, {});
|
|
215
210
|
yield* researchService.waitForTaskToAppear();
|
|
@@ -224,8 +219,8 @@ describe('Agent Service', () => {
|
|
|
224
219
|
yield* researchService.waitForTaskToAppear();
|
|
225
220
|
yield* researchService.completeAllTasks();
|
|
226
221
|
|
|
227
|
-
|
|
228
|
-
yield*
|
|
222
|
+
session = yield* getSession(session.feed);
|
|
223
|
+
yield* session.waitForCompletion();
|
|
229
224
|
},
|
|
230
225
|
Effect.provide(TestLayer()),
|
|
231
226
|
TestHelpers.provideTestContext,
|
|
@@ -237,10 +232,8 @@ describe('Agent Service', () => {
|
|
|
237
232
|
'recovers queued tool results after reload',
|
|
238
233
|
Effect.fnUntraced(
|
|
239
234
|
function* (_) {
|
|
240
|
-
let
|
|
241
|
-
|
|
242
|
-
});
|
|
243
|
-
yield* agent.submitPrompt(`Research ${JSON.stringify(ResearchService.getTestData().organizations[0])}`);
|
|
235
|
+
let session = yield* AgentService.createSession({ skills: [ResearchSkill] });
|
|
236
|
+
yield* session.submitPrompt(`Research ${JSON.stringify(ResearchService.getTestData().organizations[0])}`);
|
|
244
237
|
|
|
245
238
|
const researchService = yield* ServiceResolver.resolve(ResearchService.ResearchService, {});
|
|
246
239
|
yield* researchService.waitForTaskToAppear();
|
|
@@ -255,10 +248,10 @@ describe('Agent Service', () => {
|
|
|
255
248
|
yield* researchService.waitForTaskToAppear();
|
|
256
249
|
yield* researchService.completeAllTasks();
|
|
257
250
|
|
|
258
|
-
|
|
259
|
-
yield*
|
|
251
|
+
session = yield* getSession(session.feed);
|
|
252
|
+
yield* session.waitForCompletion();
|
|
260
253
|
|
|
261
|
-
const messages = yield* Feed.query(
|
|
254
|
+
const messages = yield* Feed.query(session.feed, Filter.type(Message.Message)).run;
|
|
262
255
|
const text = messages.map(Message.extractText).join('\n');
|
|
263
256
|
expect(text.toLocaleLowerCase()).toContain('cyberdyne');
|
|
264
257
|
},
|
|
@@ -272,9 +265,9 @@ describe('Agent Service', () => {
|
|
|
272
265
|
'rehydrates an idle session and replays conversation history',
|
|
273
266
|
Effect.fnUntraced(
|
|
274
267
|
function* (_) {
|
|
275
|
-
let
|
|
276
|
-
yield*
|
|
277
|
-
yield*
|
|
268
|
+
let session = yield* AgentService.createSession();
|
|
269
|
+
yield* session.submitPrompt('What is the capital of France? Reply with just the city name.');
|
|
270
|
+
yield* session.waitForCompletion();
|
|
278
271
|
|
|
279
272
|
// Simulate app teardown + reboot while the session sits idle (nothing in-flight).
|
|
280
273
|
const processManager = yield* ProcessManager.ProcessManagerService;
|
|
@@ -284,11 +277,11 @@ describe('Agent Service', () => {
|
|
|
284
277
|
|
|
285
278
|
// The rehydrated agent is bound to the same feed, so a follow-up that only makes sense
|
|
286
279
|
// with prior context resolves against the pre-restart turn.
|
|
287
|
-
|
|
288
|
-
yield*
|
|
289
|
-
yield*
|
|
280
|
+
session = yield* getSession(session.feed);
|
|
281
|
+
yield* session.submitPrompt('What country did I just ask you about? Reply with just the country name.');
|
|
282
|
+
yield* session.waitForCompletion();
|
|
290
283
|
|
|
291
|
-
const messages = yield* Feed.query(
|
|
284
|
+
const messages = yield* Feed.query(session.feed, Filter.type(Message.Message)).run;
|
|
292
285
|
const text = messages.map(Message.extractText).join('\n');
|
|
293
286
|
expect(text.toLocaleLowerCase()).toContain('paris');
|
|
294
287
|
expect(text.toLocaleLowerCase()).toContain('france');
|
|
@@ -319,9 +312,7 @@ describe('Agent Service', () => {
|
|
|
319
312
|
'runs AI agent with background tools via process manager',
|
|
320
313
|
Effect.fnUntraced(
|
|
321
314
|
function* (_) {
|
|
322
|
-
const
|
|
323
|
-
skills: [ResearchSkill],
|
|
324
|
-
});
|
|
315
|
+
const session = yield* AgentService.createSession({ skills: [ResearchSkill] });
|
|
325
316
|
|
|
326
317
|
const researchService = yield* ServiceResolver.resolve(ResearchService.ResearchService, {});
|
|
327
318
|
const taskDrainer = yield* Effect.gen(function* () {
|
|
@@ -330,7 +321,7 @@ describe('Agent Service', () => {
|
|
|
330
321
|
}).pipe(Effect.forever, Effect.fork);
|
|
331
322
|
|
|
332
323
|
let ephemeralEventCount = 0;
|
|
333
|
-
const ephemeralFiber = yield*
|
|
324
|
+
const ephemeralFiber = yield* session.subscribeEphemeral().pipe(
|
|
334
325
|
Stream.runForEach((msg) =>
|
|
335
326
|
Effect.gen(function* () {
|
|
336
327
|
for (const event of msg.events) {
|
|
@@ -344,11 +335,11 @@ describe('Agent Service', () => {
|
|
|
344
335
|
);
|
|
345
336
|
|
|
346
337
|
for (const org of ResearchService.getTestData().organizations) {
|
|
347
|
-
yield*
|
|
338
|
+
yield* session.submitPrompt(JSON.stringify(org));
|
|
348
339
|
}
|
|
349
|
-
yield*
|
|
340
|
+
yield* session.submitPrompt('When all research is complete, print 1-sentence summary for each organization.');
|
|
350
341
|
// TODO(dmaretskyi): wait until settles and only now start draining
|
|
351
|
-
yield*
|
|
342
|
+
yield* session.waitForCompletion();
|
|
352
343
|
|
|
353
344
|
yield* Fiber.interrupt(taskDrainer);
|
|
354
345
|
yield* Fiber.interrupt(ephemeralFiber);
|
|
@@ -361,6 +352,10 @@ describe('Agent Service', () => {
|
|
|
361
352
|
{ timeout: MemoizedAiService.isGenerationEnabled() ? 120_000 : undefined },
|
|
362
353
|
);
|
|
363
354
|
|
|
355
|
+
// Superseded by the ungated scripted-model port in `delegation-scripted.test.ts` (and the
|
|
356
|
+
// real-strategy test in `assistant-toolkit/src/supervisor/delegation-strategy.test.ts`); kept
|
|
357
|
+
// in place until this file's memoized fixtures are next regenerated, since removing it would
|
|
358
|
+
// shift the shared deterministic ID stream the later fixtures depend on.
|
|
364
359
|
describe('delegation (stub)', () => {
|
|
365
360
|
it.scoped(
|
|
366
361
|
'delegates work to a sub-agent and folds the result back on completion',
|
|
@@ -369,10 +364,10 @@ describe('Agent Service', () => {
|
|
|
369
364
|
delegationHarness.pending = [{ id: 'task-1', input: 'forty-two' }];
|
|
370
365
|
delegationHarness.completed = [];
|
|
371
366
|
|
|
372
|
-
const
|
|
373
|
-
yield*
|
|
367
|
+
const session = yield* AgentService.createSession();
|
|
368
|
+
yield* session.submitPrompt('What is the capital of France?');
|
|
374
369
|
// Settles on the turn's reply; the delegated child runs in the background (not awaited here).
|
|
375
|
-
yield*
|
|
370
|
+
yield* session.waitForCompletion();
|
|
376
371
|
|
|
377
372
|
// The post-turn reconcile spawned a linked child; its exit drives onChildEvent → onComplete.
|
|
378
373
|
yield* Effect.promise(async () => {
|
|
@@ -450,11 +445,11 @@ describe('Agent Service', () => {
|
|
|
450
445
|
function* (_) {
|
|
451
446
|
const processManager = yield* ProcessManager.ProcessManagerService;
|
|
452
447
|
|
|
453
|
-
const
|
|
454
|
-
const target = Obj.getURI(
|
|
448
|
+
const session = yield* AgentService.createSession();
|
|
449
|
+
const target = Obj.getURI(session.feed);
|
|
455
450
|
|
|
456
|
-
yield*
|
|
457
|
-
yield*
|
|
451
|
+
yield* session.submitPrompt('What is the capital of France? Reply with just the city name.');
|
|
452
|
+
yield* session.waitForCompletion();
|
|
458
453
|
|
|
459
454
|
// With no queued work, alarms, delegations, or undelivered tool results, the process calls
|
|
460
455
|
// `ctx.succeed()` (see `maybeComplete` / `isAgentWorkPending`) and reaches a terminal state
|
|
@@ -467,14 +462,14 @@ describe('Agent Service', () => {
|
|
|
467
462
|
|
|
468
463
|
// A follow-up turn does not reuse the succeeded process: `getSession` skips terminal handles
|
|
469
464
|
// and spawns a fresh one, which replays conversation history from the feed.
|
|
470
|
-
const followUp = yield* getSession(
|
|
465
|
+
const followUp = yield* getSession(session.feed);
|
|
471
466
|
yield* followUp.submitPrompt('What country did I just ask you about? Reply with just the country name.');
|
|
472
467
|
yield* followUp.waitForCompletion();
|
|
473
468
|
|
|
474
469
|
const processes = yield* processManager.list({ target, key: AGENT_PROCESS_KEY });
|
|
475
470
|
expect(processes.some((process) => String(process.pid) !== firstPid)).toBe(true);
|
|
476
471
|
|
|
477
|
-
const messages = yield* Feed.query(
|
|
472
|
+
const messages = yield* Feed.query(session.feed, Filter.type(Message.Message)).run;
|
|
478
473
|
const text = messages.map(Message.extractText).join('\n');
|
|
479
474
|
expect(text.toLocaleLowerCase()).toContain('france');
|
|
480
475
|
},
|
|
@@ -492,9 +487,9 @@ describe('Agent Service', () => {
|
|
|
492
487
|
function* (_) {
|
|
493
488
|
const processManager = yield* ProcessManager.ProcessManagerService;
|
|
494
489
|
|
|
495
|
-
//
|
|
496
|
-
const
|
|
497
|
-
const target = Obj.getURI(
|
|
490
|
+
// Spawns the agent process (no LLM turn yet) bound to a stamped host marker.
|
|
491
|
+
const session = yield* AgentService.createSession();
|
|
492
|
+
const target = Obj.getURI(session.feed);
|
|
498
493
|
const [handle] = yield* processManager.list({ target, key: AGENT_PROCESS_KEY });
|
|
499
494
|
|
|
500
495
|
// The spawn stamped the harness-host annotation so the process is discoverable as the owner.
|
|
@@ -521,3 +516,65 @@ describe('Agent Service', () => {
|
|
|
521
516
|
),
|
|
522
517
|
);
|
|
523
518
|
});
|
|
519
|
+
|
|
520
|
+
// Control-plane coverage (no LLM turn), so it runs ungated in CI unlike the replay suite above.
|
|
521
|
+
describe('Agent Service (control plane)', () => {
|
|
522
|
+
// Exercises the instruction-aware reuse identity on both paths — the session cache and the
|
|
523
|
+
// remount (rediscovered process) path.
|
|
524
|
+
it.scoped(
|
|
525
|
+
'session reuse tracks the steering-instructions ref',
|
|
526
|
+
Effect.fnUntraced(
|
|
527
|
+
function* (_) {
|
|
528
|
+
const processManager = yield* ProcessManager.ProcessManagerService;
|
|
529
|
+
|
|
530
|
+
const instructionsA = yield* Database.add(Instructions.make({ text: 'Steering A.' }));
|
|
531
|
+
const instructionsB = yield* Database.add(Instructions.make({ text: 'Steering B.' }));
|
|
532
|
+
const feed = yield* Database.add(Feed.make());
|
|
533
|
+
yield* Database.flush();
|
|
534
|
+
const target = Obj.getURI(feed);
|
|
535
|
+
|
|
536
|
+
const isActive = (state: Process.State) =>
|
|
537
|
+
state !== Process.State.SUCCEEDED && state !== Process.State.FAILED && state !== Process.State.TERMINATED;
|
|
538
|
+
const activePids = Effect.gen(function* () {
|
|
539
|
+
const processes = yield* processManager.list({ target, key: AGENT_PROCESS_KEY });
|
|
540
|
+
return processes.filter((process) => isActive(process.status.state));
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
// Spawn with A: the ref URI is stamped as a spawn annotation.
|
|
544
|
+
const sessionA = yield* getSession(feed, { instructions: Ref.make(instructionsA) });
|
|
545
|
+
const [handleA] = yield* activePids;
|
|
546
|
+
const pidA = String(handleA.pid);
|
|
547
|
+
expect(
|
|
548
|
+
Option.getOrNull(Annotation.getDictionary(handleA.params.annotations, Process.InstructionsAnnotation)),
|
|
549
|
+
).toBe(Ref.make(instructionsA).uri);
|
|
550
|
+
|
|
551
|
+
// Unchanged ref: the cached session (and process) is reused.
|
|
552
|
+
const sessionAgain = yield* getSession(feed, { instructions: Ref.make(instructionsA) });
|
|
553
|
+
expect(sessionAgain).toBe(sessionA);
|
|
554
|
+
expect((yield* activePids).map((handle) => String(handle.pid))).toEqual([pidA]);
|
|
555
|
+
|
|
556
|
+
// Repointed ref: the process is torn down and respawned with the new annotation.
|
|
557
|
+
yield* getSession(feed, { instructions: Ref.make(instructionsB) });
|
|
558
|
+
const [handleB] = yield* activePids;
|
|
559
|
+
expect(String(handleB.pid)).not.toBe(pidA);
|
|
560
|
+
expect(
|
|
561
|
+
Option.getOrNull(Annotation.getDictionary(handleB.params.annotations, Process.InstructionsAnnotation)),
|
|
562
|
+
).toBe(Ref.make(instructionsB).uri);
|
|
563
|
+
|
|
564
|
+
// Remount path (cache cleared by hydrate): present-to-absent also respawns, since the
|
|
565
|
+
// rediscovered process's spawn annotation no longer matches the request.
|
|
566
|
+
yield* hydrate();
|
|
567
|
+
yield* getSession(feed);
|
|
568
|
+
const [handleNone] = yield* activePids;
|
|
569
|
+
expect(String(handleNone.pid)).not.toBe(String(handleB.pid));
|
|
570
|
+
expect(
|
|
571
|
+
Option.getOrNull(Annotation.getDictionary(handleNone.params.annotations, Process.InstructionsAnnotation)),
|
|
572
|
+
).toBeNull();
|
|
573
|
+
|
|
574
|
+
yield* handleNone.terminate();
|
|
575
|
+
},
|
|
576
|
+
Effect.provide(TestLayer()),
|
|
577
|
+
TestHelpers.provideTestContext,
|
|
578
|
+
),
|
|
579
|
+
);
|
|
580
|
+
});
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import * as Cause from 'effect/Cause';
|
|
8
8
|
import * as Effect from 'effect/Effect';
|
|
9
9
|
import * as Layer from 'effect/Layer';
|
|
10
|
+
import * as Option from 'effect/Option';
|
|
10
11
|
|
|
11
12
|
import { AiContext } from '@dxos/assistant';
|
|
12
13
|
import { McpServer, Process, Skill } from '@dxos/compute';
|
|
@@ -22,6 +23,7 @@ import { Annotation, Database, Feed, Obj, Ref, Registry } from '@dxos/echo';
|
|
|
22
23
|
import { EffectEx } from '@dxos/effect';
|
|
23
24
|
import { DXN, EID } from '@dxos/keys';
|
|
24
25
|
import { log } from '@dxos/log';
|
|
26
|
+
import type { ContentBlock } from '@dxos/types';
|
|
25
27
|
|
|
26
28
|
import { AGENT_PROCESS_KEY, AgentProcess } from './agent-process';
|
|
27
29
|
import { type DelegationStrategy } from './delegation-strategy';
|
|
@@ -30,11 +32,12 @@ import { type DelegationStrategy } from './delegation-strategy';
|
|
|
30
32
|
type AgentRpcs = ReturnType<typeof AgentProcess> extends Process.Process<any, any, any, infer Rpcs> ? Rpcs : never;
|
|
31
33
|
|
|
32
34
|
/** Live handle to a spawned {@link AgentProcess}, carrying its `HarnessControl` RPC surface. */
|
|
33
|
-
type AgentHandle = ProcessManager.Handle<string, void, AgentRpcs>;
|
|
35
|
+
type AgentHandle = ProcessManager.Handle<string | ContentBlock.Any[], void, AgentRpcs>;
|
|
34
36
|
|
|
35
37
|
const isTerminalProcess = (state: Process.State): boolean =>
|
|
36
38
|
state === Process.State.SUCCEEDED || state === Process.State.FAILED || state === Process.State.TERMINATED;
|
|
37
39
|
|
|
40
|
+
// TODO(burdon): Agent identity?
|
|
38
41
|
export interface CreateSessionOptions {
|
|
39
42
|
readonly skills?: Skill.Skill[];
|
|
40
43
|
readonly context?: Ref.Ref<Obj.Unknown>[];
|
|
@@ -68,6 +71,7 @@ export const createSession: (
|
|
|
68
71
|
|
|
69
72
|
export interface AgentServiceOptions {
|
|
70
73
|
systemPrompt?: string;
|
|
74
|
+
|
|
71
75
|
/**
|
|
72
76
|
* Default model used by sessions that don't specify one explicitly.
|
|
73
77
|
*/
|
|
@@ -78,11 +82,6 @@ export interface AgentServiceOptions {
|
|
|
78
82
|
*/
|
|
79
83
|
provider?: DXN.DXN;
|
|
80
84
|
|
|
81
|
-
/**
|
|
82
|
-
* Provider for space-level MCP server configs.
|
|
83
|
-
*/
|
|
84
|
-
getMcpServers?: () => McpServer.McpServer[];
|
|
85
|
-
|
|
86
85
|
/**
|
|
87
86
|
* If true, long-running tool calls are moved to the background and the agent is notified
|
|
88
87
|
* asynchronously when they complete. Currently unstable — disabled by default.
|
|
@@ -96,6 +95,11 @@ export interface AgentServiceOptions {
|
|
|
96
95
|
* child processes and folds their results back into the conversation. Absent — a plain agent.
|
|
97
96
|
*/
|
|
98
97
|
delegationStrategy?: DelegationStrategy;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Provider for space-level MCP server configs.
|
|
101
|
+
*/
|
|
102
|
+
getMcpServers?: () => McpServer.McpServer[];
|
|
99
103
|
}
|
|
100
104
|
|
|
101
105
|
export const layer = (opts?: AgentServiceOptions): Layer.Layer<AgentService, never, ProcessManager.Service> =>
|
|
@@ -103,12 +107,18 @@ export const layer = (opts?: AgentServiceOptions): Layer.Layer<AgentService, nev
|
|
|
103
107
|
AgentService,
|
|
104
108
|
Effect.gen(function* () {
|
|
105
109
|
const processManager = yield* ProcessManager.Service;
|
|
106
|
-
// The agent's model
|
|
107
|
-
// each session was created with. Requesting a different model
|
|
108
|
-
// the old process and spawns a fresh one (see below).
|
|
110
|
+
// The agent's model and steering instructions are bound to its process at spawn time, so the
|
|
111
|
+
// cache tracks what each session was created with. Requesting a different model or a repointed
|
|
112
|
+
// instructions ref for the same feed tears down the old process and spawns a fresh one (see below).
|
|
109
113
|
const sessionCache = new Map<
|
|
110
114
|
string,
|
|
111
|
-
{
|
|
115
|
+
{
|
|
116
|
+
model: DXN.DXN | undefined;
|
|
117
|
+
provider: DXN.DXN | undefined;
|
|
118
|
+
instructions: string | undefined;
|
|
119
|
+
handle: AgentHandle;
|
|
120
|
+
session: Session;
|
|
121
|
+
}
|
|
112
122
|
>();
|
|
113
123
|
|
|
114
124
|
const makeExecutable = (model?: DXN.DXN, provider?: DXN.DXN) =>
|
|
@@ -144,20 +154,24 @@ export const layer = (opts?: AgentServiceOptions): Layer.Layer<AgentService, nev
|
|
|
144
154
|
Effect.gen(function* () {
|
|
145
155
|
const model = options?.model ?? opts?.model;
|
|
146
156
|
const provider = options?.provider ?? opts?.provider;
|
|
157
|
+
const instructions = options?.instructions?.uri;
|
|
147
158
|
const cached = sessionCache.get(feed.id);
|
|
148
159
|
if (cached) {
|
|
149
160
|
if (
|
|
150
161
|
cached.model === model &&
|
|
151
162
|
cached.provider === provider &&
|
|
163
|
+
cached.instructions === instructions &&
|
|
152
164
|
!isTerminalProcess(cached.handle.status.state)
|
|
153
165
|
) {
|
|
154
166
|
return cached.session;
|
|
155
167
|
}
|
|
156
168
|
|
|
157
169
|
if (!isTerminalProcess(cached.handle.status.state)) {
|
|
158
|
-
// Model or
|
|
170
|
+
// Model, provider, or steering instructions changed (e.g. the user toggled
|
|
171
|
+
// online/offline, or the chat's instructions ref was repointed): terminate the
|
|
159
172
|
// existing process so the conversation continues on a fresh process bound to the new
|
|
160
|
-
//
|
|
173
|
+
// configuration. Conversation history is preserved via the feed, which the new
|
|
174
|
+
// process replays.
|
|
161
175
|
yield* cached.handle.terminate();
|
|
162
176
|
}
|
|
163
177
|
sessionCache.delete(feed.id);
|
|
@@ -172,7 +186,19 @@ export const layer = (opts?: AgentServiceOptions): Layer.Layer<AgentService, nev
|
|
|
172
186
|
// (e.g. after the UI remounted). After a model change we always spawn a fresh process,
|
|
173
187
|
// since the process key does not encode the model.
|
|
174
188
|
const processes = yield* processManager.list({ target, key: executable.key });
|
|
175
|
-
|
|
189
|
+
let activeProcess = processes.find((process) => !isTerminalProcess(process.status.state));
|
|
190
|
+
|
|
191
|
+
// Spawn annotations are immutable, so a running process found via the remount path may
|
|
192
|
+
// carry stale instructions; terminate it and respawn with the requested ref.
|
|
193
|
+
if (activeProcess) {
|
|
194
|
+
const processInstructions = Option.getOrUndefined(
|
|
195
|
+
Annotation.getDictionary(activeProcess.params.annotations, Process.InstructionsAnnotation),
|
|
196
|
+
);
|
|
197
|
+
if (processInstructions !== instructions) {
|
|
198
|
+
yield* activeProcess.terminate();
|
|
199
|
+
activeProcess = undefined;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
176
202
|
|
|
177
203
|
let handle: AgentHandle;
|
|
178
204
|
if (activeProcess) {
|
|
@@ -186,6 +212,9 @@ export const layer = (opts?: AgentServiceOptions): Layer.Layer<AgentService, nev
|
|
|
186
212
|
// lookup (set once at spawn, immutable — the identity plane).
|
|
187
213
|
annotations: Annotation.buildDictionary((dictionary) => {
|
|
188
214
|
Annotation.setDictionary(dictionary, Process.HarnessHostAnnotation, true);
|
|
215
|
+
if (options?.instructions) {
|
|
216
|
+
Annotation.setDictionary(dictionary, Process.InstructionsAnnotation, options.instructions.uri);
|
|
217
|
+
}
|
|
189
218
|
}),
|
|
190
219
|
environment: {
|
|
191
220
|
...(spaceId !== undefined ? { space: spaceId } : {}),
|
|
@@ -201,7 +230,7 @@ export const layer = (opts?: AgentServiceOptions): Layer.Layer<AgentService, nev
|
|
|
201
230
|
sessionCache.delete(feed.id);
|
|
202
231
|
};
|
|
203
232
|
const session = makeSession(handle, feed, releaseSession);
|
|
204
|
-
sessionCache.set(feed.id, { model, provider, handle, session });
|
|
233
|
+
sessionCache.set(feed.id, { model, provider, instructions, handle, session });
|
|
205
234
|
return session;
|
|
206
235
|
}),
|
|
207
236
|
hydrate: hydrateAgents,
|
|
@@ -230,7 +259,7 @@ const makeSession = (process: AgentHandle, feed: Feed.Feed, releaseSession: () =
|
|
|
230
259
|
}),
|
|
231
260
|
);
|
|
232
261
|
}).pipe(Effect.scoped),
|
|
233
|
-
submitPrompt: (prompt: string) => process.submitInput(prompt),
|
|
262
|
+
submitPrompt: (prompt: string | ContentBlock.Any[]) => process.submitInput(prompt),
|
|
234
263
|
// Settle when the turn's reply is complete; do NOT block on background sub-agents
|
|
235
264
|
// (a supervisor delegates work that runs after the turn and reports back out of band).
|
|
236
265
|
waitForCompletion: () => process.runUntilSettled(),
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# Agent Service
|
|
2
2
|
|
|
3
|
-
`AgentService` spawns and caches a process-backed **agent** per conversation feed.
|
|
4
|
-
long-lived `AgentProcess` that handles user turns;
|
|
5
|
-
acts as a **supervisor** that delegates work
|
|
6
|
-
into the conversation.
|
|
3
|
+
`AgentService` spawns and caches a process-backed **agent** per conversation feed.
|
|
4
|
+
The agent is a long-lived `AgentProcess` that handles user turns;
|
|
5
|
+
when a `DelegationStrategy` is injected it also acts as a **supervisor** that delegates work
|
|
6
|
+
to linked child processes and folds their results back into the conversation.
|
|
7
7
|
|
|
8
8
|
## Layering
|
|
9
9
|
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
makeToolExecutionService,
|
|
26
26
|
makeToolResolverFromOperations,
|
|
27
27
|
} from '@dxos/assistant';
|
|
28
|
-
import { Credential, McpServer, Operation, Trace } from '@dxos/compute';
|
|
28
|
+
import { Credential, Instructions, McpServer, Operation, Trace } from '@dxos/compute';
|
|
29
29
|
import { Process } from '@dxos/compute';
|
|
30
30
|
import { ProcessManager } from '@dxos/compute-runtime';
|
|
31
31
|
import * as StorageService from '@dxos/compute/StorageService';
|
|
@@ -39,23 +39,23 @@ import { trim } from '@dxos/util';
|
|
|
39
39
|
import { type DelegationStrategy } from './delegation-strategy';
|
|
40
40
|
|
|
41
41
|
interface AgentProcessOptions {
|
|
42
|
+
// TODO(burdon): Instructions?
|
|
42
43
|
systemPrompt?: string;
|
|
44
|
+
|
|
45
|
+
/** Model identifier. */
|
|
43
46
|
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
47
|
|
|
48
48
|
/**
|
|
49
|
-
*
|
|
49
|
+
* The catalog's shared model ids are served by several providers, so resolution needs the provider
|
|
50
|
+
* alongside the id; without it a local model id cannot be claimed by any resolver.
|
|
50
51
|
*/
|
|
51
|
-
|
|
52
|
+
provider?: DXN.DXN;
|
|
52
53
|
|
|
53
54
|
/**
|
|
54
55
|
* If true, long-running tool calls are moved to the background after `backgroundThreshold`
|
|
55
56
|
* and the agent is notified asynchronously when they complete.
|
|
56
57
|
*
|
|
57
58
|
* Currently unstable — disabled by default.
|
|
58
|
-
*
|
|
59
59
|
* @default false
|
|
60
60
|
*/
|
|
61
61
|
enableToolBackgrounding?: boolean;
|
|
@@ -66,6 +66,11 @@ interface AgentProcessOptions {
|
|
|
66
66
|
* (the default) the process behaves as a plain conversational agent.
|
|
67
67
|
*/
|
|
68
68
|
delegationStrategy?: DelegationStrategy;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Provider for space-level MCP server configs, called on each turn.
|
|
72
|
+
*/
|
|
73
|
+
getMcpServers?: () => McpServer.McpServer[];
|
|
69
74
|
}
|
|
70
75
|
|
|
71
76
|
export const AGENT_PROCESS_KEY = 'org.dxos.testing.process.agent';
|
|
@@ -78,7 +83,8 @@ export const AgentProcess = (options: AgentProcessOptions) =>
|
|
|
78
83
|
Process.make(
|
|
79
84
|
{
|
|
80
85
|
key: AGENT_PROCESS_KEY,
|
|
81
|
-
|
|
86
|
+
// String member keeps queue entries persisted before the block-array widening decodable.
|
|
87
|
+
input: Schema.Union(Schema.String, Schema.Array(ContentBlock.Any)),
|
|
82
88
|
output: Schema.Void,
|
|
83
89
|
services: [
|
|
84
90
|
Database.Service,
|
|
@@ -102,8 +108,20 @@ export const AgentProcess = (options: AgentProcessOptions) =>
|
|
|
102
108
|
return yield* Effect.die(new Error('Agent executable requires spawn options.target set to a queue DXN.'));
|
|
103
109
|
}
|
|
104
110
|
const feed = yield* Database.resolve(feedDxn, Feed.Feed).pipe(Effect.orDie);
|
|
111
|
+
// Steering instructions travel as a spawn annotation (the session is feed-centric and cannot
|
|
112
|
+
// reach its Chat); a broken ref degrades to an unsteered session rather than failing the process.
|
|
113
|
+
const instructionsDxn = Annotation.getDictionary(ctx.params.annotations, Process.InstructionsAnnotation).pipe(
|
|
114
|
+
Option.getOrUndefined,
|
|
115
|
+
);
|
|
116
|
+
const instructions = instructionsDxn
|
|
117
|
+
? yield* Database.resolve(instructionsDxn, Instructions.Instructions).pipe(
|
|
118
|
+
Effect.orElseSucceed(() => undefined),
|
|
119
|
+
)
|
|
120
|
+
: undefined;
|
|
105
121
|
const runtime = yield* Effect.runtime<Database.Service>();
|
|
106
|
-
const session = yield* EffectEx.acquireReleaseResource(
|
|
122
|
+
const session = yield* EffectEx.acquireReleaseResource(
|
|
123
|
+
() => new AiSession.Session({ feed, runtime, instructions: instructions ? [instructions] : [] }),
|
|
124
|
+
);
|
|
107
125
|
let inputQueue: AgentEvent[] = [...(yield* AgentEventsCell.get)];
|
|
108
126
|
const storageService = yield* StorageService.StorageService;
|
|
109
127
|
const toolCallManager = new ToolCallManager(storageService);
|
|
@@ -195,9 +213,10 @@ export const AgentProcess = (options: AgentProcessOptions) =>
|
|
|
195
213
|
alarmManager.reconcile(true);
|
|
196
214
|
}),
|
|
197
215
|
}),
|
|
198
|
-
onInput: Effect.fnUntraced(function* (prompt: string) {
|
|
199
|
-
log('agent onInput received', {
|
|
200
|
-
|
|
216
|
+
onInput: Effect.fnUntraced(function* (prompt: string | readonly ContentBlock.Any[]) {
|
|
217
|
+
log('agent onInput received', { backlog: inputQueue.length });
|
|
218
|
+
const content = typeof prompt === 'string' ? [ContentBlock.Text.make({ text: prompt })] : [...prompt];
|
|
219
|
+
inputQueue.push({ _tag: 'prompt', content });
|
|
201
220
|
log('agent onInput persisting queue', { depth: inputQueue.length });
|
|
202
221
|
yield* AgentEventsCell.set(inputQueue);
|
|
203
222
|
log('agent onInput persisted', { depth: inputQueue.length });
|