@dxos/assistant 0.9.0 → 0.9.1-staging.ee54ba693a
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/neutral/extraction/index.mjs +108 -119
- package/dist/lib/neutral/extraction/index.mjs.map +4 -4
- package/dist/lib/neutral/index.mjs +395 -234
- package/dist/lib/neutral/index.mjs.map +4 -4
- package/dist/lib/neutral/meta.json +1 -1
- package/dist/types/src/extraction/extraction-llm-function/index.d.ts +2 -0
- package/dist/types/src/extraction/extraction-llm-function/index.d.ts.map +1 -0
- package/dist/types/src/extraction/extraction-llm-function/proper-noun-extraction.d.ts +26 -0
- package/dist/types/src/extraction/extraction-llm-function/proper-noun-extraction.d.ts.map +1 -0
- package/dist/types/src/extraction/index.d.ts +1 -1
- package/dist/types/src/extraction/index.d.ts.map +1 -1
- package/dist/types/src/extraction/quotes.d.ts +8 -1
- package/dist/types/src/extraction/quotes.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +1 -1
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/request/AiRequest.d.ts +19 -208
- package/dist/types/src/request/AiRequest.d.ts.map +1 -1
- package/dist/types/src/request/format.d.ts +1 -1
- package/dist/types/src/request/format.d.ts.map +1 -1
- package/dist/types/src/session/AiContext.d.ts +27 -105
- package/dist/types/src/session/AiContext.d.ts.map +1 -1
- package/dist/types/src/session/AiSession.d.ts +11 -29
- package/dist/types/src/session/AiSession.d.ts.map +1 -1
- package/dist/types/src/session/Harness.d.ts +122 -0
- package/dist/types/src/session/Harness.d.ts.map +1 -0
- package/dist/types/src/session/SessionLink.d.ts +8 -11
- package/dist/types/src/session/SessionLink.d.ts.map +1 -1
- package/dist/types/src/session/SessionLoader.d.ts +2 -2
- package/dist/types/src/session/SessionLoader.d.ts.map +1 -1
- package/dist/types/src/session/SkillHooks.d.ts +28 -0
- package/dist/types/src/session/SkillHooks.d.ts.map +1 -0
- package/dist/types/src/session/harness-control.d.ts +177 -0
- package/dist/types/src/session/harness-control.d.ts.map +1 -0
- package/dist/types/src/session/index.d.ts +3 -0
- package/dist/types/src/session/index.d.ts.map +1 -1
- package/dist/types/src/session/toolkit.d.ts +4 -4
- package/dist/types/src/session/toolkit.d.ts.map +1 -1
- package/dist/types/src/templates/system.d.ts +4 -6
- package/dist/types/src/templates/system.d.ts.map +1 -1
- package/dist/types/src/tool-runtime/index.d.ts.map +1 -0
- package/dist/types/src/tool-runtime/services.d.ts.map +1 -0
- package/dist/types/src/util/artifact.d.ts.map +1 -1
- package/dist/types/src/util/diff.d.ts +9 -3
- package/dist/types/src/util/diff.d.ts.map +1 -1
- package/dist/types/src/util/tracing.d.ts +40 -0
- package/dist/types/src/util/tracing.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +28 -25
- package/src/extraction/extraction-llm-function/index.ts +5 -0
- package/src/extraction/extraction-llm-function/proper-noun-extraction.ts +78 -0
- package/src/extraction/index.ts +1 -1
- package/src/extraction/quotes.test.ts +67 -2
- package/src/extraction/quotes.ts +47 -13
- package/src/index.ts +1 -1
- package/src/request/AiRequest.ts +45 -14
- package/src/request/format.ts +9 -11
- package/src/session/AiContext.test.ts +52 -32
- package/src/session/AiContext.ts +73 -95
- package/src/session/AiSession.test.ts +4 -4
- package/src/session/AiSession.ts +51 -83
- package/src/session/Harness.ts +241 -0
- package/src/session/SessionLink.ts +8 -8
- package/src/session/SessionLoader.ts +4 -4
- package/src/session/SkillHooks.ts +71 -0
- package/src/session/harness-control.ts +30 -0
- package/src/session/index.ts +3 -0
- package/src/session/toolkit.ts +8 -8
- package/src/templates/instructions/format.tpl +3 -3
- package/src/templates/instructions/system.tpl +10 -12
- package/src/templates/system.ts +6 -2
- package/src/util/artifact.ts +9 -2
- package/src/util/diff.test.ts +4 -4
- package/src/util/diff.ts +15 -8
- package/src/util/tracing.ts +1 -1
- package/dist/types/src/extraction/extraction.d.ts +0 -1677
- package/dist/types/src/extraction/extraction.d.ts.map +0 -1
- package/dist/types/src/functions/index.d.ts.map +0 -1
- package/dist/types/src/functions/services.d.ts.map +0 -1
- package/src/extraction/extraction-llm-function/transcription-instructions.tpl +0 -28
- package/src/extraction/extraction.ts +0 -105
- /package/dist/types/src/{functions → tool-runtime}/index.d.ts +0 -0
- /package/dist/types/src/{functions → tool-runtime}/services.d.ts +0 -0
- /package/src/{functions → tool-runtime}/index.ts +0 -0
- /package/src/{functions → tool-runtime}/services.ts +0 -0
package/src/session/AiSession.ts
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
|
|
7
7
|
import { type Registry as AtomRegistry } from '@effect-atom/atom-react';
|
|
8
8
|
import * as Array from 'effect/Array';
|
|
9
|
-
import * as Context from 'effect/Context';
|
|
10
9
|
import * as Effect from 'effect/Effect';
|
|
11
10
|
import * as Either from 'effect/Either';
|
|
12
11
|
import { pipe } from 'effect/Function';
|
|
@@ -15,20 +14,21 @@ import * as Record from 'effect/Record';
|
|
|
15
14
|
import * as Runtime from 'effect/Runtime';
|
|
16
15
|
|
|
17
16
|
import { type OpaqueToolkit, type ToolExecutionService, type ToolResolverService } from '@dxos/ai';
|
|
18
|
-
import {
|
|
17
|
+
import { McpServer, Operation, type Skill, Trace } from '@dxos/compute';
|
|
19
18
|
import { Resource } from '@dxos/context';
|
|
20
19
|
import { Database, Feed, Filter, Obj, Registry } from '@dxos/echo';
|
|
21
|
-
import { EffectEx } from '@dxos/effect';
|
|
22
20
|
import { invariant } from '@dxos/invariant';
|
|
23
21
|
import { log } from '@dxos/log';
|
|
24
22
|
import { McpToolkit } from '@dxos/mcp-client';
|
|
25
|
-
import {
|
|
23
|
+
import { type ContentBlock, Message } from '@dxos/types';
|
|
26
24
|
|
|
27
|
-
import { ToolExecutionServices } from '../functions';
|
|
28
25
|
import { AiRequest, type GenerationObserver, formatSystemPrompt } from '../request';
|
|
26
|
+
import { ToolExecutionServices } from '../tool-runtime';
|
|
29
27
|
import { McpServerError } from '../util';
|
|
30
28
|
import * as AiContext from './AiContext';
|
|
29
|
+
import * as Harness from './Harness';
|
|
31
30
|
import { SessionLoader } from './SessionLoader';
|
|
31
|
+
import * as SkillHooks from './SkillHooks';
|
|
32
32
|
import { createToolkit } from './toolkit';
|
|
33
33
|
|
|
34
34
|
export type RunProps<R = never> = {
|
|
@@ -38,14 +38,21 @@ export type RunProps<R = never> = {
|
|
|
38
38
|
toolkit?: OpaqueToolkit.OpaqueToolkit<R>;
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
|
-
* Space-level MCP servers to connect alongside
|
|
41
|
+
* Space-level MCP servers to connect alongside skill-defined ones.
|
|
42
42
|
*/
|
|
43
43
|
mcpServers?: readonly McpServer.McpServer[];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* When false, messages from this request are not appended to the feed or persisted to trace.
|
|
47
|
+
*
|
|
48
|
+
* @default true
|
|
49
|
+
*/
|
|
50
|
+
persist?: boolean;
|
|
44
51
|
};
|
|
45
52
|
|
|
46
53
|
export type Options = {
|
|
47
54
|
feed: Feed.Feed;
|
|
48
|
-
runtime: Runtime.Runtime<
|
|
55
|
+
runtime: Runtime.Runtime<Database.Service>;
|
|
49
56
|
/** @effect-atom/atom-react Registry for reactive state. */
|
|
50
57
|
registry?: AtomRegistry.Registry;
|
|
51
58
|
};
|
|
@@ -62,11 +69,11 @@ const SUMMARY_THRESHOLD = 80_000;
|
|
|
62
69
|
*/
|
|
63
70
|
export class Session extends Resource {
|
|
64
71
|
/**
|
|
65
|
-
*
|
|
72
|
+
* Skills and objects bound to the session.
|
|
66
73
|
*/
|
|
67
74
|
private readonly _binder: AiContext.Binder;
|
|
68
75
|
private readonly _feed: Feed.Feed;
|
|
69
|
-
private readonly _runtime: Runtime.Runtime<
|
|
76
|
+
private readonly _runtime: Runtime.Runtime<Database.Service>;
|
|
70
77
|
private readonly _sessionLoader = new SessionLoader();
|
|
71
78
|
|
|
72
79
|
public constructor(options: Options) {
|
|
@@ -107,7 +114,7 @@ export class Session extends Resource {
|
|
|
107
114
|
ToolExecutionService | ToolResolverService
|
|
108
115
|
> {
|
|
109
116
|
return Effect.gen(this, function* () {
|
|
110
|
-
const toolkit = yield* createToolkit({
|
|
117
|
+
const toolkit = yield* createToolkit({ skills: this.context.getSkills() });
|
|
111
118
|
return toolkit.toolkit.tools;
|
|
112
119
|
}).pipe(Effect.orDie);
|
|
113
120
|
}
|
|
@@ -133,52 +140,66 @@ export class Session extends Resource {
|
|
|
133
140
|
): Effect.Effect<Message.Message[], AiRequest.RunError, AiRequest.RunRequirements | R> {
|
|
134
141
|
return Effect.gen(this, function* () {
|
|
135
142
|
const history = yield* Effect.promise(() => this.getHistory());
|
|
136
|
-
const
|
|
143
|
+
const skills = this.context.getSkills();
|
|
137
144
|
const objects = this.context.getObjects();
|
|
138
145
|
|
|
139
146
|
log('run', {
|
|
140
147
|
history: history.length,
|
|
141
|
-
|
|
148
|
+
skills: skills.length,
|
|
142
149
|
objects: objects.length,
|
|
143
150
|
});
|
|
144
151
|
|
|
145
152
|
const request = new AiRequest.Request({
|
|
146
153
|
summarizationThreshold: SUMMARY_THRESHOLD,
|
|
147
154
|
observer: params.observer,
|
|
155
|
+
persist: params.persist,
|
|
148
156
|
onOutput: (message) =>
|
|
149
157
|
Effect.promise(() => Runtime.runPromise(this._runtime)(Feed.append(this._feed, [message]))),
|
|
150
158
|
});
|
|
151
159
|
|
|
152
160
|
yield* request.begin({
|
|
153
161
|
history,
|
|
154
|
-
|
|
162
|
+
skills,
|
|
155
163
|
objects,
|
|
156
164
|
prompt: params.prompt,
|
|
157
165
|
system: params.system,
|
|
158
166
|
});
|
|
159
167
|
|
|
160
|
-
//
|
|
168
|
+
// Fire begin-request hooks declared by the bound skills. These run in the agent's turn
|
|
169
|
+
// fiber (Tier A only), so they cannot reach the live host (Tier B) — that is the end hook's job.
|
|
170
|
+
yield* SkillHooks.runHooks({
|
|
171
|
+
skills,
|
|
172
|
+
phase: 'begin-request',
|
|
173
|
+
invoke: (operation, input) => Operation.invoke(operation, input).pipe(Effect.asVoid, Effect.orDie),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// Turn loop: recompute toolkit and system prompt between turns to pick up dynamically enabled skills.
|
|
161
177
|
do {
|
|
162
178
|
yield* Effect.promise(() => this.context.sync());
|
|
163
|
-
const
|
|
164
|
-
const mcps = yield* connectMcpServers(
|
|
179
|
+
const currentSkills = this.context.getSkills();
|
|
180
|
+
const mcps = yield* connectMcpServers(currentSkills, params.mcpServers);
|
|
165
181
|
const toolkit = yield* createToolkit({
|
|
166
182
|
toolkit: params.toolkit,
|
|
167
|
-
|
|
183
|
+
skills: currentSkills,
|
|
168
184
|
opaqueToolkits: mcps,
|
|
169
185
|
});
|
|
170
186
|
|
|
171
187
|
log('toolkit', { tools: Record.keys(toolkit.toolkit.tools) });
|
|
172
188
|
const system = yield* formatSystemPrompt({
|
|
173
189
|
system: params.system,
|
|
174
|
-
|
|
190
|
+
skills: currentSkills,
|
|
175
191
|
objects: this.context.getObjects(),
|
|
176
192
|
}).pipe(Effect.orDie);
|
|
177
193
|
|
|
178
|
-
const { done } = yield* request.runAgentTurn({ system, toolkit });
|
|
194
|
+
const { done, finishReason } = yield* request.runAgentTurn({ system, toolkit });
|
|
179
195
|
if (done) {
|
|
180
196
|
break;
|
|
181
197
|
}
|
|
198
|
+
// A paused server-tool turn (e.g. Anthropic `pause_turn`) resumes with another request and
|
|
199
|
+
// no local tool execution; the trailing server tool call must be left intact for the provider.
|
|
200
|
+
if (finishReason === 'pause') {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
182
203
|
|
|
183
204
|
yield* request.runTools({ toolkit });
|
|
184
205
|
} while (true);
|
|
@@ -193,10 +214,13 @@ export class Session extends Resource {
|
|
|
193
214
|
}).pipe(
|
|
194
215
|
Effect.provide(
|
|
195
216
|
Layer.mergeAll(
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
Layer.succeed(
|
|
217
|
+
// Tier A only: the agent's own turn fiber has no `ProcessManager.Service` in scope, so
|
|
218
|
+
// Tier B (setAlarm/enqueueMessage) is reachable only by child operations through the
|
|
219
|
+
// process-affinity HarnessService LayerSpec — not from here.
|
|
220
|
+
Layer.succeed(
|
|
221
|
+
Harness.HarnessService,
|
|
222
|
+
Harness.fromBinder({ feed: this._feed, runtime: this._runtime, binder: this.context }),
|
|
223
|
+
),
|
|
200
224
|
Operation.withInvocationOptions({ conversation: Obj.getURI(this._feed) }),
|
|
201
225
|
),
|
|
202
226
|
),
|
|
@@ -205,68 +229,12 @@ export class Session extends Resource {
|
|
|
205
229
|
}
|
|
206
230
|
}
|
|
207
231
|
|
|
208
|
-
/**
|
|
209
|
-
* Gives access to the ai session.
|
|
210
|
-
*/
|
|
211
|
-
export class Service extends Context.Tag('@dxos/assistant/AiSessionService')<Service, Session>() {
|
|
212
|
-
/**
|
|
213
|
-
* Create a new session layer from options.
|
|
214
|
-
*/
|
|
215
|
-
static layer = (options: Options): Layer.Layer<Service | AiContext.Service> =>
|
|
216
|
-
aiContextFromSession.pipe(
|
|
217
|
-
Layer.provideMerge(
|
|
218
|
-
Layer.scoped(
|
|
219
|
-
Service,
|
|
220
|
-
Effect.gen(function* () {
|
|
221
|
-
const session = yield* EffectEx.acquireReleaseResource(() => new Session(options));
|
|
222
|
-
return session;
|
|
223
|
-
}),
|
|
224
|
-
),
|
|
225
|
-
),
|
|
226
|
-
);
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* Create a new session with a new feed.
|
|
230
|
-
*/
|
|
231
|
-
static layerNewFeed = (
|
|
232
|
-
options?: Omit<Options, 'feed' | 'runtime'>,
|
|
233
|
-
): Layer.Layer<Service | AiContext.Service, never, Database.Service | Feed.FeedService> =>
|
|
234
|
-
Layer.unwrapScoped(
|
|
235
|
-
Effect.gen(function* () {
|
|
236
|
-
const feed = yield* Database.add(Feed.make());
|
|
237
|
-
const runtime = yield* Effect.runtime<Feed.FeedService>();
|
|
238
|
-
return Service.layer({ ...options, feed, runtime });
|
|
239
|
-
}),
|
|
240
|
-
);
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Run a prompt in the current session.
|
|
244
|
-
*/
|
|
245
|
-
static run = <R = never>(
|
|
246
|
-
params: RunProps<R>,
|
|
247
|
-
): Effect.Effect<Message.Message[], AiRequest.RunError, AiRequest.RunRequirements | Service | R> =>
|
|
248
|
-
Effect.gen(function* () {
|
|
249
|
-
const session = yield* Service;
|
|
250
|
-
return yield* session.createRequest(params);
|
|
251
|
-
});
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const aiContextFromSession = Layer.effect(
|
|
255
|
-
AiContext.Service,
|
|
256
|
-
Effect.gen(function* () {
|
|
257
|
-
const session = yield* Service;
|
|
258
|
-
return {
|
|
259
|
-
binder: session.context,
|
|
260
|
-
};
|
|
261
|
-
}),
|
|
262
|
-
);
|
|
263
|
-
|
|
264
232
|
const connectMcpServers = (
|
|
265
|
-
|
|
233
|
+
skills: readonly Skill.Skill[],
|
|
266
234
|
spaceMcpServers: readonly McpServer.McpServer[] = [],
|
|
267
235
|
): Effect.Effect<OpaqueToolkit.OpaqueToolkit[], never, Trace.TraceService> => {
|
|
268
|
-
const
|
|
269
|
-
|
|
236
|
+
const skillServers: McpToolkit.McpToolkitOptions[] = pipe(
|
|
237
|
+
skills,
|
|
270
238
|
Array.flatMap((_) => _.mcpServers ?? []),
|
|
271
239
|
Array.map(({ url, protocol, apiKey }) => ({ url, protocol, apiKey })),
|
|
272
240
|
);
|
|
@@ -275,7 +243,7 @@ const connectMcpServers = (
|
|
|
275
243
|
protocol,
|
|
276
244
|
apiKey,
|
|
277
245
|
}));
|
|
278
|
-
const allServers = [...
|
|
246
|
+
const allServers = [...skillServers, ...spaceServers];
|
|
279
247
|
|
|
280
248
|
return pipe(
|
|
281
249
|
allServers,
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
// @import-as-namespace
|
|
6
|
+
|
|
7
|
+
import type * as RpcClient from '@effect/rpc/RpcClient';
|
|
8
|
+
import * as Context from 'effect/Context';
|
|
9
|
+
import * as DateTime from 'effect/DateTime';
|
|
10
|
+
import * as Effect from 'effect/Effect';
|
|
11
|
+
import * as Layer from 'effect/Layer';
|
|
12
|
+
import * as Option from 'effect/Option';
|
|
13
|
+
import type * as Runtime from 'effect/Runtime';
|
|
14
|
+
import type * as Scope from 'effect/Scope';
|
|
15
|
+
|
|
16
|
+
import { LayerSpec, Process, ServiceNotAvailableError } from '@dxos/compute';
|
|
17
|
+
import { ProcessManager } from '@dxos/compute-runtime';
|
|
18
|
+
import { Annotation, Database, EID, Feed, Filter, Obj, type URI } from '@dxos/echo';
|
|
19
|
+
import { EffectEx } from '@dxos/effect';
|
|
20
|
+
import { BaseError } from '@dxos/errors';
|
|
21
|
+
import { type ContentBlock, Message } from '@dxos/types';
|
|
22
|
+
|
|
23
|
+
import * as AiContext from './AiContext';
|
|
24
|
+
import { type HarnessControlRpcs } from './harness-control';
|
|
25
|
+
import { SessionLoader } from './SessionLoader';
|
|
26
|
+
|
|
27
|
+
export interface Service {
|
|
28
|
+
/** The conversation {@link AiContext.Binder} (Tier A). */
|
|
29
|
+
binder: Effect.Effect<AiContext.Binder, NotSupportedError>;
|
|
30
|
+
/** The conversation message history (Tier A). */
|
|
31
|
+
history: Effect.Effect<Message.Message[], NotSupportedError>;
|
|
32
|
+
/** Objects bound to the conversation that match the filter (Tier A). */
|
|
33
|
+
queryContext<T extends Obj.Unknown>(filter: Filter.Filter<T>): Effect.Effect<T[], NotSupportedError>;
|
|
34
|
+
/** Enqueue a message into the owning host's input queue (Tier B). */
|
|
35
|
+
enqueueMessage(options: EnqueueMessageOptions): Effect.Effect<void, NotSupportedError>;
|
|
36
|
+
/** Schedule a self-wake on the owning host (Tier B). */
|
|
37
|
+
setAlarm(options: SetAlarmOptions): Effect.Effect<void, NotSupportedError>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Provides access to the controlling harness for tools and operations that are executed by the agent.
|
|
42
|
+
*
|
|
43
|
+
* Replaces AiContextService and AiSessionService.
|
|
44
|
+
*/
|
|
45
|
+
export class HarnessService extends Context.Tag('@dxos/assistant/HarnessService')<HarnessService, Service>() {}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Acess current context binder.
|
|
49
|
+
*/
|
|
50
|
+
export const binder: Effect.Effect<AiContext.Binder, NotSupportedError, HarnessService> = Effect.flatMap(
|
|
51
|
+
HarnessService,
|
|
52
|
+
(service) => service.binder,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Query the context using a filter.
|
|
57
|
+
*/
|
|
58
|
+
export const queryContext = <T extends Obj.Unknown>(
|
|
59
|
+
filter: Filter.Filter<T>,
|
|
60
|
+
): Effect.Effect<T[], NotSupportedError, HarnessService> =>
|
|
61
|
+
Effect.flatMap(HarnessService, (service) => service.queryContext(filter));
|
|
62
|
+
|
|
63
|
+
interface EnqueueMessageOptions {
|
|
64
|
+
content: ContentBlock.Any[];
|
|
65
|
+
|
|
66
|
+
// TODO(dmaretskyi): Order: 'first' | 'last'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Query the session history.
|
|
71
|
+
*/
|
|
72
|
+
export const history: Effect.Effect<Message.Message[], NotSupportedError, HarnessService> = Effect.flatMap(
|
|
73
|
+
HarnessService,
|
|
74
|
+
(service) => service.history,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Enqueue a message to the harness.
|
|
79
|
+
*/
|
|
80
|
+
export const enqueueMessage = (
|
|
81
|
+
options: EnqueueMessageOptions,
|
|
82
|
+
): Effect.Effect<void, NotSupportedError, HarnessService> =>
|
|
83
|
+
Effect.flatMap(HarnessService, (service) => service.enqueueMessage(options));
|
|
84
|
+
|
|
85
|
+
interface SetAlarmOptions {
|
|
86
|
+
at: DateTime.DateTime;
|
|
87
|
+
/**
|
|
88
|
+
* Message to send when the alarm fires.
|
|
89
|
+
*/
|
|
90
|
+
message: string | null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const setAlarm = (options: SetAlarmOptions): Effect.Effect<void, NotSupportedError, HarnessService> =>
|
|
94
|
+
Effect.flatMap(HarnessService, (service) => service.setAlarm(options));
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Binds skills and/or objects to the conversation context.
|
|
98
|
+
*/
|
|
99
|
+
export const bindContext = ({
|
|
100
|
+
skills,
|
|
101
|
+
objects,
|
|
102
|
+
}: AiContext.BindingProps): Effect.Effect<void, NotSupportedError, HarnessService> =>
|
|
103
|
+
Effect.gen(function* () {
|
|
104
|
+
const sessionBinder = yield* binder;
|
|
105
|
+
yield* Effect.promise(() => sessionBinder.bind({ skills, objects }));
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
export class NotSupportedError extends BaseError.extend('NotSupportedError', 'Operation not supported') {}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Process-affinity {@link LayerSpec} that materialises {@link HarnessService} from the resolution
|
|
112
|
+
* context's `conversation` DXN. Merges the former `AiContext.Service` (Tier A) with live host
|
|
113
|
+
* dispatch (Tier B).
|
|
114
|
+
*/
|
|
115
|
+
export const layerSpec: LayerSpec.LayerSpec = LayerSpec.make(
|
|
116
|
+
{
|
|
117
|
+
affinity: 'process',
|
|
118
|
+
requires: [Database.Service, ProcessManager.Service],
|
|
119
|
+
provides: [HarnessService],
|
|
120
|
+
},
|
|
121
|
+
(context) =>
|
|
122
|
+
Layer.scoped(
|
|
123
|
+
HarnessService,
|
|
124
|
+
Effect.gen(function* () {
|
|
125
|
+
if (!context.conversation) {
|
|
126
|
+
// LayerSpec.make requires a never-failing layer (error channel = never), so typed failure
|
|
127
|
+
// is not possible here. Die with ServiceNotAvailableError to signal a programming error
|
|
128
|
+
// (missing 'conversation' in spawn environment) that callers cannot recover from.
|
|
129
|
+
return yield* Effect.die(
|
|
130
|
+
new ServiceNotAvailableError(HarnessService.key, {
|
|
131
|
+
message: `Service not available: ${HarnessService.key} — process spawn is missing 'conversation' in environment (set via Operation.withInvocationOptions or ProcessManager.spawn environment)`,
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
const conversation = context.conversation;
|
|
136
|
+
const processManager = yield* ProcessManager.Service;
|
|
137
|
+
const runtime = yield* Effect.runtime<Database.Service>();
|
|
138
|
+
return yield* make({ conversation, processManager, runtime });
|
|
139
|
+
}),
|
|
140
|
+
),
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
interface MakeOptions {
|
|
144
|
+
conversation: URI.URI;
|
|
145
|
+
processManager: Context.Tag.Service<ProcessManager.Service>;
|
|
146
|
+
runtime: Runtime.Runtime<Database.Service>;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Builds the {@link Service} for a conversation-scoped resolution context. Tier A resolves the
|
|
151
|
+
* conversation feed / {@link AiContext.Binder} once; Tier B looks up the owning host process lazily per call
|
|
152
|
+
* and dispatches over its `Handle.rpc` control surface (raising {@link NotSupportedError} when no
|
|
153
|
+
* live host owns the conversation).
|
|
154
|
+
*/
|
|
155
|
+
export const make = ({
|
|
156
|
+
conversation,
|
|
157
|
+
processManager,
|
|
158
|
+
runtime,
|
|
159
|
+
}: MakeOptions): Effect.Effect<Service, never, Scope.Scope> =>
|
|
160
|
+
Effect.gen(function* () {
|
|
161
|
+
const feed = yield* Database.resolve(EID.parse(conversation), Feed.Feed).pipe(
|
|
162
|
+
Effect.provide(runtime),
|
|
163
|
+
Effect.orDie,
|
|
164
|
+
);
|
|
165
|
+
const boundBinder = yield* EffectEx.acquireReleaseResource(() => new AiContext.Binder({ feed, runtime }));
|
|
166
|
+
return makeService({
|
|
167
|
+
feed,
|
|
168
|
+
runtime,
|
|
169
|
+
binder: boundBinder,
|
|
170
|
+
owningHost: lookupOwningHost(processManager, conversation),
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
interface FromBinderOptions {
|
|
175
|
+
feed: Feed.Feed;
|
|
176
|
+
runtime: Runtime.Runtime<Database.Service>;
|
|
177
|
+
binder: AiContext.Binder;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Builds the {@link Service} from a {@link AiContext.Binder} already opened for the conversation (e.g. the
|
|
182
|
+
* agent's own turn fiber, which has no `ProcessManager.Service` in scope). Tier A is fully served;
|
|
183
|
+
* Tier B raises {@link NotSupportedError} since the live-host control surface is not reachable here.
|
|
184
|
+
*/
|
|
185
|
+
export const fromBinder = ({ feed, runtime, binder }: FromBinderOptions): Service =>
|
|
186
|
+
makeService({ feed, runtime, binder, owningHost: Effect.fail(new NotSupportedError()) });
|
|
187
|
+
|
|
188
|
+
interface MakeServiceOptions {
|
|
189
|
+
feed: Feed.Feed;
|
|
190
|
+
runtime: Runtime.Runtime<Database.Service>;
|
|
191
|
+
binder: AiContext.Binder;
|
|
192
|
+
owningHost: Effect.Effect<RpcClient.RpcClient<HarnessControlRpcs>, NotSupportedError>;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const makeService = ({ feed, runtime, binder, owningHost }: MakeServiceOptions): Service => ({
|
|
196
|
+
binder: Effect.succeed(binder),
|
|
197
|
+
history: Effect.gen(function* () {
|
|
198
|
+
const messages = yield* Feed.query(feed, Filter.type(Message.Message)).run;
|
|
199
|
+
return yield* new SessionLoader().reifyHistory(feed, messages);
|
|
200
|
+
}).pipe(Effect.provide(runtime)),
|
|
201
|
+
queryContext: <T extends Obj.Unknown>(filter: Filter.Filter<T>) =>
|
|
202
|
+
Effect.sync(() => {
|
|
203
|
+
const match = Filter.toPredicate(filter);
|
|
204
|
+
return binder.getObjects().filter(match);
|
|
205
|
+
}),
|
|
206
|
+
enqueueMessage: ({ content }) => owningHost.pipe(Effect.flatMap((rpc) => rpc.enqueueMessage({ content }))),
|
|
207
|
+
setAlarm: ({ at, message }) =>
|
|
208
|
+
owningHost.pipe(Effect.flatMap((rpc) => rpc.setAlarm({ at: DateTime.toUtc(at), message }))),
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Resolves the live host owning `conversation` per call (so a process replacement — e.g. a model
|
|
213
|
+
* switch — is never captured stale) and exposes its `HarnessControl` RPC client.
|
|
214
|
+
*/
|
|
215
|
+
const lookupOwningHost = (
|
|
216
|
+
processManager: Context.Tag.Service<ProcessManager.Service>,
|
|
217
|
+
conversation: URI.URI,
|
|
218
|
+
): Effect.Effect<RpcClient.RpcClient<HarnessControlRpcs>, NotSupportedError> =>
|
|
219
|
+
Effect.gen(function* () {
|
|
220
|
+
const processes = yield* processManager.list({ target: conversation });
|
|
221
|
+
const host = processes.find(
|
|
222
|
+
(process) =>
|
|
223
|
+
!isTerminalProcess(process.status.state) &&
|
|
224
|
+
Option.getOrElse(
|
|
225
|
+
Annotation.getDictionary(process.params.annotations, Process.HarnessHostAnnotation),
|
|
226
|
+
() => false,
|
|
227
|
+
),
|
|
228
|
+
);
|
|
229
|
+
if (!host) {
|
|
230
|
+
return yield* Effect.fail(new NotSupportedError());
|
|
231
|
+
}
|
|
232
|
+
// The host is discovered by annotation, but the HarnessControl RPC contract is fixed at the
|
|
233
|
+
// spawn site (agent-process declares it as its `rpcs`); narrow the variance-erased
|
|
234
|
+
// `RpcClient<any>` to that contract so the dispatch below is typed. No typed path exists here:
|
|
235
|
+
// the host handle carries `RpcClient<any>` (design spec §4.4) and the concrete definition lives
|
|
236
|
+
// in `@dxos/functions-runtime`, which depends on this package — importing it would cycle.
|
|
237
|
+
return host.rpc as unknown as RpcClient.RpcClient<HarnessControlRpcs>;
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
const isTerminalProcess = (state: Process.State): boolean =>
|
|
241
|
+
state === Process.State.SUCCEEDED || state === Process.State.FAILED || state === Process.State.TERMINATED;
|
|
@@ -13,11 +13,11 @@ import { DXN, Feed, Obj, Ref, Type } from '@dxos/echo';
|
|
|
13
13
|
* When present in a feed, the messages from the referenced feed up to and including
|
|
14
14
|
* `messageId` are prepended to the conversation history before each turn.
|
|
15
15
|
*/
|
|
16
|
-
export
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
16
|
+
export class SessionLink extends Type.makeObject<SessionLink>(DXN.make('org.dxos.type.sessionLink', '0.1.0'))(
|
|
17
|
+
Schema.Struct({
|
|
18
|
+
/** Reference to the feed of the source session. */
|
|
19
|
+
feedRef: Ref.Ref(Feed.Feed),
|
|
20
|
+
/** Messages from the source feed up to and including this message ID are injected. */
|
|
21
|
+
messageId: Obj.ID,
|
|
22
|
+
}),
|
|
23
|
+
) {}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import * as Effect from 'effect/Effect';
|
|
6
6
|
|
|
7
|
-
import { Feed, Filter, Obj } from '@dxos/echo';
|
|
7
|
+
import { Database, Feed, Filter, Obj } from '@dxos/echo';
|
|
8
8
|
import { Message } from '@dxos/types';
|
|
9
9
|
|
|
10
10
|
import * as SessionLink from './SessionLink';
|
|
@@ -21,9 +21,9 @@ export class SessionLoader {
|
|
|
21
21
|
reifyHistory(
|
|
22
22
|
feed: Feed.Feed,
|
|
23
23
|
messages: Message.Message[],
|
|
24
|
-
): Effect.Effect<Message.Message[], never,
|
|
24
|
+
): Effect.Effect<Message.Message[], never, Database.Service> {
|
|
25
25
|
return Effect.gen(function* () {
|
|
26
|
-
const links = yield* Feed.
|
|
26
|
+
const links = yield* Feed.query(feed, Filter.type(SessionLink.SessionLink)).run;
|
|
27
27
|
const sessionLinks = links.filter(Obj.instanceOf(SessionLink.SessionLink));
|
|
28
28
|
|
|
29
29
|
const link = sessionLinks[0];
|
|
@@ -36,7 +36,7 @@ export class SessionLoader {
|
|
|
36
36
|
return messages;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
const sourceMessages = yield* Feed.
|
|
39
|
+
const sourceMessages = yield* Feed.query(sourceFeed, Filter.type(Message.Message)).run;
|
|
40
40
|
const filtered = sourceMessages.filter(Obj.instanceOf(Message.Message));
|
|
41
41
|
|
|
42
42
|
// Sort by creation timestamp so history is in chronological order.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
// @import-as-namespace
|
|
6
|
+
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
|
|
9
|
+
import { Operation, Skill } from '@dxos/compute';
|
|
10
|
+
import { Database, Obj } from '@dxos/echo';
|
|
11
|
+
import { invariant } from '@dxos/invariant';
|
|
12
|
+
import { log } from '@dxos/log';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Tag of the lifecycle point a hook fires at, matching {@link Skill.HookSpec}.
|
|
16
|
+
*/
|
|
17
|
+
export type HookPhase = 'begin-request' | 'end-request';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Invokes a resolved operation with the hook's templated input. Begin and end hooks fire in
|
|
21
|
+
* different contexts (the agent's turn fiber vs. a child process with Tier B reachable), so the
|
|
22
|
+
* invocation strategy is supplied by the caller rather than fixed here. `R` carries the invoker's
|
|
23
|
+
* own requirements (e.g. `Operation.Service`) up into {@link runHooks}.
|
|
24
|
+
*/
|
|
25
|
+
export type HookInvoker<R> = (
|
|
26
|
+
operation: Operation.Definition.Any,
|
|
27
|
+
input: Record<string, unknown>,
|
|
28
|
+
) => Effect.Effect<void, never, R>;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Runs every {@link Skill.Hook} on `skills` whose spec matches `phase`. Each hook resolves its
|
|
32
|
+
* `function` ref to a {@link Operation.PersistentOperation}, deserializes it to a definition, and
|
|
33
|
+
* invokes it with the templated `input` via `invoke`. Hooks without a function ref are skipped.
|
|
34
|
+
*
|
|
35
|
+
* Hook failures are isolated: a throwing hook logs and is swallowed so one skill's hook cannot
|
|
36
|
+
* abort the request lifecycle (or the sibling hooks).
|
|
37
|
+
*/
|
|
38
|
+
export const runHooks = <R>({
|
|
39
|
+
skills,
|
|
40
|
+
phase,
|
|
41
|
+
invoke,
|
|
42
|
+
}: {
|
|
43
|
+
skills: readonly Skill.Skill[];
|
|
44
|
+
phase: HookPhase;
|
|
45
|
+
invoke: HookInvoker<R>;
|
|
46
|
+
}): Effect.Effect<void, never, Database.Service | R> =>
|
|
47
|
+
Effect.gen(function* () {
|
|
48
|
+
for (const skill of skills) {
|
|
49
|
+
for (const hook of skill.hooks ?? []) {
|
|
50
|
+
if (hook.spec._tag !== phase) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (!hook.function) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
yield* Effect.gen(function* () {
|
|
58
|
+
const record = yield* Database.load(hook.function!);
|
|
59
|
+
invariant(Obj.instanceOf(Operation.PersistentOperation, record), 'Hook function must be an operation.');
|
|
60
|
+
const operation = Operation.deserialize(record);
|
|
61
|
+
// Hooks carry no event payload (the spec is a bare tag), so the input template is passed
|
|
62
|
+
// through verbatim — the hook's operation reads any conversation state it needs itself.
|
|
63
|
+
yield* invoke(operation, { ...hook.input });
|
|
64
|
+
}).pipe(
|
|
65
|
+
Effect.catchAllCause((cause) =>
|
|
66
|
+
Effect.sync(() => log.warn('skill hook failed', { phase, skill: Skill.getKey(skill), cause })),
|
|
67
|
+
),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import * as Rpc from '@effect/rpc/Rpc';
|
|
6
|
+
import * as RpcGroup from '@effect/rpc/RpcGroup';
|
|
7
|
+
import * as Schema from 'effect/Schema';
|
|
8
|
+
|
|
9
|
+
import { ContentBlock } from '@dxos/types';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Typed control surface on the live host process that owns a conversation's harness.
|
|
13
|
+
*
|
|
14
|
+
* The same group is declared as the agent process's `rpcs` (so its handlers run on the host's
|
|
15
|
+
* server fiber with the live `AlarmManager`/`inputQueue` in scope) and used by {@link HarnessService}
|
|
16
|
+
* Tier B to dispatch over the discovered host's `Handle.rpc` — keeping both ends on one contract.
|
|
17
|
+
*/
|
|
18
|
+
export const HarnessControl = RpcGroup.make(
|
|
19
|
+
Rpc.make('setAlarm', {
|
|
20
|
+
payload: Schema.Struct({ at: Schema.DateTimeUtc, message: Schema.NullOr(Schema.String) }),
|
|
21
|
+
success: Schema.Void,
|
|
22
|
+
}),
|
|
23
|
+
Rpc.make('enqueueMessage', {
|
|
24
|
+
payload: Schema.Struct({ content: Schema.Array(ContentBlock.Any) }),
|
|
25
|
+
success: Schema.Void,
|
|
26
|
+
}),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
/** Union of the RPCs declared in {@link HarnessControl}. */
|
|
30
|
+
export type HarnessControlRpcs = RpcGroup.Rpcs<typeof HarnessControl>;
|
package/src/session/index.ts
CHANGED
|
@@ -6,5 +6,8 @@ export * from './toolkit';
|
|
|
6
6
|
|
|
7
7
|
export * as AiContext from './AiContext';
|
|
8
8
|
export * as AiSession from './AiSession';
|
|
9
|
+
export * as Harness from './Harness';
|
|
10
|
+
export * as SkillHooks from './SkillHooks';
|
|
11
|
+
export { HarnessControl, type HarnessControlRpcs } from './harness-control';
|
|
9
12
|
export { SessionLoader } from './SessionLoader';
|
|
10
13
|
export * as SessionLink from './SessionLink';
|