@dxos/agent-runtime 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/LICENSE +105 -0
  2. package/dist/lib/neutral/chunk-EZKG2ZGG.mjs +754 -0
  3. package/dist/lib/neutral/chunk-EZKG2ZGG.mjs.map +7 -0
  4. package/dist/lib/neutral/index.mjs +9 -0
  5. package/dist/lib/neutral/index.mjs.map +7 -0
  6. package/dist/lib/neutral/meta.json +1 -0
  7. package/dist/lib/neutral/testing/index.mjs +285 -0
  8. package/dist/lib/neutral/testing/index.mjs.map +7 -0
  9. package/dist/types/src/agent-service/AgentService.d.ts +45 -0
  10. package/dist/types/src/agent-service/AgentService.d.ts.map +1 -0
  11. package/dist/types/src/agent-service/AgentService.test.d.ts +2 -0
  12. package/dist/types/src/agent-service/AgentService.test.d.ts.map +1 -0
  13. package/dist/types/src/agent-service/agent-process.d.ts +457 -0
  14. package/dist/types/src/agent-service/agent-process.d.ts.map +1 -0
  15. package/dist/types/src/agent-service/agent-process.test.d.ts +2 -0
  16. package/dist/types/src/agent-service/agent-process.test.d.ts.map +1 -0
  17. package/dist/types/src/agent-service/delegation-strategy.d.ts +47 -0
  18. package/dist/types/src/agent-service/delegation-strategy.d.ts.map +1 -0
  19. package/dist/types/src/agent-service/index.d.ts +4 -0
  20. package/dist/types/src/agent-service/index.d.ts.map +1 -0
  21. package/dist/types/src/assistant-session-tests/format.test.d.ts +2 -0
  22. package/dist/types/src/assistant-session-tests/format.test.d.ts.map +1 -0
  23. package/dist/types/src/assistant-session-tests/request.test.d.ts +2 -0
  24. package/dist/types/src/assistant-session-tests/request.test.d.ts.map +1 -0
  25. package/dist/types/src/assistant-session-tests/session-ollama.test.d.ts +2 -0
  26. package/dist/types/src/assistant-session-tests/session-ollama.test.d.ts.map +1 -0
  27. package/dist/types/src/assistant-session-tests/session.test.d.ts +2 -0
  28. package/dist/types/src/assistant-session-tests/session.test.d.ts.map +1 -0
  29. package/dist/types/src/assistant-session-tests/xml-response.test.d.ts +2 -0
  30. package/dist/types/src/assistant-session-tests/xml-response.test.d.ts.map +1 -0
  31. package/dist/types/src/functions.test.d.ts +2 -0
  32. package/dist/types/src/functions.test.d.ts.map +1 -0
  33. package/dist/types/src/index.d.ts +2 -0
  34. package/dist/types/src/index.d.ts.map +1 -0
  35. package/dist/types/src/testing/ResearchService.d.ts +28 -0
  36. package/dist/types/src/testing/ResearchService.d.ts.map +1 -0
  37. package/dist/types/src/testing/assistant-test-layer.d.ts +70 -0
  38. package/dist/types/src/testing/assistant-test-layer.d.ts.map +1 -0
  39. package/dist/types/src/testing/index.d.ts +4 -0
  40. package/dist/types/src/testing/index.d.ts.map +1 -0
  41. package/dist/types/src/testing/trace-pretty-print.d.ts +16 -0
  42. package/dist/types/src/testing/trace-pretty-print.d.ts.map +1 -0
  43. package/dist/types/tsconfig.tsbuildinfo +1 -0
  44. package/package.json +70 -0
  45. package/src/agent-service/AgentService.conversations.json +1 -0
  46. package/src/agent-service/AgentService.test.ts +523 -0
  47. package/src/agent-service/AgentService.ts +239 -0
  48. package/src/agent-service/README.md +91 -0
  49. package/src/agent-service/agent-process.test.ts +206 -0
  50. package/src/agent-service/agent-process.ts +793 -0
  51. package/src/agent-service/delegation-strategy.ts +63 -0
  52. package/src/agent-service/index.ts +7 -0
  53. package/src/assistant-session-tests/format.test.ts +131 -0
  54. package/src/assistant-session-tests/request.conversations.json +1 -0
  55. package/src/assistant-session-tests/request.test.ts +190 -0
  56. package/src/assistant-session-tests/session-ollama.test.ts +80 -0
  57. package/src/assistant-session-tests/session.test.ts +56 -0
  58. package/src/assistant-session-tests/xml-response.conversations.json +1 -0
  59. package/src/assistant-session-tests/xml-response.test.ts +65 -0
  60. package/src/functions.conversations.json +1 -0
  61. package/src/functions.test.ts +87 -0
  62. package/src/index.ts +7 -0
  63. package/src/testing/ResearchService.ts +144 -0
  64. package/src/testing/assistant-test-layer.ts +299 -0
  65. package/src/testing/index.ts +7 -0
  66. package/src/testing/trace-pretty-print.ts +55 -0
