@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/assistant",
3
- "version": "0.13.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.13.0",
86
- "@dxos/compute": "0.13.0",
87
- "@dxos/compute-runtime": "0.13.0",
88
- "@dxos/context": "0.13.0",
89
- "@dxos/debug": "0.13.0",
90
- "@dxos/echo": "0.13.0",
91
- "@dxos/echo-client": "0.13.0",
92
- "@dxos/echo-doc": "0.13.0",
93
- "@dxos/echo-protocol": "0.13.0",
94
- "@dxos/effect": "0.13.0",
95
- "@dxos/errors": "0.13.0",
96
- "@dxos/invariant": "0.13.0",
97
- "@dxos/keys": "0.13.0",
98
- "@dxos/log": "0.13.0",
99
- "@dxos/mcp-client": "0.13.0",
100
- "@dxos/node-std": "0.13.0",
101
- "@dxos/protocols": "0.13.0",
102
- "@dxos/schema": "0.13.0",
103
- "@dxos/types": "0.13.0",
104
- "@dxos/util": "0.13.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* () {
@@ -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 RuntimeProvider.runPromise(Effect.succeed(this._runtime))(
288
- Feed.append(this._feed, [
289
- Obj.make(Binding, {
290
- skills: {
291
- added: addedSkills,
292
- removed: [],
293
- },
294
- objects: {
295
- added: addedObjects,
296
- removed: [],
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 removedSkillDxns = (skills ?? []).map((ref) => ref.uri);
314
- const removedObjectDxns = (objects ?? []).map((ref) => ref.uri);
315
- if (removedSkillDxns.length > 0) {
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) => !removedSkillDxns.some((uri) => Obj.getURI(obj) === uri)),
354
+ current.filter((obj) => !removedSkillKeys.has(refKey(Obj.getURI(obj)))),
320
355
  );
321
356
  }
322
- if (removedObjectDxns.length > 0) {
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) => !removedObjectDxns.some((uri) => Obj.getURI(obj) === uri)),
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 RuntimeProvider.runPromise(Effect.succeed(this._runtime))(
332
- Feed.append(this._feed, [
333
- Obj.make(Binding, {
334
- skills: {
335
- added: [],
336
- removed: skills ?? [],
337
- },
338
- objects: {
339
- added: [],
340
- removed: objects ?? [],
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
@@ -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>[];