@dxos/assistant 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/session/AiContext.mjs +86 -22
- package/dist/lib/session/AiContext.mjs.map +1 -1
- package/dist/lib/session/SkillHooks.mjs +3 -3
- package/dist/lib/session/SkillHooks.mjs.map +1 -1
- package/dist/lib/types/Agent.mjs +14 -1
- package/dist/lib/types/Agent.mjs.map +1 -1
- package/dist/types/src/session/AiContext.d.ts +16 -0
- package/dist/types/src/session/AiContext.d.ts.map +1 -1
- package/dist/types/src/session/SkillHooks.d.ts +1 -1
- package/dist/types/src/session/SkillHooks.d.ts.map +1 -1
- package/dist/types/src/types/Agent.d.ts +9 -0
- package/dist/types/src/types/Agent.d.ts.map +1 -1
- package/dist/types/src/types/Chat.d.ts +1 -0
- package/dist/types/src/types/Chat.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +21 -21
- package/src/session/AiContext.test.ts +54 -0
- package/src/session/AiContext.ts +102 -35
- package/src/session/SkillHooks.ts +2 -1
- package/src/types/Agent.ts +15 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dxos/assistant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Natural language interface to DXOS",
|
|
5
5
|
"homepage": "https://dxos.org",
|
|
6
6
|
"bugs": "https://github.com/dxos/dxos/issues",
|
|
@@ -82,26 +82,26 @@
|
|
|
82
82
|
"dependencies": {
|
|
83
83
|
"@automerge/automerge": "3.5.0",
|
|
84
84
|
"lodash.defaultsdeep": "^4.6.1",
|
|
85
|
-
"@dxos/ai": "0.
|
|
86
|
-
"@dxos/compute": "0.
|
|
87
|
-
"@dxos/compute-runtime": "0.
|
|
88
|
-
"@dxos/context": "0.
|
|
89
|
-
"@dxos/debug": "0.
|
|
90
|
-
"@dxos/echo": "0.
|
|
91
|
-
"@dxos/echo-client": "0.
|
|
92
|
-
"@dxos/echo-doc": "0.
|
|
93
|
-
"@dxos/echo-protocol": "0.
|
|
94
|
-
"@dxos/effect": "0.
|
|
95
|
-
"@dxos/errors": "0.
|
|
96
|
-
"@dxos/invariant": "0.
|
|
97
|
-
"@dxos/keys": "0.
|
|
98
|
-
"@dxos/log": "0.
|
|
99
|
-
"@dxos/mcp-client": "0.
|
|
100
|
-
"@dxos/node-std": "0.
|
|
101
|
-
"@dxos/protocols": "0.
|
|
102
|
-
"@dxos/schema": "0.
|
|
103
|
-
"@dxos/types": "0.
|
|
104
|
-
"@dxos/util": "0.
|
|
85
|
+
"@dxos/ai": "0.14.0",
|
|
86
|
+
"@dxos/compute": "0.14.0",
|
|
87
|
+
"@dxos/compute-runtime": "0.14.0",
|
|
88
|
+
"@dxos/context": "0.14.0",
|
|
89
|
+
"@dxos/debug": "0.14.0",
|
|
90
|
+
"@dxos/echo": "0.14.0",
|
|
91
|
+
"@dxos/echo-client": "0.14.0",
|
|
92
|
+
"@dxos/echo-doc": "0.14.0",
|
|
93
|
+
"@dxos/echo-protocol": "0.14.0",
|
|
94
|
+
"@dxos/effect": "0.14.0",
|
|
95
|
+
"@dxos/errors": "0.14.0",
|
|
96
|
+
"@dxos/invariant": "0.14.0",
|
|
97
|
+
"@dxos/keys": "0.14.0",
|
|
98
|
+
"@dxos/log": "0.14.0",
|
|
99
|
+
"@dxos/mcp-client": "0.14.0",
|
|
100
|
+
"@dxos/node-std": "0.14.0",
|
|
101
|
+
"@dxos/protocols": "0.14.0",
|
|
102
|
+
"@dxos/schema": "0.14.0",
|
|
103
|
+
"@dxos/types": "0.14.0",
|
|
104
|
+
"@dxos/util": "0.14.0"
|
|
105
105
|
},
|
|
106
106
|
"devDependencies": {
|
|
107
107
|
"@opentelemetry/sdk-trace-base": "^2.11.0",
|
|
@@ -81,6 +81,60 @@ describe('AiContext.Binder', () => {
|
|
|
81
81
|
.pipe(Effect.runPromise);
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
+
test('a holding binder writes its bindings only when flushed', async ({ expect }) => {
|
|
85
|
+
await Effect.gen(function* () {
|
|
86
|
+
const feed = Feed.make();
|
|
87
|
+
const runtime = yield* Effect.context<Database.Service>();
|
|
88
|
+
const a = yield* Database.add(Obj.make(TypeA, {}));
|
|
89
|
+
const b = yield* Database.add(Obj.make(TypeB, {}));
|
|
90
|
+
|
|
91
|
+
const binder = new AiContext.Binder({ feed, runtime, hold: true });
|
|
92
|
+
yield* Effect.promise(() => binder.open());
|
|
93
|
+
yield* Effect.promise(() => binder.bind({ objects: [Ref.make(a), Ref.make(b)] }));
|
|
94
|
+
yield* Effect.promise(() => binder.unbind({ objects: [Ref.make(b)] }));
|
|
95
|
+
const held = binder.getObjects();
|
|
96
|
+
|
|
97
|
+
yield* Database.add(feed);
|
|
98
|
+
const beforeFlush = yield* Feed.query(feed, Query.type(AiContext.Binding)).run;
|
|
99
|
+
yield* Effect.promise(() => binder.flush());
|
|
100
|
+
yield* Effect.promise(() => binder.close());
|
|
101
|
+
|
|
102
|
+
const reader = new AiContext.Binder({ feed, runtime });
|
|
103
|
+
yield* Effect.promise(() => reader.open());
|
|
104
|
+
const reopened = reader.getObjects();
|
|
105
|
+
yield* Effect.promise(() => reader.close());
|
|
106
|
+
|
|
107
|
+
expect(held.map((obj) => Obj.getURI(obj))).toEqual([Obj.getURI(a)]);
|
|
108
|
+
expect(beforeFlush).toHaveLength(0);
|
|
109
|
+
expect(reopened.map((obj) => Obj.getURI(obj))).toEqual([Obj.getURI(a)]);
|
|
110
|
+
})
|
|
111
|
+
.pipe(Effect.provide(TestLayer))
|
|
112
|
+
.pipe(Effect.runPromise);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('a holding binder resolves a registry skill bound by URI', async ({ expect }) => {
|
|
116
|
+
const registered = Skill.make({ key: 'org.dxos.skill.registered', name: 'Registered' });
|
|
117
|
+
await Effect.gen(function* () {
|
|
118
|
+
const runtime = yield* Effect.context<Database.Service>();
|
|
119
|
+
|
|
120
|
+
const binder = new AiContext.Binder({ feed: Feed.make(), runtime, hold: true });
|
|
121
|
+
yield* Effect.promise(() => binder.open());
|
|
122
|
+
yield* Effect.promise(() =>
|
|
123
|
+
binder.bind({ skills: [Ref.fromURI(Skill.registryURI('org.dxos.skill.registered'))] }),
|
|
124
|
+
);
|
|
125
|
+
const skills = binder.getSkills();
|
|
126
|
+
yield* Effect.promise(() => binder.close());
|
|
127
|
+
|
|
128
|
+
expect(skills.map((skill) => Skill.getKey(skill))).toEqual(['org.dxos.skill.registered']);
|
|
129
|
+
})
|
|
130
|
+
.pipe(
|
|
131
|
+
Effect.provide(
|
|
132
|
+
TestDatabaseLayer({ types: [Feed.Feed, TypeA, TypeB, Skill.Skill, Text.Text], registry: [registered] }),
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
.pipe(Effect.runPromise);
|
|
136
|
+
});
|
|
137
|
+
|
|
84
138
|
// Run between agent turns: a rejected re-read used to fail the whole agent process.
|
|
85
139
|
test('a sync whose query fails keeps the current bindings', async ({ expect }) => {
|
|
86
140
|
await Effect.gen(function* () {
|
package/src/session/AiContext.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { Resource } from '@dxos/context';
|
|
|
18
18
|
import { Database, DXN, Feed, Obj, Query, type QueryResult, Ref, Type } from '@dxos/echo';
|
|
19
19
|
import * as AtomEx from '@dxos/effect/AtomEx';
|
|
20
20
|
import * as RuntimeProvider from '@dxos/effect/RuntimeProvider';
|
|
21
|
-
import { assertArgument } from '@dxos/invariant';
|
|
21
|
+
import { assertArgument, invariant } from '@dxos/invariant';
|
|
22
22
|
import { EID, type URI } from '@dxos/keys';
|
|
23
23
|
import { log } from '@dxos/log';
|
|
24
24
|
import { ComplexSet, isNonNullable } from '@dxos/util';
|
|
@@ -81,18 +81,20 @@ export type BinderOptions = {
|
|
|
81
81
|
runtime: Context.Context<Database.Service>;
|
|
82
82
|
/** @effect/atom-react Registry for reactive state management. */
|
|
83
83
|
registry?: AtomRegistry.AtomRegistry;
|
|
84
|
+
/** Holds bindings in memory until {@link Binder.flush}, so the feed need not be stored yet (a draft conversation). */
|
|
85
|
+
hold?: boolean;
|
|
84
86
|
};
|
|
85
87
|
|
|
86
88
|
/**
|
|
87
89
|
* Manages bindings of skills and objects to a conversation.
|
|
88
90
|
*/
|
|
89
|
-
// TODO(burdon): Context should manage ephemeral state of bindings until prompt is issued?
|
|
90
91
|
export class Binder extends Resource {
|
|
91
92
|
private readonly _skills = Atom.make<Skill.Skill[]>([]).pipe(Atom.keepAlive);
|
|
92
93
|
private readonly _objects = Atom.make<Obj.Unknown[]>([]).pipe(Atom.keepAlive);
|
|
93
94
|
private readonly _registry: AtomRegistry.AtomRegistry;
|
|
94
95
|
private readonly _feed: Feed.Feed;
|
|
95
96
|
private readonly _runtime: Context.Context<Database.Service>;
|
|
97
|
+
private readonly _hold: boolean;
|
|
96
98
|
|
|
97
99
|
#bindingsQuery: QueryResult.QueryResult<Binding> | undefined;
|
|
98
100
|
|
|
@@ -105,6 +107,11 @@ export class Binder extends Resource {
|
|
|
105
107
|
*/
|
|
106
108
|
#bound = { skills: new Set<URI.URI>(), objects: new Set<URI.URI>() };
|
|
107
109
|
|
|
110
|
+
#pending?: Binding[];
|
|
111
|
+
|
|
112
|
+
/** Bumped by each held write, so only the latest resolution of the held bindings lands. */
|
|
113
|
+
#heldGeneration = 0;
|
|
114
|
+
|
|
108
115
|
constructor(options: BinderOptions) {
|
|
109
116
|
super();
|
|
110
117
|
assertArgument(options.feed, 'options.feed', 'Feed is required');
|
|
@@ -112,6 +119,7 @@ export class Binder extends Resource {
|
|
|
112
119
|
this._feed = options.feed;
|
|
113
120
|
this._runtime = options.runtime;
|
|
114
121
|
this._registry = options.registry ?? AtomEx.makeRegistry();
|
|
122
|
+
this._hold = options.hold ?? false;
|
|
115
123
|
}
|
|
116
124
|
|
|
117
125
|
/**
|
|
@@ -157,6 +165,36 @@ export class Binder extends Resource {
|
|
|
157
165
|
}
|
|
158
166
|
|
|
159
167
|
protected override async _open(): Promise<void> {
|
|
168
|
+
if (this._hold) {
|
|
169
|
+
this.#pending = [];
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
await this._follow();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Writes a holding binder's bindings to its feed, which the caller has stored, as one binding. Later
|
|
177
|
+
* writes go straight to the feed, which the binder does not follow.
|
|
178
|
+
*/
|
|
179
|
+
async flush(): Promise<void> {
|
|
180
|
+
const held = this.#pending;
|
|
181
|
+
if (!held) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
invariant(Obj.getDatabase(this._feed), 'The feed must be stored before its bindings are flushed.');
|
|
185
|
+
this.#pending = undefined;
|
|
186
|
+
const { skills, objects } = this._reduce(held);
|
|
187
|
+
if (skills.size > 0 || objects.size > 0) {
|
|
188
|
+
await this._append(
|
|
189
|
+
Obj.make(Binding, {
|
|
190
|
+
skills: { added: [...skills], removed: [] },
|
|
191
|
+
objects: { added: [...objects], removed: [] },
|
|
192
|
+
}),
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private async _follow(): Promise<void> {
|
|
160
198
|
const bindingsQuery = await RuntimeProvider.runPromise(Effect.succeed(this._runtime))(
|
|
161
199
|
Feed.query(this._feed, Query.type(Binding)),
|
|
162
200
|
);
|
|
@@ -284,21 +322,18 @@ export class Binder extends Resource {
|
|
|
284
322
|
this._registry.set(this._objects, nextObjects);
|
|
285
323
|
|
|
286
324
|
log('bind', { skills: addedSkills.length, objects: addedObjects.length });
|
|
287
|
-
await
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
}),
|
|
299
|
-
]),
|
|
325
|
+
await this._write(
|
|
326
|
+
Obj.make(Binding, {
|
|
327
|
+
skills: {
|
|
328
|
+
added: addedSkills,
|
|
329
|
+
removed: [],
|
|
330
|
+
},
|
|
331
|
+
objects: {
|
|
332
|
+
added: addedObjects,
|
|
333
|
+
removed: [],
|
|
334
|
+
},
|
|
335
|
+
}),
|
|
300
336
|
);
|
|
301
|
-
countBindingWrite(this._feed);
|
|
302
337
|
}
|
|
303
338
|
|
|
304
339
|
async unbind({ skills, objects }: BindingProps): Promise<void> {
|
|
@@ -310,38 +345,70 @@ export class Binder extends Resource {
|
|
|
310
345
|
(objects ?? []).forEach((ref) => this.#bound.objects.delete(refKey(ref.uri)));
|
|
311
346
|
|
|
312
347
|
// Immediately update atom state so removals are reflected before the queue round-trips.
|
|
313
|
-
const
|
|
314
|
-
const
|
|
315
|
-
if (
|
|
348
|
+
const removedSkillKeys = new Set((skills ?? []).map((ref) => refKey(ref.uri)));
|
|
349
|
+
const removedObjectKeys = new Set((objects ?? []).map((ref) => refKey(ref.uri)));
|
|
350
|
+
if (removedSkillKeys.size > 0) {
|
|
316
351
|
const current = this._registry.get(this._skills);
|
|
317
352
|
this._registry.set(
|
|
318
353
|
this._skills,
|
|
319
|
-
current.filter((obj) => !
|
|
354
|
+
current.filter((obj) => !removedSkillKeys.has(refKey(Obj.getURI(obj)))),
|
|
320
355
|
);
|
|
321
356
|
}
|
|
322
|
-
if (
|
|
357
|
+
if (removedObjectKeys.size > 0) {
|
|
323
358
|
const current = this._registry.get(this._objects);
|
|
324
359
|
this._registry.set(
|
|
325
360
|
this._objects,
|
|
326
|
-
current.filter((obj) => !
|
|
361
|
+
current.filter((obj) => !removedObjectKeys.has(refKey(Obj.getURI(obj)))),
|
|
327
362
|
);
|
|
328
363
|
}
|
|
329
364
|
|
|
330
365
|
log('unbind', { skills: skills?.length, objects: objects?.length });
|
|
331
|
-
await
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
}),
|
|
343
|
-
]),
|
|
366
|
+
await this._write(
|
|
367
|
+
Obj.make(Binding, {
|
|
368
|
+
skills: {
|
|
369
|
+
added: [],
|
|
370
|
+
removed: skills ?? [],
|
|
371
|
+
},
|
|
372
|
+
objects: {
|
|
373
|
+
added: [],
|
|
374
|
+
removed: objects ?? [],
|
|
375
|
+
},
|
|
376
|
+
}),
|
|
344
377
|
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private async _write(binding: Binding): Promise<void> {
|
|
381
|
+
if (this.#pending) {
|
|
382
|
+
this.#pending.push(binding);
|
|
383
|
+
await this._resolveHeld(this.#pending);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
await this._append(binding);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Sets the atoms from the held bindings. Their refs have no feed query to hydrate them, so each is
|
|
391
|
+
* resolved against the database, which also spans the registry.
|
|
392
|
+
*/
|
|
393
|
+
private async _resolveHeld(held: Binding[]): Promise<void> {
|
|
394
|
+
const generation = ++this.#heldGeneration;
|
|
395
|
+
const { db } = Context.get(this._runtime, Database.Service);
|
|
396
|
+
const { skills, objects } = this._reduce(held);
|
|
397
|
+
const hydrate = <T extends Obj.Unknown>(refs: Iterable<Ref.Ref<T>>): Ref.Ref<T>[] =>
|
|
398
|
+
[...refs].map((ref) => (ref.isAvailable ? ref : db.makeRef<T>(ref.uri)));
|
|
399
|
+
const [resolvedSkills, resolvedObjects] = await Promise.all([
|
|
400
|
+
this._resolve(hydrate(skills), this._registry.get(this._skills)),
|
|
401
|
+
this._resolve(hydrate(objects), this._registry.get(this._objects)),
|
|
402
|
+
]);
|
|
403
|
+
if (generation !== this.#heldGeneration) {
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
this._registry.set(this._skills, resolvedSkills);
|
|
407
|
+
this._registry.set(this._objects, resolvedObjects);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private async _append(binding: Binding): Promise<void> {
|
|
411
|
+
await RuntimeProvider.runPromise(Effect.succeed(this._runtime))(Feed.append(this._feed, [binding]));
|
|
345
412
|
countBindingWrite(this._feed);
|
|
346
413
|
}
|
|
347
414
|
|
|
@@ -26,6 +26,7 @@ export type HookPhase = 'begin-request' | 'end-request';
|
|
|
26
26
|
export type HookInvoker<R> = (
|
|
27
27
|
operation: Operation.Definition.Any,
|
|
28
28
|
input: Record<string, unknown>,
|
|
29
|
+
hook: Skill.Hook,
|
|
29
30
|
) => Effect.Effect<void, never, R>;
|
|
30
31
|
|
|
31
32
|
/**
|
|
@@ -61,7 +62,7 @@ export const runHooks = <R>({
|
|
|
61
62
|
const operation = Operation.deserialize(record);
|
|
62
63
|
// Hooks carry no event payload (the spec is a bare tag), so the input template is passed
|
|
63
64
|
// through verbatim — the hook's operation reads any conversation state it needs itself.
|
|
64
|
-
yield* invoke(operation, { ...hook.input });
|
|
65
|
+
yield* invoke(operation, { ...hook.input }, hook);
|
|
65
66
|
}).pipe(
|
|
66
67
|
Effect.catchCause((cause) =>
|
|
67
68
|
// Meta key rather than `Skill.getKey`: a space-authored skill has none, and an error
|
package/src/types/Agent.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import * as Effect from 'effect/Effect';
|
|
8
8
|
import * as Schema from 'effect/Schema';
|
|
9
9
|
|
|
10
|
+
import type * as AgentService from '@dxos/compute/AgentService';
|
|
10
11
|
import * as Instructions from '@dxos/compute/Instructions';
|
|
11
12
|
import * as Skill from '@dxos/compute/Skill';
|
|
12
13
|
import { Annotation, Database, DXN, type Error as EchoError, Feed, Filter, Obj, Ref, Type } from '@dxos/echo';
|
|
@@ -127,6 +128,20 @@ export const loadForChat = (chat: Chat.Chat): Effect.Effect<Agent | undefined> =
|
|
|
127
128
|
return parent && Obj.instanceOf(Agent, parent) ? parent : undefined;
|
|
128
129
|
});
|
|
129
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Where a chat's turns run: as its `remote` flag says, else on EDGE for an agent's chat, since its chats share
|
|
133
|
+
* the agent's one brain, which only EDGE holds. An explicit `remote: false` keeps an agent's chats local, for
|
|
134
|
+
* a runtime with no EDGE to reach (a story or test), where every chat shares the client's brain instead.
|
|
135
|
+
*/
|
|
136
|
+
export const chatLocation = (chat: Chat.Chat): AgentService.AgentLocation =>
|
|
137
|
+
(chat.remote ?? isAgentChat(chat)) ? 'edge' : 'local';
|
|
138
|
+
|
|
139
|
+
/** Whether the chat runs as an agent (the agent parents it); synchronous, like {@link loadForChat}. */
|
|
140
|
+
export const isAgentChat = (chat: Chat.Chat): boolean => {
|
|
141
|
+
const parent = Obj.getParent(chat);
|
|
142
|
+
return parent !== undefined && Obj.instanceOf(Agent, parent);
|
|
143
|
+
};
|
|
144
|
+
|
|
130
145
|
export type MakeProps = Omit<Obj.MakeProps<typeof Agent>, 'instructions'> & {
|
|
131
146
|
instructions: string;
|
|
132
147
|
skills?: Ref.Ref<Skill.Skill>[];
|