@@ -0,0 +1 @@
1
+ {"conversations":[{"parameters":{"model":"dxn:com.anthropic.model.claude-opus-4-8.default","stream":true,"tools":[{"name":"read-name","description":"Reads the name of an organization.","inputSchema":{"type":"object","required":["org"],"properties":{"org":{"type":"string","description":"The URI of the referenced object. Protocols accepted:\n- echo://01KG7R1ZXWFMWQ4DA1Q6TN1DG4. \n- space ID, object ID tuple (spaceID:objectID)\n- Only object ID that is assumed to be in the current space (01KG7R1ZXWFMWQ4DA1Q6TN1DG4)\n- dxn:org.dxos.type.calendar:0.1.0 -- named entity from the registry. Absoutely make sure it actually exists in the registry.\n\nURIs can be prepended with an @ symbol for compatibility with in-text references.","examples":["echo://01KG7R1ZXWFMWQ4DA1Q6TN1DG4","@echo://01KG7R1ZXWFMWQ4DA1Q6TN1DG4","BM3FSHFOMJCHCG5QW7JTVKGYABD2GAA7G:01KG7R1ZXWFMWQ4DA1Q6TN1DG4","01KG7R1ZXWFMWQ4DA1Q6TN1DG4","dxn:org.dxos.type.calendar:0.1.0","@dxn:org.dxos.type.calendar:0.1.0"]}},"additionalProperties":false}}]},"prompt":{"content":[{"role":"user","content":"What is the name of the organization? 01JGFJJZ00G0WKQSJGMAKCNT8A","options":{"anthropic":{"cacheControl":{"ttl":"5m","type":"ephemeral"}}}}]},"response":[{"type":"response-metadata","id":"msg_01QqqhNhmhVZUUYXFYx9XMtH","modelId":"claude-opus-4-8","timestamp":"1970-01-01T00:00:00.000Z","metadata":{}},{"type":"text-start","id":"0","metadata":{}},{"type":"text-delta","id":"0","delta":"I","metadata":{}},{"type":"text-delta","id":"0","delta":"'ll look up the name of that organization for you.","metadata":{}},{"type":"text-end","id":"0","metadata":{}},{"type":"tool-params-start","id":"toolu_01SSt4imayWykNwg78gTArrk","name":"read-name","providerExecuted":false,"metadata":{}},{"type":"tool-params-delta","id":"toolu_01SSt4imayWykNwg78gTArrk","delta":"","metadata":{}},{"type":"tool-params-delta","id":"toolu_01SSt4imayWykNwg78gTArrk","delta":"{\"o","metadata":{}},{"type":"tool-params-delta","id":"toolu_01SSt4imayWykNwg78gTArrk","delta":"rg\": \"01J","metadata":{}},{"type":"tool-params-delta","id":"toolu_01SSt4imayWykNwg78gTArrk","delta":"GFJJZ00G","metadata":{}},{"type":"tool-params-delta","id":"toolu_01SSt4imayWykNwg78gTArrk","delta":"0WKQSJGMA","metadata":{}},{"type":"tool-params-delta","id":"toolu_01SSt4imayWykNwg78gTArrk","delta":"KCNT8A\"}","metadata":{}},{"type":"tool-params-end","id":"toolu_01SSt4imayWykNwg78gTArrk","metadata":{}},{"type":"finish","reason":"tool-calls","usage":{"inputTokens":838,"outputTokens":86,"totalTokens":924},"metadata":{"anthropic":{"usage":{"cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":838,"output_tokens":1,"server_tool_use":null,"service_tier":"standard"}}}}]},{"parameters":{"model":"dxn:com.anthropic.model.claude-opus-4-8.default","stream":true,"tools":[{"name":"read-name","description":"Reads the name of an organization.","inputSchema":{"type":"object","required":["org"],"properties":{"org":{"type":"string","description":"The URI of the referenced object. Protocols accepted:\n- echo://01KG7R1ZXWFMWQ4DA1Q6TN1DG4. \n- space ID, object ID tuple (spaceID:objectID)\n- Only object ID that is assumed to be in the current space (01KG7R1ZXWFMWQ4DA1Q6TN1DG4)\n- dxn:org.dxos.type.calendar:0.1.0 -- named entity from the registry. Absoutely make sure it actually exists in the registry.\n\nURIs can be prepended with an @ symbol for compatibility with in-text references.","examples":["echo://01KG7R1ZXWFMWQ4DA1Q6TN1DG4","@echo://01KG7R1ZXWFMWQ4DA1Q6TN1DG4","BM3FSHFOMJCHCG5QW7JTVKGYABD2GAA7G:01KG7R1ZXWFMWQ4DA1Q6TN1DG4","01KG7R1ZXWFMWQ4DA1Q6TN1DG4","dxn:org.dxos.type.calendar:0.1.0","@dxn:org.dxos.type.calendar:0.1.0"]}},"additionalProperties":false}}]},"prompt":{"content":[{"role":"user","content":"What is the name of the organization? 01JGFJJZ00G0WKQSJGMAKCNT8A","options":{}},{"role":"assistant","content":[{"type":"text","text":"I'll look up the name of that organization for you.","options":{}},{"type":"tool-call","id":"toolu_01SSt4imayWykNwg78gTArrk","name":"read-name","params":{"org":"01JGFJJZ00G0WKQSJGMAKCNT8A"},"providerExecuted":false,"options":{}}],"options":{}},{"role":"tool","content":[{"type":"tool-result","id":"toolu_01SSt4imayWykNwg78gTArrk","name":"read-name","isFailure":false,"result":"BlueYard","providerExecuted":false,"options":{}}],"options":{"anthropic":{"cacheControl":{"ttl":"5m","type":"ephemeral"}}}}]},"response":[{"type":"response-metadata","id":"msg_01GzzfDTuyW585TBj6Yzz4Lv","modelId":"claude-opus-4-8","timestamp":"1970-01-01T00:00:00.000Z","metadata":{}},{"type":"text-start","id":"0","metadata":{}},{"type":"text-delta","id":"0","delta":"The","metadata":{}},{"type":"text-delta","id":"0","delta":" name of the organization is **BlueYard**.","metadata":{}},{"type":"text-end","id":"0","metadata":{}},{"type":"finish","reason":"stop","usage":{"inputTokens":936,"outputTokens":17,"totalTokens":953},"metadata":{"anthropic":{"usage":{"cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":936,"output_tokens":1,"server_tool_use":null,"service_tier":"standard"}}}}]}]}
@@ -0,0 +1,87 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { describe, it } from '@effect/vitest';
6
+ import * as Effect from 'effect/Effect';
7
+ import * as Layer from 'effect/Layer';
8
+ import * as Schema from 'effect/Schema';
9
+
10
+ import { ConsolePrinter } from '@dxos/ai';
11
+ import { MemoizedAiService } from '@dxos/ai/testing';
12
+ import { AiRequest, GenerationObserver, ToolExecutionServices, createToolkit } from '@dxos/assistant';
13
+ import { Operation, OperationHandlerSet, Skill } from '@dxos/compute';
14
+ import { Database, Obj, Ref } from '@dxos/echo';
15
+ import { TestHelpers } from '@dxos/effect/testing';
16
+ import { DXN, EntityId } from '@dxos/keys';
17
+ import { Organization } from '@dxos/types';
18
+
19
+ import { AssistantTestLayer } from './testing';
20
+
21
+ EntityId.dangerouslyDisableRandomness();
22
+
23
+ const ReadName = Operation.make({
24
+ meta: {
25
+ key: DXN.make('org.dxos.function.readName'),
26
+ name: 'Read Name',
27
+ description: 'Reads the name of an organization.',
28
+ },
29
+ input: Schema.Struct({
30
+ org: Ref.Ref(Organization.Organization),
31
+ }),
32
+ output: Schema.String,
33
+ services: [Database.Service],
34
+ });
35
+
36
+ const Handlers = OperationHandlerSet.make(
37
+ Operation.withHandler(
38
+ ReadName,
39
+ Effect.fn(function* ({ org }) {
40
+ const resolved = yield* Database.load(org);
41
+ return resolved.name ?? '<no org>';
42
+ }),
43
+ ),
44
+ );
45
+
46
+ const skill = Skill.make({
47
+ key: 'org.dxos.skill.test',
48
+ name: 'Test skill',
49
+ tools: Skill.toolDefinitions({ operations: [ReadName] }),
50
+ });
51
+
52
+ const TestLayer = Layer.empty.pipe(
53
+ Layer.provideMerge(ToolExecutionServices),
54
+ Layer.provideMerge(
55
+ AssistantTestLayer({
56
+ aiServicePreset: 'edge-remote',
57
+ operationHandlers: Handlers,
58
+ types: [Organization.Organization],
59
+ }),
60
+ ),
61
+ );
62
+
63
+ describe('Research', () => {
64
+ it.effect(
65
+ 'call a function with a ref input',
66
+ Effect.fnUntraced(
67
+ function* (_) {
68
+ const org = yield* Database.add(
69
+ Obj.make(Organization.Organization, {
70
+ name: 'BlueYard',
71
+ website: 'https://blueyard.com',
72
+ }),
73
+ );
74
+ yield* Database.flush();
75
+ yield* new AiRequest.Request({ observer: GenerationObserver.fromPrinter(new ConsolePrinter()) }).run({
76
+ prompt: `What is the name of the organization? ${org.id}`,
77
+ toolkit: yield* createToolkit({
78
+ skills: [skill],
79
+ }),
80
+ });
81
+ },
82
+ Effect.provide(TestLayer),
83
+ TestHelpers.provideTestContext,
84
+ ),
85
+ MemoizedAiService.isGenerationEnabled() ? 240_000 : 30_000,
86
+ );
87
+ });
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ // Re-exports the agent-service barrel: `AgentService` namespace (API from @dxos/compute +
6
+ // createSession/layer), `AGENT_PROCESS_KEY`, and the delegation types.
7
+ export * from './agent-service';
@@ -0,0 +1,144 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ // @import-as-namespace
6
+
7
+ import * as Context from 'effect/Context';
8
+ import * as Deferred from 'effect/Deferred';
9
+ import * as Effect from 'effect/Effect';
10
+ import * as Layer from 'effect/Layer';
11
+ import * as Queue from 'effect/Queue';
12
+
13
+ import { log } from '@dxos/log';
14
+ import { Organization } from '@dxos/types';
15
+
16
+ //
17
+ // Test data.
18
+ //
19
+
20
+ export const getTestData = () => ({
21
+ organizations: [
22
+ Organization.make({
23
+ name: 'Cyberdyne Systems',
24
+ website: 'https://cyberdyne.com',
25
+ }),
26
+ Organization.make({
27
+ name: 'Acme Robotics',
28
+ website: 'https://acmerobotics.example',
29
+ }),
30
+ Organization.make({
31
+ name: 'Globex Research',
32
+ website: 'https://globex.example',
33
+ }),
34
+ ],
35
+ research: {
36
+ 'https://cyberdyne.com': `
37
+ Cyberdyne Systems is a company that builds AI agents.
38
+ They are based in San Francisco, California.
39
+ They were founded in 1984.
40
+ They are a public company.
41
+ They are listed on the NASDAQ under the symbol CYBR.
42
+ They are a member of the S&P 500 index.
43
+ `,
44
+ 'https://acmerobotics.example': `
45
+ Acme Robotics designs industrial automation and collaborative robots.
46
+ They are headquartered in Austin, Texas.
47
+ They were founded in 2010.
48
+ They serve manufacturing and logistics customers worldwide.
49
+ `,
50
+ 'https://globex.example': `
51
+ Globex Research runs applied R&D labs focused on materials science and energy storage.
52
+ They are based in Cambridge, Massachusetts.
53
+ They partner with universities and government grants programs.
54
+ Their flagship product line is solid-state battery prototypes for EVs.
55
+ `,
56
+ } as Record<string, string>,
57
+ });
58
+
59
+ export interface Task {
60
+ id: string;
61
+ state: 'pending' | 'completed' | 'interrupted';
62
+ website: string;
63
+ deferred: Deferred.Deferred<string>;
64
+ }
65
+
66
+ export interface Service {
67
+ getTasks: () => readonly Task[];
68
+ research: (website: string) => Effect.Effect<string>;
69
+ waitForTaskToAppear: () => Effect.Effect<void>;
70
+ completeOneTask: () => Effect.Effect<void>;
71
+ completeAllTasks: () => Effect.Effect<void>;
72
+ }
73
+
74
+ export class ResearchService extends Context.Tag('@dxos/functions-runtime/testing/ResearchService')<
75
+ ResearchService,
76
+ Service
77
+ >() {}
78
+
79
+ export const layer = Layer.effect(
80
+ ResearchService,
81
+ Effect.gen(function* () {
82
+ const taskSignal = yield* Queue.unbounded<void>();
83
+ const tasks: Task[] = [];
84
+ const complete = (task: Task) =>
85
+ Effect.gen(function* () {
86
+ log.info('complete research', { id: task.id, website: task.website });
87
+ task.state = 'completed';
88
+ const result = getTestData().research[task.website];
89
+ if (!result) {
90
+ yield* Effect.die(new Error(`No research found for ${task.website}`));
91
+ return;
92
+ }
93
+ yield* Deferred.succeed(task.deferred, result);
94
+ });
95
+
96
+ return ResearchService.of({
97
+ getTasks: () => tasks,
98
+ research: (website: string) =>
99
+ Effect.gen(function* () {
100
+ const id = crypto.randomUUID();
101
+ const task: Task = { id, state: 'pending', website, deferred: yield* Deferred.make<string>() };
102
+ log.info('start research', { id, website });
103
+ tasks.push(task);
104
+ yield* Queue.offer(taskSignal, undefined);
105
+ return yield* Deferred.await(task.deferred).pipe(
106
+ Effect.onInterrupt(() =>
107
+ Effect.sync(() => {
108
+ log.info('interrupt research', { id, website: task.website });
109
+ task.state = 'interrupted';
110
+ }),
111
+ ),
112
+ );
113
+ }),
114
+ waitForTaskToAppear: () =>
115
+ Effect.gen(function* () {
116
+ while (true) {
117
+ const task = tasks.find((t) => t.state === 'pending');
118
+ if (task) {
119
+ return;
120
+ }
121
+ yield* Queue.take(taskSignal);
122
+ }
123
+ }),
124
+ completeOneTask: () =>
125
+ Effect.gen(function* () {
126
+ const task = tasks.find((t) => t.state === 'pending');
127
+ if (!task) {
128
+ return;
129
+ }
130
+ yield* complete(task);
131
+ }),
132
+ completeAllTasks: () =>
133
+ Effect.gen(function* () {
134
+ while (true) {
135
+ const task = tasks.find((t) => t.state === 'pending');
136
+ if (!task) {
137
+ return;
138
+ }
139
+ yield* complete(task);
140
+ }
141
+ }),
142
+ });
143
+ }),
144
+ );
@@ -0,0 +1,299 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { Registry as AtomRegistry } from '@effect-atom/atom';
6
+ import * as LanguageModel from '@effect/ai/LanguageModel';
7
+ import * as KeyValueStore from '@effect/platform/KeyValueStore';
8
+ import * as Array from 'effect/Array';
9
+ import * as Context from 'effect/Context';
10
+ import * as Effect from 'effect/Effect';
11
+ import * as Layer from 'effect/Layer';
12
+ import * as Match from 'effect/Match';
13
+
14
+ import { AiService, OpaqueToolkit, Provider } from '@dxos/ai';
15
+ import { TestAiService } from '@dxos/ai/testing';
16
+ import { Harness } from '@dxos/assistant';
17
+ import {
18
+ AgentService,
19
+ Credential,
20
+ Instructions,
21
+ Operation,
22
+ OperationHandlerSet,
23
+ Process,
24
+ ServiceNotAvailableError,
25
+ ServiceResolver,
26
+ Skill,
27
+ Trace,
28
+ Trigger,
29
+ } from '@dxos/compute';
30
+ import {
31
+ FeedTraceSink,
32
+ ProcessManager,
33
+ ProcessMonitor,
34
+ RemoteProcessManager,
35
+ TriggerDispatcher,
36
+ TriggerStateStore,
37
+ configuredCredentialsLayer,
38
+ } from '@dxos/compute-runtime';
39
+ import { TestDatabaseLayer } from '@dxos/compute-runtime/testing';
40
+ import { Database, Feed, Registry, Tag, Type } from '@dxos/echo';
41
+ import { registryLayer } from '@dxos/echo-client';
42
+ import { type TestContextService } from '@dxos/effect/testing';
43
+ import { DXN } from '@dxos/keys';
44
+
45
+ import { AgentService as AgentServiceRuntime } from '../agent-service';
46
+ import { traceSinkPrettyLayer } from './trace-pretty-print';
47
+
48
+ interface TestLayerOptions {
49
+ aiServicePreset?: 'direct' | 'edge-local' | 'edge-remote' | 'ollama';
50
+
51
+ /**
52
+ * Overrides the AI service entirely (e.g. a scripted model for deterministic e2e tests).
53
+ * When set, `aiServicePreset` and `disableLlmMemoization` are ignored.
54
+ */
55
+ aiService?: Layer.Layer<AiService.AiService>;
56
+ model?: DXN.DXN;
57
+ /** Provider the model resolves through; defaults to `ollama` for the ollama preset, else `edge`. */
58
+ provider?: DXN.DXN;
59
+ operationHandlers?: OperationHandlerSet.OperationHandlerSet | OperationHandlerSet.OperationHandlerSet[];
60
+ toolkits?: OpaqueToolkit.OpaqueToolkit[];
61
+ types?: Type.AnyEntity[];
62
+ skills?: Skill.Skill[];
63
+ credentials?: Credential.ServiceCredential[];
64
+
65
+ /**
66
+ * Tracing configuration.
67
+ * - `'feed'` persists trace events to a FeedTraceSink (queryable from the database).
68
+ * @default 'noop'
69
+ */
70
+ tracing?: 'noop' | 'console' | 'pretty' | 'feed';
71
+
72
+ disableLlmMemoization?: boolean;
73
+
74
+ /**
75
+ * Patterns matching run-specific values (e.g. timestamps derived from TestClock) that should be
76
+ * canonicalized for memoized-conversation matching. Forwarded to `TestAiService`. Opt-in.
77
+ */
78
+ dynamicValuePatterns?: readonly RegExp[];
79
+
80
+ /**
81
+ * Options for the agent process (system prompt, tool backgrounding, delegation strategy, etc.).
82
+ * The model defaults to the resolved test-layer model when not set here.
83
+ */
84
+ agent?: AgentServiceRuntime.AgentServiceOptions;
85
+
86
+ /**
87
+ * Extra services to make available in the service resolver.
88
+ * Operations can depend on those services.
89
+ */
90
+ extraServices?: Layer.Layer<never, never, never>;
91
+ }
92
+
93
+ export type AssistantTestServices =
94
+ | LanguageModel.LanguageModel
95
+ | Credential.CredentialsService
96
+ | AgentService.AgentService
97
+ | AiService.AiService
98
+ | Database.Service
99
+ | Registry.Service
100
+ | OpaqueToolkit.OpaqueToolkitProvider
101
+ | Operation.Service
102
+ | ProcessManager.Service
103
+ | Process.ProcessMonitorService
104
+ | AtomRegistry.AtomRegistry
105
+ | OperationHandlerSet.OperationHandlerProvider
106
+ | KeyValueStore.KeyValueStore
107
+ | ServiceResolver.ServiceResolver
108
+ | Trace.TraceService
109
+ | Trace.TraceSink
110
+ | FeedTraceSink.FeedTraceSink;
111
+
112
+ export const AssistantTestLayer = (
113
+ options: TestLayerOptions = {},
114
+ ): Layer.Layer<AssistantTestServices, never, TestContextService> => {
115
+ const resolvedModel: DXN.DXN =
116
+ options.model ??
117
+ (options.aiServicePreset === 'ollama'
118
+ ? DXN.make('com.openai.model.gpt-oss-20b.default')
119
+ : DXN.make('com.anthropic.model.claude-opus-4-8.default'));
120
+
121
+ // The catalog's shared model ids need a provider to resolve; pair the resolved model with the
122
+ // provider its preset registers a resolver for.
123
+ const resolvedProvider: DXN.DXN =
124
+ options.provider ?? (options.aiServicePreset === 'ollama' ? Provider.ollama.id : Provider.edge.id);
125
+
126
+ const agentOptions: AgentServiceRuntime.AgentServiceOptions = { ...options.agent };
127
+ agentOptions.model ??= resolvedModel;
128
+ agentOptions.provider ??= resolvedProvider;
129
+
130
+ // The resolver materialises `HarnessService` (Tier B needs `ProcessManager.Service`), but
131
+ // `ProcessManager.layer` requires the `ServiceResolver` — a construction cycle. Resolve it by
132
+ // sharing a late-bound holder: the resolver reads the manager lazily (only at resolution time,
133
+ // when the manager exists), and a downstream layer fills the holder once it is built.
134
+ const processManagerHolder: ProcessManagerHolder = {};
135
+
136
+ return Layer.empty.pipe(
137
+ Layer.provideMerge(ProcessManager.ProcessOperationInvoker.layer),
138
+ Layer.provideMerge(ProcessMonitor.layer),
139
+ Layer.provideMerge(RemoteProcessManager.layerNoop),
140
+ Layer.provideMerge(AgentServiceRuntime.layer(agentOptions)),
141
+ Layer.provideMerge(Trace.testTraceService({ meta: { processName: 'test' } })),
142
+ // Order matters: in a `provideMerge` chain each layer's *requirements* are satisfied only by
143
+ // layers added later (whose outputs feed it). `captureProcessManager` needs the manager, the
144
+ // manager needs the `ServiceResolver`, and the resolver needs nothing (it reads the manager via
145
+ // the holder), so they must appear in exactly this order.
146
+ Layer.provideMerge(captureProcessManager(processManagerHolder)),
147
+ Layer.provideMerge(ProcessManager.layer({ idGenerator: ProcessManager.SequentialIdGenerator })),
148
+ Layer.provideMerge(AssistantTestServiceResolverLayer(options, processManagerHolder)),
149
+ Layer.provideMerge(AiService.model(DXN.getName(resolvedModel), { provider: resolvedProvider })),
150
+ Layer.provideMerge(AssistantTestTracingLayer(options.tracing ?? 'noop')),
151
+ Layer.provideMerge(
152
+ options.aiService ??
153
+ TestAiService({
154
+ preset: options.aiServicePreset,
155
+ disableMemoization: options.disableLlmMemoization,
156
+ dynamicValuePatterns: options.dynamicValuePatterns,
157
+ }),
158
+ ),
159
+ Layer.provideMerge(AssistantTestBaseLayer(options)),
160
+ Layer.orDie,
161
+ );
162
+ };
163
+
164
+ /** Late-bound reference to the {@link ProcessManager.Service}, filled once the manager is built. */
165
+ interface ProcessManagerHolder {
166
+ current?: Context.Tag.Service<ProcessManager.Service>;
167
+ }
168
+
169
+ /** Fills the {@link ProcessManagerHolder} once the manager is available, breaking the build cycle. */
170
+ const captureProcessManager = (holder: ProcessManagerHolder): Layer.Layer<never, never, ProcessManager.Service> =>
171
+ Layer.effectDiscard(
172
+ Effect.gen(function* () {
173
+ holder.current = yield* ProcessManager.Service;
174
+ }),
175
+ );
176
+
177
+ /**
178
+ * Service resolver for testing.
179
+ */
180
+ export const AssistantTestServiceResolverLayer = (
181
+ { extraServices = Layer.empty }: Pick<TestLayerOptions, 'extraServices'>,
182
+ processManagerHolder: ProcessManagerHolder,
183
+ ) =>
184
+ Layer.scoped(
185
+ ServiceResolver.ServiceResolver,
186
+ Effect.gen(function* () {
187
+ const services = yield* Effect.context<Database.Service>().pipe(
188
+ Effect.map(Context.pick(Database.Service)),
189
+ Effect.map(Layer.succeedContext),
190
+ );
191
+
192
+ const extraServicesRt = yield* Layer.toRuntime(extraServices);
193
+
194
+ return ServiceResolver.compose(
195
+ ServiceResolver.succeed(Harness.HarnessService, (context) =>
196
+ Effect.gen(function* () {
197
+ if (!context.conversation) {
198
+ return yield* Effect.fail(new ServiceNotAvailableError(Harness.HarnessService.key));
199
+ }
200
+ // Read the manager lazily: the resolver is invoked at spawn time, by which point the
201
+ // holder has been filled (see the construction-cycle note in `AssistantTestLayer`).
202
+ const processManager = processManagerHolder.current;
203
+ if (!processManager) {
204
+ return yield* Effect.fail(new ServiceNotAvailableError(ProcessManager.Service.key));
205
+ }
206
+ const runtime = yield* Effect.runtime<Database.Service>();
207
+ return yield* Harness.make({ conversation: context.conversation, processManager, runtime });
208
+ }).pipe(Effect.provide(services)),
209
+ ),
210
+ yield* ServiceResolver.fromRequirements(
211
+ Database.Service,
212
+ OpaqueToolkit.OpaqueToolkitProvider,
213
+ AiService.AiService,
214
+ Registry.Service,
215
+ Credential.CredentialsService,
216
+ ),
217
+ ServiceResolver.fromContext(extraServicesRt.context),
218
+ );
219
+ }),
220
+ );
221
+
222
+ /**
223
+ * Only storage + registry.
224
+ */
225
+ export const AssistantTestBaseLayer = ({
226
+ operationHandlers = [],
227
+ toolkits = [],
228
+ types = [],
229
+ credentials = [],
230
+ skills = [],
231
+ }: Pick<TestLayerOptions, 'operationHandlers' | 'toolkits' | 'types' | 'skills' | 'tracing' | 'credentials'>) => {
232
+ const toolkit = OpaqueToolkit.merge(...toolkits);
233
+ const operationHandlersSet = Array.isArray(operationHandlers)
234
+ ? OperationHandlerSet.merge(...operationHandlers)
235
+ : operationHandlers;
236
+ types.push(
237
+ Skill.Skill,
238
+ Instructions.Instructions,
239
+ Operation.PersistentOperation,
240
+ Feed.Feed,
241
+ Trigger.Trigger,
242
+ Tag.Tag,
243
+ );
244
+ types = Array.dedupeWith(types, (a, b) => Type.getTypename(a) === Type.getTypename(b));
245
+
246
+ return Layer.empty.pipe(
247
+ Layer.provideMerge(
248
+ TestDatabaseLayer({
249
+ spaceKey: 'fixed',
250
+ types,
251
+ }),
252
+ ),
253
+ Layer.provideMerge(configuredCredentialsLayer(credentials)),
254
+ Layer.provideMerge(
255
+ Layer.effect(
256
+ Registry.Service,
257
+ Effect.gen(function* () {
258
+ const handlerSet = yield* OperationHandlerSet.OperationHandlerProvider;
259
+ const registry = yield* Registry.Service;
260
+ const handlers = yield* handlerSet.handlers;
261
+ registry.add(handlers.map(Operation.serialize));
262
+ return registry;
263
+ }),
264
+ ),
265
+ ),
266
+ Layer.provideMerge(registryLayer({ initial: skills })),
267
+ Layer.provideMerge(OpaqueToolkit.providerLayer(toolkit)),
268
+ Layer.provideMerge(OperationHandlerSet.provide(operationHandlersSet)),
269
+ Layer.provideMerge(KeyValueStore.layerMemory),
270
+ Layer.provideMerge(AtomRegistry.layer),
271
+ Layer.orDie,
272
+ );
273
+ };
274
+
275
+ const AssistantTestTracingLayer = (
276
+ mode: 'noop' | 'console' | 'pretty' | 'feed',
277
+ ): Layer.Layer<FeedTraceSink.FeedTraceSink | Trace.TraceSink, never, Database.Service> =>
278
+ Match.value(mode).pipe(
279
+ Match.when('noop', () => Layer.mergeAll(Trace.layerNoop, FeedTraceSink.layerNoop)),
280
+ Match.when('console', () => Layer.mergeAll(Trace.layerConsole, FeedTraceSink.layerNoop)),
281
+ Match.when('pretty', () => Layer.mergeAll(traceSinkPrettyLayer(), FeedTraceSink.layerNoop)),
282
+ Match.when('feed', () => FeedTraceSink.layerLiveWithDirectSink),
283
+ Match.exhaustive,
284
+ );
285
+
286
+ interface TestLayerWithTriggersOptions extends TestLayerOptions {}
287
+
288
+ export type AssistantTestServicesWithTriggers = AssistantTestServices | TriggerDispatcher | TriggerStateStore;
289
+
290
+ export const AssistantTestLayerWithTriggers = (
291
+ options: TestLayerWithTriggersOptions,
292
+ ): Layer.Layer<AssistantTestServicesWithTriggers, never, TestContextService> =>
293
+ Layer.mergeAll(
294
+ AssistantTestLayer(options),
295
+ TriggerDispatcher.layer({ timeControl: 'manual', startingTime: new Date('2025-09-05T15:01:00.000Z') }).pipe(
296
+ Layer.provide(AtomRegistry.layer),
297
+ ),
298
+ TriggerStateStore.layerMemory,
299
+ ) as any;
@@ -0,0 +1,7 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * from './assistant-test-layer';
6
+ export * from './trace-pretty-print';
7
+ export * as ResearchService from './ResearchService';
@@ -0,0 +1,55 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Effect from 'effect/Effect';
6
+ import * as Layer from 'effect/Layer';
7
+
8
+ import { ConsolePrinter } from '@dxos/ai';
9
+ import { CompleteBlock } from '@dxos/assistant';
10
+ import { Process, Trace } from '@dxos/compute';
11
+ import { FeedTraceSink } from '@dxos/compute-runtime';
12
+ import { Database, Filter, Query } from '@dxos/echo';
13
+
14
+ /**
15
+ * Pretty-prints assistant trace messages to stdout (CompleteBlock, process spawn/exit).
16
+ */
17
+ export const prettyPrintTraceMessage = (message: Trace.Message): void => {
18
+ for (const event of message.events) {
19
+ if (Trace.isOfType(CompleteBlock, event)) {
20
+ const tag = message.meta.processName ?? `${message.meta.pid ?? 'unknown'}`;
21
+ console.log(`[${tag}] ${event.data.role.toUpperCase()}`);
22
+ new ConsolePrinter({ tag }).printContentBlock(event.data.block);
23
+ } else if (Trace.isOfType(Process.SpawnedEvent, event)) {
24
+ console.log(`[${message.meta.pid}] Process spawned: ${message.meta.processName}`);
25
+ } else if (Trace.isOfType(Process.ExitedEvent, event)) {
26
+ console.log(`[${message.meta.pid}] Process exited: ${event.data.outcome}`);
27
+ }
28
+ }
29
+ };
30
+
31
+ /** {@link Trace.TraceSink} layer that mirrors {@link AssistantTestLayer}'s `tracing: 'pretty'` mode. */
32
+ export const traceSinkPrettyLayer = (): Layer.Layer<Trace.TraceSink> =>
33
+ Layer.succeed(Trace.TraceSink, {
34
+ write: prettyPrintTraceMessage,
35
+ });
36
+
37
+ /**
38
+ * Subscribes to the space trace feed and pretty-prints newly appended messages.
39
+ * Returns an unsubscribe function. Requires ambient {@link Database.Service}.
40
+ */
41
+ export const traceFeedPrettyPrintSubscription = Effect.gen(function* () {
42
+ const feed = yield* FeedTraceSink.getOrCreateTraceFeed();
43
+ const queryResult = yield* Database.query(Query.select(Filter.type(Trace.Message)).from(feed));
44
+ const seen = new Set(queryResult.runSync().map((message) => message.id));
45
+
46
+ return queryResult.subscribe((result) => {
47
+ for (const message of result.results) {
48
+ if (seen.has(message.id)) {
49
+ continue;
50
+ }
51
+ seen.add(message.id);
52
+ prettyPrintTraceMessage(message);
53
+ }
54
+ });
55
+ });