@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.
Files changed (46) hide show
  1. package/dist/lib/chunk-AgentService.mjs +789 -0
  2. package/dist/lib/chunk-AgentService.mjs.map +1 -0
  3. package/dist/lib/index.mjs +2 -0
  4. package/dist/lib/testing.mjs +294 -0
  5. package/dist/lib/testing.mjs.map +1 -0
  6. package/dist/types/src/agent-service/AgentService.d.ts +4 -4
  7. package/dist/types/src/agent-service/AgentService.d.ts.map +1 -1
  8. package/dist/types/src/agent-service/agent-process.d.ts +173 -5
  9. package/dist/types/src/agent-service/agent-process.d.ts.map +1 -1
  10. package/dist/types/src/agent-service/delegation-scripted.test.d.ts +2 -0
  11. package/dist/types/src/agent-service/delegation-scripted.test.d.ts.map +1 -0
  12. package/dist/types/src/assistant-session-tests/scripted-loop.test.d.ts +2 -0
  13. package/dist/types/src/assistant-session-tests/scripted-loop.test.d.ts.map +1 -0
  14. package/dist/types/src/index.d.ts.map +1 -1
  15. package/dist/types/src/testing/assistant-test-layer.d.ts +6 -2
  16. package/dist/types/src/testing/assistant-test-layer.d.ts.map +1 -1
  17. package/dist/types/src/testing/index.d.ts +2 -0
  18. package/dist/types/src/testing/index.d.ts.map +1 -1
  19. package/dist/types/src/testing/test-agent.d.ts +70 -0
  20. package/dist/types/src/testing/test-agent.d.ts.map +1 -0
  21. package/dist/types/tsconfig.tsbuildinfo +1 -1
  22. package/package.json +17 -16
  23. package/src/agent-service/AgentService.conversations.json +1 -1
  24. package/src/agent-service/AgentService.test.ts +115 -58
  25. package/src/agent-service/AgentService.ts +44 -15
  26. package/src/agent-service/README.md +4 -4
  27. package/src/agent-service/agent-process.ts +31 -12
  28. package/src/agent-service/delegation-scripted.test.ts +123 -0
  29. package/src/assistant-session-tests/request.conversations.json +1 -1
  30. package/src/assistant-session-tests/request.test.ts +2 -2
  31. package/src/assistant-session-tests/scripted-loop.test.ts +119 -0
  32. package/src/assistant-session-tests/xml-response.conversations.json +1 -1
  33. package/src/assistant-session-tests/xml-response.test.ts +2 -2
  34. package/src/functions.conversations.json +1 -1
  35. package/src/functions.test.ts +2 -2
  36. package/src/index.ts +0 -2
  37. package/src/testing/assistant-test-layer.ts +32 -1
  38. package/src/testing/index.ts +3 -0
  39. package/src/testing/test-agent.ts +104 -0
  40. package/dist/lib/neutral/chunk-EZKG2ZGG.mjs +0 -754
  41. package/dist/lib/neutral/chunk-EZKG2ZGG.mjs.map +0 -7
  42. package/dist/lib/neutral/index.mjs +0 -9
  43. package/dist/lib/neutral/index.mjs.map +0 -7
  44. package/dist/lib/neutral/meta.json +0 -1
  45. package/dist/lib/neutral/testing/index.mjs +0 -285
  46. 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 agent = yield* AgentService.createSession();
153
- yield* agent.submitPrompt('What is the capital of France?');
154
- yield* agent.waitForCompletion();
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(agent.feed, Filter.type(Message.Message)).run;
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 agent = yield* AgentService.createSession({
171
- skills: [ResearchSkill],
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* agent.waitForCompletion();
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 agent = yield* AgentService.createSession({
191
- skills: [ResearchSkill],
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* agent.terminate();
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 agent = yield* AgentService.createSession({
210
- skills: [ResearchSkill],
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
- agent = yield* getSession(agent.feed);
228
- yield* agent.waitForCompletion();
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 agent = yield* AgentService.createSession({
241
- skills: [ResearchSkill],
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
- agent = yield* getSession(agent.feed);
259
- yield* agent.waitForCompletion();
251
+ session = yield* getSession(session.feed);
252
+ yield* session.waitForCompletion();
260
253
 
261
- const messages = yield* Feed.query(agent.feed, Filter.type(Message.Message)).run;
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 agent = yield* AgentService.createSession();
276
- yield* agent.submitPrompt('What is the capital of France? Reply with just the city name.');
277
- yield* agent.waitForCompletion();
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
- agent = yield* getSession(agent.feed);
288
- yield* agent.submitPrompt('What country did I just ask you about? Reply with just the country name.');
289
- yield* agent.waitForCompletion();
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(agent.feed, Filter.type(Message.Message)).run;
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 agent = yield* AgentService.createSession({
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* agent.subscribeEphemeral().pipe(
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* agent.submitPrompt(JSON.stringify(org));
338
+ yield* session.submitPrompt(JSON.stringify(org));
348
339
  }
349
- yield* agent.submitPrompt('When all research is complete, print 1-sentence summary for each organization.');
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* agent.waitForCompletion();
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 agent = yield* AgentService.createSession();
373
- yield* agent.submitPrompt('What is the capital of France?');
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* agent.waitForCompletion();
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 agent = yield* AgentService.createSession();
454
- const target = Obj.getURI(agent.feed);
448
+ const session = yield* AgentService.createSession();
449
+ const target = Obj.getURI(session.feed);
455
450
 
456
- yield* agent.submitPrompt('What is the capital of France? Reply with just the city name.');
457
- yield* agent.waitForCompletion();
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(agent.feed);
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(agent.feed, Filter.type(Message.Message)).run;
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
- // createSession spawns the agent process (no LLM turn yet) bound to a stamped host marker.
496
- const agent = yield* AgentService.createSession();
497
- const target = Obj.getURI(agent.feed);
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 is bound to its process at spawn time, so the cache tracks the model
107
- // each session was created with. Requesting a different model for the same feed tears down
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
- { model: DXN.DXN | undefined; provider: DXN.DXN | undefined; handle: AgentHandle; session: Session }
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 provider changed (e.g. the user toggled online/offline): terminate the
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
- // model. Conversation history is preserved via the feed, which the new process replays.
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
- const activeProcess = processes.find((process) => !isTerminalProcess(process.status.state));
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. The agent is a
4
- long-lived `AgentProcess` that handles user turns; when a `DelegationStrategy` is injected it also
5
- acts as a **supervisor** that delegates work to linked child processes and folds their results back
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
- * Provider for space-level MCP server configs, called on each turn.
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
- getMcpServers?: () => McpServer.McpServer[];
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
- input: Schema.String,
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(() => new AiSession.Session({ feed, runtime }));
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', { promptLength: prompt.length, backlog: inputQueue.length });
200
- inputQueue.push({ _tag: 'prompt', content: [ContentBlock.Text.make({ text: prompt })] });
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 });