@dxos/assistant 0.8.4-main.bc674ce → 0.8.4-main.bcb3aa67d6
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/README.md +1 -0
- package/dist/lib/neutral/chunk-KVYJFFLF.mjs +1805 -0
- package/dist/lib/neutral/chunk-KVYJFFLF.mjs.map +7 -0
- package/dist/lib/{browser → neutral}/extraction/index.mjs +26 -32
- package/dist/lib/neutral/extraction/index.mjs.map +7 -0
- package/dist/lib/{browser → neutral}/index.mjs +72 -371
- package/dist/lib/neutral/index.mjs.map +7 -0
- package/dist/lib/neutral/meta.json +1 -0
- package/dist/lib/neutral/testing/index.mjs +112 -0
- package/dist/lib/neutral/testing/index.mjs.map +7 -0
- package/dist/types/src/conversation/context.d.ts +24 -5
- package/dist/types/src/conversation/context.d.ts.map +1 -1
- package/dist/types/src/conversation/conversation.d.ts +29 -4
- package/dist/types/src/conversation/conversation.d.ts.map +1 -1
- package/dist/types/src/diff.d.ts +3 -1
- package/dist/types/src/diff.d.ts.map +1 -1
- package/dist/types/src/extraction/extraction-ner-function/index.d.ts +0 -1
- package/dist/types/src/extraction/extraction-ner-function/index.d.ts.map +1 -1
- package/dist/types/src/extraction/extraction.d.ts +729 -17
- package/dist/types/src/extraction/extraction.d.ts.map +1 -1
- package/dist/types/src/extraction/index.d.ts +0 -1
- package/dist/types/src/extraction/index.d.ts.map +1 -1
- package/dist/types/src/functions/services.d.ts +27 -8
- package/dist/types/src/functions/services.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +2 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/service/AgentService.d.ts +62 -0
- package/dist/types/src/service/AgentService.d.ts.map +1 -0
- package/dist/types/src/service/AgentService.test.d.ts +2 -0
- package/dist/types/src/service/AgentService.test.d.ts.map +1 -0
- package/dist/types/src/service/agent-process.d.ts +17 -0
- package/dist/types/src/service/agent-process.d.ts.map +1 -0
- package/dist/types/src/service/index.d.ts +3 -0
- package/dist/types/src/service/index.d.ts.map +1 -0
- package/dist/types/src/session/index.d.ts +1 -1
- package/dist/types/src/session/index.d.ts.map +1 -1
- package/dist/types/src/session/observer.d.ts.map +1 -1
- package/dist/types/src/session/session.d.ts +64 -8
- package/dist/types/src/session/session.d.ts.map +1 -1
- package/dist/types/src/session/toolkit.d.ts +6 -2
- package/dist/types/src/session/toolkit.d.ts.map +1 -1
- package/dist/types/src/templates/system.d.ts +0 -1
- package/dist/types/src/templates/system.d.ts.map +1 -1
- package/dist/types/src/testing/layer.d.ts +23 -9
- package/dist/types/src/testing/layer.d.ts.map +1 -1
- package/dist/types/src/tracing.d.ts +264 -0
- package/dist/types/src/tracing.d.ts.map +1 -0
- package/dist/types/src/types/artifact.d.ts +5 -5
- package/dist/types/src/types/artifact.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +33 -33
- package/src/conversation/context.test.ts +1 -1
- package/src/conversation/context.ts +84 -18
- package/src/conversation/conversation.ts +173 -32
- package/src/diff.test.ts +36 -5
- package/src/diff.ts +13 -4
- package/src/execution-graph.test.ts +2 -2
- package/src/execution-graph.ts +2 -2
- package/src/extraction/extraction-ner-function/index.ts +0 -1
- package/src/extraction/extraction.ts +38 -10
- package/src/extraction/index.ts +0 -1
- package/src/functions/services.ts +199 -50
- package/src/functions.conversations.json +1 -1
- package/src/functions.test.ts +28 -20
- package/src/index.ts +2 -0
- package/src/service/AgentService.conversations.json +1 -0
- package/src/service/AgentService.test.ts +169 -0
- package/src/service/AgentService.ts +171 -0
- package/src/service/agent-process.ts +360 -0
- package/src/service/index.ts +6 -0
- package/src/session/format.test.ts +23 -14
- package/src/session/index.ts +1 -2
- package/src/session/observer.ts +3 -2
- package/src/session/session.conversations.json +1 -1
- package/src/session/session.test.ts +52 -4
- package/src/session/session.ts +231 -117
- package/src/session/toolkit.ts +12 -7
- package/src/templates/instructions/format.tpl +0 -4
- package/src/templates/instructions/system.tpl +11 -2
- package/src/templates/system.ts +6 -5
- package/src/testing/layer.ts +209 -24
- package/src/tracing.ts +112 -0
- package/src/types/artifact.ts +17 -10
- package/dist/lib/browser/chunk-7CDDG6LL.mjs +0 -578
- package/dist/lib/browser/chunk-7CDDG6LL.mjs.map +0 -7
- package/dist/lib/browser/extraction/index.mjs.map +0 -7
- package/dist/lib/browser/index.mjs.map +0 -7
- package/dist/lib/browser/meta.json +0 -1
- package/dist/lib/browser/testing/index.mjs +0 -32
- package/dist/lib/browser/testing/index.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-CC7ER6Q3.mjs +0 -579
- package/dist/lib/node-esm/chunk-CC7ER6Q3.mjs.map +0 -7
- package/dist/lib/node-esm/chunk-HSLMI22Q.mjs +0 -11
- package/dist/lib/node-esm/chunk-HSLMI22Q.mjs.map +0 -7
- package/dist/lib/node-esm/extraction/index.mjs +0 -226
- package/dist/lib/node-esm/extraction/index.mjs.map +0 -7
- package/dist/lib/node-esm/index.mjs +0 -851
- package/dist/lib/node-esm/index.mjs.map +0 -7
- package/dist/lib/node-esm/meta.json +0 -1
- package/dist/lib/node-esm/testing/index.mjs +0 -33
- package/dist/lib/node-esm/testing/index.mjs.map +0 -7
- package/dist/types/src/extraction/extraction-llm-function/extraction-llm-function.d.ts +0 -4
- package/dist/types/src/extraction/extraction-llm-function/extraction-llm-function.d.ts.map +0 -1
- package/dist/types/src/extraction/extraction-llm-function/extraction-llm-function.test.d.ts +0 -2
- package/dist/types/src/extraction/extraction-llm-function/extraction-llm-function.test.d.ts.map +0 -1
- package/dist/types/src/extraction/extraction-llm-function/index.d.ts +0 -2
- package/dist/types/src/extraction/extraction-llm-function/index.d.ts.map +0 -1
- package/dist/types/src/extraction/extraction-ner-function/extraction-ner-function.d.ts +0 -347
- package/dist/types/src/extraction/extraction-ner-function/extraction-ner-function.d.ts.map +0 -1
- package/dist/types/src/extraction/extraction-ner-function/extraction-ner-function.test.d.ts +0 -2
- package/dist/types/src/extraction/extraction-ner-function/extraction-ner-function.test.d.ts.map +0 -1
- package/dist/types/src/session/GenericToolkit.d.ts +0 -85
- package/dist/types/src/session/GenericToolkit.d.ts.map +0 -1
- package/src/extraction/extraction-llm-function/extraction-llm-function.test.ts +0 -84
- package/src/extraction/extraction-llm-function/extraction-llm-function.ts +0 -62
- package/src/extraction/extraction-llm-function/index.ts +0 -5
- package/src/extraction/extraction-ner-function/extraction-ner-function.test.ts +0 -116
- package/src/extraction/extraction-ner-function/extraction-ner-function.ts +0 -56
- package/src/session/GenericToolkit.ts +0 -131
- /package/dist/lib/{browser → neutral}/chunk-J5LGTIGS.mjs +0 -0
- /package/dist/lib/{browser → neutral}/chunk-J5LGTIGS.mjs.map +0 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { Registry } from '@effect-atom/atom';
|
|
6
|
+
import { describe, it } from '@effect/vitest';
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
import * as Schema from 'effect/Schema';
|
|
9
|
+
import { Blueprint } from '@dxos/blueprints';
|
|
10
|
+
|
|
11
|
+
import { Feed } from '@dxos/echo';
|
|
12
|
+
import { TestHelpers } from '@dxos/effect/testing';
|
|
13
|
+
import { Process } from '@dxos/functions-runtime';
|
|
14
|
+
import { Organization } from '@dxos/types';
|
|
15
|
+
import { trim } from '@dxos/util';
|
|
16
|
+
import { Operation, OperationHandlerSet } from '@dxos/operation';
|
|
17
|
+
|
|
18
|
+
import { AssistantTestLayer } from '../testing';
|
|
19
|
+
import { log } from '@dxos/log';
|
|
20
|
+
import * as AgentService from './AgentService';
|
|
21
|
+
import { ObjectId } from '@dxos/keys';
|
|
22
|
+
import * as Stream from 'effect/Stream';
|
|
23
|
+
import { Trace } from '@dxos/functions';
|
|
24
|
+
import { PartialBlock } from '../tracing';
|
|
25
|
+
import { expect } from 'vitest';
|
|
26
|
+
|
|
27
|
+
ObjectId.dangerouslyDisableRandomness();
|
|
28
|
+
|
|
29
|
+
//
|
|
30
|
+
// Test data.
|
|
31
|
+
//
|
|
32
|
+
|
|
33
|
+
const TEST_DATA = {
|
|
34
|
+
organizations: [
|
|
35
|
+
Organization.make({
|
|
36
|
+
name: 'Cyberdyne Systems',
|
|
37
|
+
website: 'https://cyberdyne.com',
|
|
38
|
+
}),
|
|
39
|
+
Organization.make({
|
|
40
|
+
name: 'Acme Robotics',
|
|
41
|
+
website: 'https://acmerobotics.example',
|
|
42
|
+
}),
|
|
43
|
+
Organization.make({
|
|
44
|
+
name: 'Globex Research',
|
|
45
|
+
website: 'https://globex.example',
|
|
46
|
+
}),
|
|
47
|
+
],
|
|
48
|
+
research: {
|
|
49
|
+
'https://cyberdyne.com': `
|
|
50
|
+
Cyberdyne Systems is a company that builds AI agents.
|
|
51
|
+
They are based in San Francisco, California.
|
|
52
|
+
They were founded in 1984.
|
|
53
|
+
They are a public company.
|
|
54
|
+
They are listed on the NASDAQ under the symbol CYBR.
|
|
55
|
+
They are a member of the S&P 500 index.
|
|
56
|
+
`,
|
|
57
|
+
'https://acmerobotics.example': `
|
|
58
|
+
Acme Robotics designs industrial automation and collaborative robots.
|
|
59
|
+
They are headquartered in Austin, Texas.
|
|
60
|
+
They were founded in 2010.
|
|
61
|
+
They serve manufacturing and logistics customers worldwide.
|
|
62
|
+
`,
|
|
63
|
+
'https://globex.example': `
|
|
64
|
+
Globex Research runs applied R&D labs focused on materials science and energy storage.
|
|
65
|
+
They are based in Cambridge, Massachusetts.
|
|
66
|
+
They partner with universities and government grants programs.
|
|
67
|
+
Their flagship product line is solid-state battery prototypes for EVs.
|
|
68
|
+
`,
|
|
69
|
+
} as Record<string, string>,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const Research = Operation.make({
|
|
73
|
+
meta: {
|
|
74
|
+
key: 'org.dxos.function.research',
|
|
75
|
+
name: 'Research',
|
|
76
|
+
description: 'Research an organization',
|
|
77
|
+
},
|
|
78
|
+
input: Schema.Struct({
|
|
79
|
+
website: Schema.String.annotations({ description: 'The website of the organization to research' }),
|
|
80
|
+
}),
|
|
81
|
+
output: Schema.String,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const handlers = OperationHandlerSet.make(
|
|
85
|
+
Research.pipe(
|
|
86
|
+
Operation.withHandler(
|
|
87
|
+
Effect.fnUntraced(function* ({ website }) {
|
|
88
|
+
log.info('begin research', { website });
|
|
89
|
+
yield* Effect.sleep(
|
|
90
|
+
15_000 * (TEST_DATA.organizations.findIndex((organization) => organization.website === website) + 1),
|
|
91
|
+
);
|
|
92
|
+
log.info('end research', { website });
|
|
93
|
+
return TEST_DATA.research[website];
|
|
94
|
+
}),
|
|
95
|
+
),
|
|
96
|
+
),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const ResearchBlueprint = Blueprint.make({
|
|
100
|
+
key: 'org.dxos.blueprint.research',
|
|
101
|
+
name: 'Research',
|
|
102
|
+
tools: Blueprint.toolDefinitions({ operations: [Research] }),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
//
|
|
106
|
+
// Test layer: AI services + toolkit.
|
|
107
|
+
//
|
|
108
|
+
|
|
109
|
+
const SYSTEM_PROMPT = trim`
|
|
110
|
+
You are reacting to a continuous stream of organizations.
|
|
111
|
+
Research each organization.
|
|
112
|
+
Research takes a while to complete.
|
|
113
|
+
Do not block on the research since there might be more organizations to research, and we want research to happen in parallel.
|
|
114
|
+
Do not wait longer then 3 seconds to keep the conversation open for new events or user queries.
|
|
115
|
+
Do internal reasoning, but only show the answers to the user.
|
|
116
|
+
`;
|
|
117
|
+
|
|
118
|
+
const TestLayer = AssistantTestLayer({
|
|
119
|
+
types: [Organization.Organization, Feed.Feed, Blueprint.Blueprint],
|
|
120
|
+
tracing: 'console',
|
|
121
|
+
aiServicePreset: 'edge-remote',
|
|
122
|
+
operationHandlers: [handlers],
|
|
123
|
+
blueprints: [ResearchBlueprint],
|
|
124
|
+
systemPrompt: SYSTEM_PROMPT,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('Agent Executable', () => {
|
|
128
|
+
// TODO(dmaretskyi): Figure out how to make it not sleep for 45 seconds.
|
|
129
|
+
it.scoped.skip(
|
|
130
|
+
'runs AI agent with background tools via process manager',
|
|
131
|
+
Effect.fnUntraced(
|
|
132
|
+
function* (_) {
|
|
133
|
+
const registry = yield* Registry.AtomRegistry;
|
|
134
|
+
const monitor = yield* Process.ProcessMonitorService;
|
|
135
|
+
registry.subscribe(monitor.processTreeAtom, (tree) => {
|
|
136
|
+
console.log(`\n----- Process tree -----\n${Process.prettyProcessTree(tree)}\n-----------------\n`);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const agent = yield* AgentService.createSession({
|
|
140
|
+
blueprints: [ResearchBlueprint],
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
let ephemeralEventCount = 0;
|
|
144
|
+
yield* agent.subscribeEphemeral().pipe(
|
|
145
|
+
Stream.runForEach((msg) =>
|
|
146
|
+
Effect.gen(function* () {
|
|
147
|
+
for (const event of msg.events) {
|
|
148
|
+
if (Trace.isOfType(PartialBlock, event)) {
|
|
149
|
+
ephemeralEventCount++;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}),
|
|
153
|
+
),
|
|
154
|
+
Effect.fork,
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
for (const org of TEST_DATA.organizations) {
|
|
158
|
+
yield* agent.submitPrompt(JSON.stringify(org));
|
|
159
|
+
}
|
|
160
|
+
yield* agent.submitPrompt('When all research is complete, print 1-sentence summary for each organization.');
|
|
161
|
+
yield* agent.waitForCompletion();
|
|
162
|
+
expect(ephemeralEventCount).toBeGreaterThan(0);
|
|
163
|
+
},
|
|
164
|
+
Effect.provide(TestLayer),
|
|
165
|
+
TestHelpers.provideTestContext,
|
|
166
|
+
),
|
|
167
|
+
{ timeout: 120_000 },
|
|
168
|
+
);
|
|
169
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
// @import-as-namespace
|
|
6
|
+
|
|
7
|
+
import { Database, Feed, Obj } from '@dxos/echo';
|
|
8
|
+
import * as Context from 'effect/Context';
|
|
9
|
+
import * as Effect from 'effect/Effect';
|
|
10
|
+
import * as Layer from 'effect/Layer';
|
|
11
|
+
import * as Stream from 'effect/Stream';
|
|
12
|
+
|
|
13
|
+
import { Blueprint } from '@dxos/blueprints';
|
|
14
|
+
import { ProcessManager } from '@dxos/functions-runtime';
|
|
15
|
+
import { failedInvariant } from '@dxos/invariant';
|
|
16
|
+
import { AgentProcess } from './agent-process';
|
|
17
|
+
|
|
18
|
+
import { Ref } from '@dxos/echo';
|
|
19
|
+
import { QueueService, type Trace } from '@dxos/functions';
|
|
20
|
+
import { type Message } from '@dxos/types';
|
|
21
|
+
|
|
22
|
+
import { acquireReleaseResource } from '@dxos/effect';
|
|
23
|
+
import { AiContextBinder, ContextBinding } from '../conversation';
|
|
24
|
+
|
|
25
|
+
export interface Service {
|
|
26
|
+
/**
|
|
27
|
+
* Gets or creates a session for a feed.
|
|
28
|
+
*/
|
|
29
|
+
getSession: (feed: Feed.Feed) => Effect.Effect<Session>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class AgentService extends Context.Tag('@dxos/assistant/AgentService')<AgentService, Service>() {}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Handle to an agent session.
|
|
36
|
+
*/
|
|
37
|
+
export interface Session {
|
|
38
|
+
/**
|
|
39
|
+
* The feed that the session is associated with.
|
|
40
|
+
*/
|
|
41
|
+
readonly feed: Feed.Feed;
|
|
42
|
+
/**
|
|
43
|
+
* Submits a prompt to the agent.
|
|
44
|
+
*/
|
|
45
|
+
submitPrompt: (prompt: string) => Effect.Effect<void>;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Wait until agent has completed its work.
|
|
49
|
+
*/
|
|
50
|
+
waitForCompletion: () => Effect.Effect<void>;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Subscribe to ephemeral trace events (e.g. streaming partial messages).
|
|
54
|
+
* Replays buffered events, then streams new ones until the process ends.
|
|
55
|
+
*/
|
|
56
|
+
subscribeEphemeral: () => Stream.Stream<Trace.Message>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Adds context objects to the agent.
|
|
60
|
+
*/
|
|
61
|
+
addContext: (context: Ref.Ref<Obj.Unknown>[]) => Effect.Effect<void, never, QueueService>;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Gets the context objects from the agent.
|
|
65
|
+
*/
|
|
66
|
+
getContext: () => Effect.Effect<Ref.Ref<Obj.Unknown>[], never, QueueService>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Gets or creates a session for a feed.
|
|
71
|
+
*/
|
|
72
|
+
export const getSession = Effect.serviceFunctionEffect(AgentService, (service) => service.getSession);
|
|
73
|
+
|
|
74
|
+
export interface CreateSessionOptions {
|
|
75
|
+
readonly blueprints?: Blueprint.Blueprint[];
|
|
76
|
+
readonly context?: Ref.Ref<Obj.Unknown>[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const createSession: (
|
|
80
|
+
opts?: CreateSessionOptions,
|
|
81
|
+
) => Effect.Effect<
|
|
82
|
+
Session,
|
|
83
|
+
Blueprint.NotFoundError,
|
|
84
|
+
Database.Service | QueueService | Blueprint.RegistryService | AgentService
|
|
85
|
+
> = Effect.fn('createSession')(function* (opts) {
|
|
86
|
+
const blueprints = yield* Effect.forEach(opts?.blueprints ?? [], (blueprint) =>
|
|
87
|
+
Blueprint.upsert(blueprint.key).pipe(Effect.map(Ref.make)),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const feed = yield* Database.add(Feed.make());
|
|
91
|
+
const queueDxn = Feed.getQueueDxn(feed) ?? failedInvariant();
|
|
92
|
+
const queue = yield* QueueService.getQueue<Message.Message | ContextBinding>(queueDxn);
|
|
93
|
+
const binder = yield* acquireReleaseResource(() => new AiContextBinder({ queue }));
|
|
94
|
+
|
|
95
|
+
yield* Effect.promise(() =>
|
|
96
|
+
binder.bind({
|
|
97
|
+
blueprints,
|
|
98
|
+
objects: opts?.context ?? [],
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
return yield* getSession(feed);
|
|
103
|
+
}, Effect.scoped);
|
|
104
|
+
|
|
105
|
+
export const layer = (opts?: {
|
|
106
|
+
systemPrompt?: string;
|
|
107
|
+
}): Layer.Layer<AgentService, never, ProcessManager.ProcessManagerService> =>
|
|
108
|
+
Layer.effect(
|
|
109
|
+
AgentService,
|
|
110
|
+
Effect.gen(function* () {
|
|
111
|
+
const processManager = yield* ProcessManager.ProcessManagerService;
|
|
112
|
+
const sessionCache = new Map<string, Session>();
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
getSession: (feed: Feed.Feed) =>
|
|
116
|
+
Effect.gen(function* () {
|
|
117
|
+
const cached = sessionCache.get(feed.id);
|
|
118
|
+
if (cached) {
|
|
119
|
+
return cached;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const target = Obj.getDXN(feed).toString();
|
|
123
|
+
const executable = AgentProcess({ systemPrompt: opts?.systemPrompt });
|
|
124
|
+
const processes = yield* processManager.list({ target, key: executable.key });
|
|
125
|
+
|
|
126
|
+
let handle: ProcessManager.Handle<string, void>;
|
|
127
|
+
if (processes.length > 0) {
|
|
128
|
+
handle = processes[0];
|
|
129
|
+
} else {
|
|
130
|
+
handle = yield* processManager.spawn(executable, {
|
|
131
|
+
name: 'agent',
|
|
132
|
+
target,
|
|
133
|
+
traceMeta: {
|
|
134
|
+
conversationId: feed.id,
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const session = makeSession(handle, feed);
|
|
140
|
+
sessionCache.set(feed.id, session);
|
|
141
|
+
return session;
|
|
142
|
+
}),
|
|
143
|
+
};
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const makeSession = (process: ProcessManager.Handle<string, void>, feed: Feed.Feed): Session => ({
|
|
148
|
+
feed,
|
|
149
|
+
submitPrompt: (prompt: string) => process.submitInput(prompt),
|
|
150
|
+
waitForCompletion: () => process.runToCompletion(),
|
|
151
|
+
subscribeEphemeral: () => process.subscribeEphemeral(),
|
|
152
|
+
addContext: (context: Ref.Ref<Obj.Unknown>[]) =>
|
|
153
|
+
Effect.gen(function* () {
|
|
154
|
+
const queueDxn = Feed.getQueueDxn(feed) ?? failedInvariant();
|
|
155
|
+
const queue = yield* QueueService.getQueue<Message.Message | ContextBinding>(queueDxn);
|
|
156
|
+
const binder = yield* acquireReleaseResource(() => new AiContextBinder({ queue }));
|
|
157
|
+
yield* Effect.promise(() =>
|
|
158
|
+
binder.bind({
|
|
159
|
+
blueprints: [],
|
|
160
|
+
objects: context,
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
}).pipe(Effect.scoped),
|
|
164
|
+
getContext: () =>
|
|
165
|
+
Effect.gen(function* () {
|
|
166
|
+
const queueDxn = Feed.getQueueDxn(feed) ?? failedInvariant();
|
|
167
|
+
const queue = yield* QueueService.getQueue<Message.Message | ContextBinding>(queueDxn);
|
|
168
|
+
const binder = yield* acquireReleaseResource(() => new AiContextBinder({ queue }));
|
|
169
|
+
return binder.getObjects().map((object) => Ref.make(object));
|
|
170
|
+
}).pipe(Effect.scoped),
|
|
171
|
+
});
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Effect from 'effect/Effect';
|
|
6
|
+
import * as Schema from 'effect/Schema';
|
|
7
|
+
|
|
8
|
+
import { AiService, GenericToolkit, type ModelName } from '@dxos/ai';
|
|
9
|
+
import { Database, DXN, Feed, Obj } from '@dxos/echo';
|
|
10
|
+
import { QueueService, TracingService } from '@dxos/functions';
|
|
11
|
+
import { Process, ProcessManager, StorageService } from '@dxos/functions-runtime';
|
|
12
|
+
import { Operation, OperationRegistry } from '@dxos/operation';
|
|
13
|
+
import { Message } from '@dxos/types';
|
|
14
|
+
|
|
15
|
+
import { trim } from '@dxos/util';
|
|
16
|
+
import * as Tool from '@effect/ai/Tool';
|
|
17
|
+
import * as Toolkit from '@effect/ai/Toolkit';
|
|
18
|
+
import * as Duration from 'effect/Duration';
|
|
19
|
+
import * as Exit from 'effect/Exit';
|
|
20
|
+
import * as Layer from 'effect/Layer';
|
|
21
|
+
import * as Option from 'effect/Option';
|
|
22
|
+
import { AiConversation, type ContextBinding } from '../conversation';
|
|
23
|
+
import {
|
|
24
|
+
functionInvocationServiceFromOperations,
|
|
25
|
+
getOperationFromTool,
|
|
26
|
+
makeToolExecutionService,
|
|
27
|
+
makeToolResolverFromOperations,
|
|
28
|
+
} from '../functions';
|
|
29
|
+
import { acquireReleaseResource } from '@dxos/effect';
|
|
30
|
+
import { log } from '@dxos/log';
|
|
31
|
+
import * as Match from 'effect/Match';
|
|
32
|
+
import * as Cause from 'effect/Cause';
|
|
33
|
+
|
|
34
|
+
interface AgentProcessOptions {
|
|
35
|
+
systemPrompt?: string;
|
|
36
|
+
model?: ModelName;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const AGENT_PROCESS_KEY = 'org.dxos.testing.process.agent';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Hosts a persistent, suspendible AiAgent that can process a number of prompts.
|
|
43
|
+
* The process target is a queue DXN string.
|
|
44
|
+
*/
|
|
45
|
+
export const AgentProcess = (options: AgentProcessOptions) =>
|
|
46
|
+
Process.make(
|
|
47
|
+
{
|
|
48
|
+
key: AGENT_PROCESS_KEY,
|
|
49
|
+
input: Schema.String,
|
|
50
|
+
output: Schema.Void,
|
|
51
|
+
services: [
|
|
52
|
+
Database.Service,
|
|
53
|
+
GenericToolkit.GenericToolkitProvider,
|
|
54
|
+
Operation.Service,
|
|
55
|
+
OperationRegistry.Service,
|
|
56
|
+
StorageService.StorageService,
|
|
57
|
+
QueueService,
|
|
58
|
+
ProcessManager.ProcessOperationInvoker.Service,
|
|
59
|
+
AiService.AiService,
|
|
60
|
+
// @deprecated Required by AiSessionRunRequirements for backward compat with tool handlers.
|
|
61
|
+
TracingService,
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
(ctx) =>
|
|
65
|
+
Effect.gen(function* () {
|
|
66
|
+
const feedDxn = ctx.params.target;
|
|
67
|
+
if (feedDxn == null) {
|
|
68
|
+
return yield* Effect.die(new Error('Agent executable requires spawn options.target set to a queue DXN.'));
|
|
69
|
+
}
|
|
70
|
+
const feed = yield* Database.resolve(DXN.parse(feedDxn), Feed.Feed).pipe(Effect.orDie);
|
|
71
|
+
const queue = yield* QueueService.getQueue<Message.Message | ContextBinding>(Feed.getQueueDxn(feed)!);
|
|
72
|
+
const conversation = yield* acquireReleaseResource(() => new AiConversation({ queue }));
|
|
73
|
+
let inputQueue: AgentEvent[] = [...(yield* AgentEventsKey.get)];
|
|
74
|
+
const storageService = yield* StorageService.StorageService;
|
|
75
|
+
const toolCallManager = new ToolCallManager(storageService);
|
|
76
|
+
yield* toolCallManager.load();
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
onInput: Effect.fnUntraced(function* (prompt: string) {
|
|
80
|
+
log('agent onInput received', { promptLength: prompt.length, backlog: inputQueue.length });
|
|
81
|
+
inputQueue.push({ _tag: 'prompt', content: prompt });
|
|
82
|
+
log('agent onInput persisting queue', { depth: inputQueue.length });
|
|
83
|
+
yield* AgentEventsKey.set(inputQueue);
|
|
84
|
+
log('agent onInput persisted', { depth: inputQueue.length });
|
|
85
|
+
ctx.setAlarm();
|
|
86
|
+
log('agent onInput alarm scheduled');
|
|
87
|
+
}),
|
|
88
|
+
onAlarm: Effect.fnUntraced(
|
|
89
|
+
function* () {
|
|
90
|
+
log('agent onAlarm fired', { pending: inputQueue.length });
|
|
91
|
+
const item = inputQueue.shift();
|
|
92
|
+
if (!item) {
|
|
93
|
+
log('agent onAlarm empty queue', {});
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (item._tag === 'tool_result' && toolCallManager.isReported(item.pid)) {
|
|
98
|
+
log.info('skip tool result that was reported synchronously', { pid: item.pid });
|
|
99
|
+
// Ignore tool results that were reported synchronously.
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
log('agent onAlarm handling', { tag: item._tag });
|
|
104
|
+
|
|
105
|
+
const prompt = Match.value(item).pipe(
|
|
106
|
+
Match.tag('prompt', (item) => item.content),
|
|
107
|
+
Match.tag('tool_result', (item) =>
|
|
108
|
+
item.isError
|
|
109
|
+
? toolErrorResponse(item.pid, item.result as string)
|
|
110
|
+
: toolResultResponse(item.pid, item.result),
|
|
111
|
+
),
|
|
112
|
+
Match.exhaustive,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
log('begin request', { prompt });
|
|
116
|
+
yield* conversation.createRequest({
|
|
117
|
+
prompt,
|
|
118
|
+
// TODO(dmaretskyi): Polling currently broken, agent relies on completion notifications being delivered.
|
|
119
|
+
// toolkit: AsynchronousExectionToolkit,
|
|
120
|
+
system: options.systemPrompt,
|
|
121
|
+
});
|
|
122
|
+
log('end request');
|
|
123
|
+
yield* AgentEventsKey.set(inputQueue);
|
|
124
|
+
if (inputQueue.length > 0) {
|
|
125
|
+
ctx.setAlarm();
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
Effect.orDie,
|
|
129
|
+
Effect.provide(
|
|
130
|
+
Layer.mergeAll(
|
|
131
|
+
makeToolResolverFromOperations(),
|
|
132
|
+
ToolExecutionService({
|
|
133
|
+
toolCallManager,
|
|
134
|
+
feed,
|
|
135
|
+
}),
|
|
136
|
+
functionInvocationServiceFromOperations,
|
|
137
|
+
AsynchronousExectionToolkitLayer,
|
|
138
|
+
AiService.model(options.model ?? '@anthropic/claude-opus-4-6'),
|
|
139
|
+
).pipe(Layer.orDie),
|
|
140
|
+
),
|
|
141
|
+
),
|
|
142
|
+
onChildEvent: Effect.fnUntraced(function* (event) {
|
|
143
|
+
log.info('childEvent', { event });
|
|
144
|
+
if (event._tag === 'exited') {
|
|
145
|
+
if (!toolCallManager.isToolCall(event.pid)) {
|
|
146
|
+
log.verbose('childEvent ignored non-tool call', { pid: event.pid });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const operationInvoker = yield* ProcessManager.ProcessOperationInvoker.Service;
|
|
151
|
+
const fiber = yield* operationInvoker.attachFiber(event.pid).pipe(Effect.orDie);
|
|
152
|
+
const result = yield* fiber.await.pipe(Effect.orDie).pipe(
|
|
153
|
+
Effect.map(
|
|
154
|
+
Exit.match({
|
|
155
|
+
onSuccess: (value): AgentEvent => ({
|
|
156
|
+
_tag: 'tool_result',
|
|
157
|
+
pid: event.pid,
|
|
158
|
+
result: value,
|
|
159
|
+
isError: false,
|
|
160
|
+
}),
|
|
161
|
+
onFailure: (cause): AgentEvent => ({
|
|
162
|
+
_tag: 'tool_result',
|
|
163
|
+
pid: event.pid,
|
|
164
|
+
result: Cause.pretty(cause),
|
|
165
|
+
isError: true,
|
|
166
|
+
}),
|
|
167
|
+
}),
|
|
168
|
+
),
|
|
169
|
+
);
|
|
170
|
+
inputQueue.push(result);
|
|
171
|
+
log('agent onChildEvent persisted tool result', { depth: inputQueue.length, childPid: event.pid });
|
|
172
|
+
yield* AgentEventsKey.set(inputQueue);
|
|
173
|
+
ctx.setAlarm();
|
|
174
|
+
log('agent onChildEvent alarm scheduled', { depth: inputQueue.length });
|
|
175
|
+
}
|
|
176
|
+
}),
|
|
177
|
+
};
|
|
178
|
+
}),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
interface ToolExecutionServiceOptions {
|
|
182
|
+
/**
|
|
183
|
+
* Threshold after which the tool execution is placed in the background.
|
|
184
|
+
*/
|
|
185
|
+
// TODO(dmaretskyi): Tool annotation to never run in background.
|
|
186
|
+
backgroundThreshold?: Duration.Duration;
|
|
187
|
+
|
|
188
|
+
toolCallManager: ToolCallManager;
|
|
189
|
+
feed: Feed.Feed;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const AgentEvent = Schema.Union(
|
|
193
|
+
Schema.TaggedStruct('prompt', {
|
|
194
|
+
content: Schema.String,
|
|
195
|
+
}),
|
|
196
|
+
Schema.TaggedStruct('tool_result', {
|
|
197
|
+
pid: Process.ID,
|
|
198
|
+
result: Schema.Unknown,
|
|
199
|
+
isError: Schema.Boolean,
|
|
200
|
+
}),
|
|
201
|
+
);
|
|
202
|
+
type AgentEvent = Schema.Schema.Type<typeof AgentEvent>;
|
|
203
|
+
|
|
204
|
+
const AgentEventsKey = StorageService.key(
|
|
205
|
+
Schema.parseJson(Schema.Array(AgentEvent).pipe(Schema.mutable)),
|
|
206
|
+
'inputQueue',
|
|
207
|
+
).pipe(StorageService.withDefault(() => []));
|
|
208
|
+
|
|
209
|
+
const ToolCallState = Schema.Struct({
|
|
210
|
+
activeCalls: Schema.Array(
|
|
211
|
+
Schema.Struct({
|
|
212
|
+
pid: Process.ID,
|
|
213
|
+
// Whether the result was reported to the agent.
|
|
214
|
+
reported: Schema.Boolean,
|
|
215
|
+
}).pipe(Schema.mutable),
|
|
216
|
+
).pipe(Schema.mutable),
|
|
217
|
+
});
|
|
218
|
+
interface ToolCallState extends Schema.Schema.Type<typeof ToolCallState> {}
|
|
219
|
+
|
|
220
|
+
// Id's of processes who's results were already submitted to the agent.
|
|
221
|
+
const ToolCallStateKey = StorageService.key(Schema.parseJson(ToolCallState.pipe(Schema.mutable)), 'toolCallState').pipe(
|
|
222
|
+
StorageService.withDefault(() => ({ activeCalls: [] })),
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
class ToolCallManager {
|
|
226
|
+
#storageService: StorageService.Service;
|
|
227
|
+
#state: ToolCallState = { activeCalls: [] };
|
|
228
|
+
|
|
229
|
+
constructor(storageService: StorageService.Service) {
|
|
230
|
+
this.#storageService = storageService;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
load() {
|
|
234
|
+
return Effect.gen(this, function* () {
|
|
235
|
+
this.#state = yield* ToolCallStateKey.get;
|
|
236
|
+
}).pipe(Effect.provideService(StorageService.StorageService, this.#storageService));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
beginCall(pid: Process.ID) {
|
|
240
|
+
return Effect.gen(this, function* () {
|
|
241
|
+
this.#state.activeCalls.push({ pid, reported: false });
|
|
242
|
+
yield* ToolCallStateKey.set(this.#state);
|
|
243
|
+
}).pipe(Effect.provideService(StorageService.StorageService, this.#storageService));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
markAsReported(pid: Process.ID) {
|
|
247
|
+
return Effect.gen(this, function* () {
|
|
248
|
+
const call = this.#state.activeCalls.find((call) => call.pid === pid);
|
|
249
|
+
if (!call) {
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
call.reported = true;
|
|
253
|
+
yield* ToolCallStateKey.set(this.#state);
|
|
254
|
+
}).pipe(Effect.provideService(StorageService.StorageService, this.#storageService));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
isToolCall(pid: Process.ID): boolean {
|
|
258
|
+
return this.#state.activeCalls.some((call) => call.pid === pid);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
isReported(pid: Process.ID) {
|
|
262
|
+
return this.#state.activeCalls.some((call) => call.pid === pid && call.reported);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const ToolExecutionService = ({
|
|
267
|
+
backgroundThreshold = Duration.seconds(1),
|
|
268
|
+
toolCallManager,
|
|
269
|
+
feed,
|
|
270
|
+
}: ToolExecutionServiceOptions) =>
|
|
271
|
+
Layer.unwrapEffect(
|
|
272
|
+
Effect.gen(function* () {
|
|
273
|
+
const operationInvoker = yield* ProcessManager.ProcessOperationInvoker.Service;
|
|
274
|
+
return makeToolExecutionService({
|
|
275
|
+
invoke: (tool, input) =>
|
|
276
|
+
Effect.gen(function* () {
|
|
277
|
+
const operationDef = getOperationFromTool(tool).pipe(Option.getOrThrow);
|
|
278
|
+
log('invoking operation', { operationDef, input });
|
|
279
|
+
const fiber = yield* operationInvoker.invokeFiber(operationDef, input, {
|
|
280
|
+
environment: {
|
|
281
|
+
conversation: Obj.getDXN(feed).toString(),
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
toolCallManager.beginCall(fiber.pid);
|
|
285
|
+
log('invoked operation', { operationDef, input, fiber });
|
|
286
|
+
|
|
287
|
+
const result = yield* fiber.await.pipe(
|
|
288
|
+
Effect.tap(() => toolCallManager.markAsReported(fiber.pid)),
|
|
289
|
+
Effect.timeout(backgroundThreshold),
|
|
290
|
+
Effect.catchTag('TimeoutException', () => Effect.succeed(toolIsRunningInBackgroundResponse(fiber.pid))),
|
|
291
|
+
);
|
|
292
|
+
log('result', { result });
|
|
293
|
+
return result;
|
|
294
|
+
}),
|
|
295
|
+
});
|
|
296
|
+
}),
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
class AsynchronousExectionToolkit extends Toolkit.make(
|
|
300
|
+
Tool.make('poll-tools', {
|
|
301
|
+
description: trim`
|
|
302
|
+
Poll tool calls running in the background.
|
|
303
|
+
Set wait to true to wait for the tool call to complete before returning.
|
|
304
|
+
Only set wait to true if you dont have other tasks to perform in parallel.
|
|
305
|
+
Set an appropriate timeout to avoid waiting forever.
|
|
306
|
+
You will also be notified about the job completion separatelly, so you do not always need to inspect the job if you dont need the result right now.
|
|
307
|
+
`,
|
|
308
|
+
parameters: {
|
|
309
|
+
ids: Schema.Array(Schema.String).annotations({
|
|
310
|
+
description: 'The IDs of the jobs to inspect.',
|
|
311
|
+
}),
|
|
312
|
+
wait: Schema.optional(Schema.Boolean).annotations({
|
|
313
|
+
description: 'Whether to wait for the job to complete before returning.',
|
|
314
|
+
default: false,
|
|
315
|
+
}),
|
|
316
|
+
timeout: Schema.optional(Schema.Number).annotations({
|
|
317
|
+
description:
|
|
318
|
+
'Maximum time to wait for the job to complete. If the job does not complete within the timeout, the current state is returned.',
|
|
319
|
+
default: 10_000,
|
|
320
|
+
}),
|
|
321
|
+
},
|
|
322
|
+
}),
|
|
323
|
+
) {}
|
|
324
|
+
|
|
325
|
+
// TODO(dmaretskyi): Currently broken: polling a completed process returns interruped error.
|
|
326
|
+
const AsynchronousExectionToolkitLayer = AsynchronousExectionToolkit.toLayer(
|
|
327
|
+
Effect.gen(function* () {
|
|
328
|
+
const invoker = yield* ProcessManager.ProcessOperationInvoker.Service;
|
|
329
|
+
return {
|
|
330
|
+
'poll-tools': ({ ids, wait, timeout = 10_000 }) =>
|
|
331
|
+
Effect.gen(function* () {
|
|
332
|
+
return yield* Effect.forEach(ids, (pid) =>
|
|
333
|
+
invoker.attachFiber<unknown>(Process.ID.make(pid)).pipe(
|
|
334
|
+
Effect.flatMap((_) => _.await),
|
|
335
|
+
Effect.timeout(Duration.millis(timeout)),
|
|
336
|
+
Effect.flatMap(
|
|
337
|
+
Exit.match({
|
|
338
|
+
onSuccess: (value) => Effect.succeed(toolResultResponse(pid, value)),
|
|
339
|
+
onFailure: (cause) => Effect.succeed(toolErrorResponse(pid, Cause.pretty(cause))),
|
|
340
|
+
}),
|
|
341
|
+
),
|
|
342
|
+
Effect.catchTag('ProcessNotFoundError', () => Effect.succeed(`Process not found: ${pid}`)),
|
|
343
|
+
Effect.catchTag('TimeoutException', () => Effect.succeed(`Process still running: ${pid}`)),
|
|
344
|
+
),
|
|
345
|
+
);
|
|
346
|
+
}),
|
|
347
|
+
};
|
|
348
|
+
}),
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Instructs model that the tool is running in the background.
|
|
353
|
+
*/
|
|
354
|
+
const toolIsRunningInBackgroundResponse = (pid: Process.ID) =>
|
|
355
|
+
`Tool is running in the background id=${pid}; wait for the completion notification to get the result.`;
|
|
356
|
+
// `Tool is running in the background id=${pid}; use ${AsynchronousExectionToolkit.tools['poll-tools'].name} to get the result.`;
|
|
357
|
+
|
|
358
|
+
const toolResultResponse = (pid: string, value: unknown) => `<result pid=${pid}>${JSON.stringify(value)}</result>`;
|
|
359
|
+
|
|
360
|
+
const toolErrorResponse = (pid: string, cause: string) => `<error pid=${pid}>${cause}</error>`;
|