@dxos/assistant 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/lib/extraction.mjs +169 -0
  2. package/dist/lib/extraction.mjs.map +1 -0
  3. package/dist/lib/index.mjs +1958 -0
  4. package/dist/lib/index.mjs.map +1 -0
  5. package/dist/types/src/request/AiRequest.d.ts +6 -3
  6. package/dist/types/src/request/AiRequest.d.ts.map +1 -1
  7. package/dist/types/src/request/AiRequest.test.d.ts +2 -0
  8. package/dist/types/src/request/AiRequest.test.d.ts.map +1 -0
  9. package/dist/types/src/request/format.d.ts +2 -2
  10. package/dist/types/src/request/format.d.ts.map +1 -1
  11. package/dist/types/src/request/format.test.d.ts +2 -0
  12. package/dist/types/src/request/format.test.d.ts.map +1 -0
  13. package/dist/types/src/session/AiContext.d.ts +1 -1
  14. package/dist/types/src/session/AiContext.d.ts.map +1 -1
  15. package/dist/types/src/session/AiSession.d.ts +25 -4
  16. package/dist/types/src/session/AiSession.d.ts.map +1 -1
  17. package/dist/types/src/session/harness-control.d.ts +4 -0
  18. package/dist/types/src/session/harness-control.d.ts.map +1 -1
  19. package/dist/types/src/tool-runtime/services.d.ts +7 -0
  20. package/dist/types/src/tool-runtime/services.d.ts.map +1 -1
  21. package/dist/types/src/tool-runtime/services.test.d.ts +2 -0
  22. package/dist/types/src/tool-runtime/services.test.d.ts.map +1 -0
  23. package/dist/types/src/util/execution-graph.d.ts.map +1 -1
  24. package/dist/types/src/util/tracing.d.ts +10 -0
  25. package/dist/types/src/util/tracing.d.ts.map +1 -1
  26. package/dist/types/tsconfig.tsbuildinfo +1 -1
  27. package/package.json +25 -25
  28. package/src/extraction/quotes.test.ts +4 -4
  29. package/src/request/AiRequest.test.ts +110 -0
  30. package/src/request/AiRequest.ts +11 -4
  31. package/src/request/format.test.ts +93 -0
  32. package/src/request/format.ts +48 -11
  33. package/src/session/AiContext.ts +1 -1
  34. package/src/session/AiSession.test.ts +124 -0
  35. package/src/session/AiSession.ts +81 -8
  36. package/src/tool-runtime/services.test.ts +41 -0
  37. package/src/tool-runtime/services.ts +16 -1
  38. package/src/util/execution-graph.ts +1 -1
  39. package/dist/lib/neutral/chunk-J5LGTIGS.mjs +0 -10
  40. package/dist/lib/neutral/chunk-J5LGTIGS.mjs.map +0 -7
  41. package/dist/lib/neutral/extraction/index.mjs +0 -197
  42. package/dist/lib/neutral/extraction/index.mjs.map +0 -7
  43. package/dist/lib/neutral/index.mjs +0 -2047
  44. package/dist/lib/neutral/index.mjs.map +0 -7
  45. package/dist/lib/neutral/meta.json +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/assistant",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "Natural language interface to DXOS",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -16,12 +16,12 @@
16
16
  ".": {
17
17
  "source": "./src/index.ts",
18
18
  "types": "./dist/types/src/index.d.ts",
19
- "default": "./dist/lib/neutral/index.mjs"
19
+ "import": "./dist/lib/index.mjs"
20
20
  },
21
21
  "./extraction": {
22
22
  "source": "./src/extraction/index.ts",
23
23
  "types": "./dist/types/src/extraction/index.d.ts",
24
- "default": "./dist/lib/neutral/extraction/index.mjs"
24
+ "import": "./dist/lib/extraction.mjs"
25
25
  }
26
26
  },
27
27
  "types": "dist/types/src/index.d.ts",
@@ -39,28 +39,28 @@
39
39
  "@effect/rpc": "0.75.1",
40
40
  "@xenova/transformers": "^2.17.2",
41
41
  "lodash.defaultsdeep": "^4.6.1",
42
- "@dxos/async": "0.10.0",
43
- "@dxos/ai": "0.10.0",
44
- "@dxos/compute-runtime": "0.10.0",
45
- "@dxos/context": "0.10.0",
46
- "@dxos/compute": "0.10.0",
47
- "@dxos/debug": "0.10.0",
48
- "@dxos/echo": "0.10.0",
49
- "@dxos/echo-client": "0.10.0",
50
- "@dxos/echo-doc": "0.10.0",
51
- "@dxos/echo-protocol": "0.10.0",
52
- "@dxos/effect": "0.10.0",
53
- "@dxos/errors": "0.10.0",
54
- "@dxos/functions": "0.10.0",
55
- "@dxos/graph": "0.10.0",
56
- "@dxos/invariant": "0.10.0",
57
- "@dxos/keys": "0.10.0",
58
- "@dxos/log": "0.10.0",
59
- "@dxos/mcp-client": "0.10.0",
60
- "@dxos/schema": "0.10.0",
61
- "@dxos/util": "0.10.0",
62
- "@dxos/node-std": "0.10.0",
63
- "@dxos/types": "0.10.0"
42
+ "@dxos/compute": "0.11.1",
43
+ "@dxos/compute-runtime": "0.11.1",
44
+ "@dxos/context": "0.11.1",
45
+ "@dxos/ai": "0.11.1",
46
+ "@dxos/async": "0.11.1",
47
+ "@dxos/debug": "0.11.1",
48
+ "@dxos/echo": "0.11.1",
49
+ "@dxos/echo-client": "0.11.1",
50
+ "@dxos/echo-doc": "0.11.1",
51
+ "@dxos/echo-protocol": "0.11.1",
52
+ "@dxos/graph": "0.11.1",
53
+ "@dxos/effect": "0.11.1",
54
+ "@dxos/invariant": "0.11.1",
55
+ "@dxos/keys": "0.11.1",
56
+ "@dxos/log": "0.11.1",
57
+ "@dxos/errors": "0.11.1",
58
+ "@dxos/mcp-client": "0.11.1",
59
+ "@dxos/types": "0.11.1",
60
+ "@dxos/node-std": "0.11.1",
61
+ "@dxos/schema": "0.11.1",
62
+ "@dxos/util": "0.11.1",
63
+ "@dxos/protocols": "0.11.1"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@types/lodash.defaultsdeep": "^4.6.6",
@@ -17,7 +17,7 @@ describe('insertReferences', () => {
17
17
  };
18
18
 
19
19
  expect(insertReferences('This is a computational irreducibility test.', quotes)).toBe(
20
- 'This is a [computational irreducibility](echo:/01JTG9JW11XGWJZ32AW8ET93D1) test.',
20
+ 'This is a [computational irreducibility](echo:///01JTG9JW11XGWJZ32AW8ET93D1) test.',
21
21
  );
22
22
  expect(
23
23
  insertReferences(
@@ -25,7 +25,7 @@ describe('insertReferences', () => {
25
25
  quotes,
26
26
  ),
27
27
  ).toBe(
28
- "And what I'd like to talk today about is Steven Wolfram's concept of a [computational irreducibility](echo:/01JTG9JW11XGWJZ32AW8ET93D1).",
28
+ "And what I'd like to talk today about is Steven Wolfram's concept of a [computational irreducibility](echo:///01JTG9JW11XGWJZ32AW8ET93D1).",
29
29
  );
30
30
  });
31
31
 
@@ -40,7 +40,7 @@ describe('insertReferences', () => {
40
40
  // "Sarah Johnson" is linked as a whole (processed first); the standalone "Sarah" is linked
41
41
  // separately; the "Sarah" inside the first link is left untouched (no nested `[[..](..)](..)`).
42
42
  expect(insertReferences('Sarah Johnson spoke. Later Sarah left.', quotes)).toBe(
43
- '[Sarah Johnson](echo:/01JTG9JW11XGWJZ32AW8ET93D1) spoke. Later [Sarah](echo:/01JTG9JW11XGWJZ32AW8ET93D1) left.',
43
+ '[Sarah Johnson](echo:///01JTG9JW11XGWJZ32AW8ET93D1) spoke. Later [Sarah](echo:///01JTG9JW11XGWJZ32AW8ET93D1) left.',
44
44
  );
45
45
  });
46
46
 
@@ -48,7 +48,7 @@ describe('insertReferences', () => {
48
48
  const quotes = { references: [{ quote: 'Amco', id: '01JTG9JW11XGWJZ32AW8ET93D1' }] };
49
49
 
50
50
  expect(insertReferences('Amco and Amco again.', quotes)).toBe(
51
- '[Amco](echo:/01JTG9JW11XGWJZ32AW8ET93D1) and [Amco](echo:/01JTG9JW11XGWJZ32AW8ET93D1) again.',
51
+ '[Amco](echo:///01JTG9JW11XGWJZ32AW8ET93D1) and [Amco](echo:///01JTG9JW11XGWJZ32AW8ET93D1) again.',
52
52
  );
53
53
  });
54
54
  });
@@ -0,0 +1,110 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Tool from '@effect/ai/Tool';
6
+ import * as Toolkit from '@effect/ai/Toolkit';
7
+ import { describe, expect, it } from '@effect/vitest';
8
+ import * as Effect from 'effect/Effect';
9
+ import * as Layer from 'effect/Layer';
10
+ import * as Schema from 'effect/Schema';
11
+
12
+ import { OpaqueToolkit, ToolExecutionService, ToolResolverService } from '@dxos/ai';
13
+ import { ScriptedLanguageModel } from '@dxos/ai/testing';
14
+ import { Trace } from '@dxos/compute';
15
+ import { operationServiceLayerNoop } from '@dxos/compute/testing';
16
+ import { TestDatabaseLayer } from '@dxos/echo-client/testing';
17
+ import { registryLayerNoop } from '@dxos/echo/testing';
18
+ import { ContentBlock, type Message } from '@dxos/types';
19
+
20
+ import * as AiRequest from './AiRequest';
21
+
22
+ const { text, toolCall, scriptedLanguageModelLayer } = ScriptedLanguageModel;
23
+
24
+ // Real handler, so a scripted tool call drives a genuine tool-call → result → continue cycle.
25
+ const TestToolkit = Toolkit.make(
26
+ Tool.make('Echo', {
27
+ description: 'Returns its input value verbatim.',
28
+ parameters: {
29
+ value: Schema.String.annotations({ description: 'The value to echo.' }),
30
+ },
31
+ success: Schema.Struct({ value: Schema.String }),
32
+ failure: Schema.Never,
33
+ }),
34
+ );
35
+
36
+ const toolkit = OpaqueToolkit.make(
37
+ TestToolkit,
38
+ TestToolkit.toLayer({
39
+ Echo: ({ value }) => Effect.succeed({ value }),
40
+ }),
41
+ );
42
+
43
+ // `RunRequirements` types several services `run()` never yields on this path, hence the noops below.
44
+ const testLayer = (turns: readonly ScriptedLanguageModel.ScriptedTurn[]) =>
45
+ Layer.mergeAll(
46
+ scriptedLanguageModelLayer(turns),
47
+ ToolExecutionService.layerEmpty,
48
+ ToolResolverService.layerEmpty,
49
+ TestDatabaseLayer(),
50
+ registryLayerNoop,
51
+ operationServiceLayerNoop,
52
+ Trace.testTraceService().pipe(Layer.provide(Trace.layerNoop)),
53
+ );
54
+
55
+ // Lower-level counterpart to `scripted-loop.test.ts` (`@dxos/agent-runtime`): same loop shapes, no `AssistantTestLayer`.
56
+ describe('AiRequest.Request.run (scripted model)', () => {
57
+ it.effect('completes in one turn when the model emits no tool calls', () =>
58
+ Effect.gen(function* () {
59
+ const request = new AiRequest.Request();
60
+ const messages = yield* request.run({ prompt: 'Say hi.', history: [] });
61
+
62
+ expect(textOf(messages)).toContain('Hi there.');
63
+ expect(request.toolCalls).toEqual(0);
64
+ }).pipe(Effect.provide(testLayer([{ parts: [text('Hi there.')] }]))),
65
+ );
66
+
67
+ it.effect('executes a tool call, feeds the result back, then stops', () =>
68
+ Effect.gen(function* () {
69
+ const request = new AiRequest.Request();
70
+ const messages = yield* request.run({ toolkit, prompt: 'Echo hello.', history: [] });
71
+
72
+ expect(toolResultsOf(messages)).toHaveLength(1);
73
+ expect(textOf(messages)).toContain('Echoed the value.');
74
+ expect(request.toolCalls).toEqual(1);
75
+ }).pipe(
76
+ Effect.provide(
77
+ testLayer([{ parts: [toolCall('Echo', { value: 'hello' })] }, { parts: [text('Echoed the value.')] }]),
78
+ ),
79
+ ),
80
+ );
81
+
82
+ it.effect('iterates until the model stops, feeding each tool result back', () =>
83
+ Effect.gen(function* () {
84
+ const request = new AiRequest.Request();
85
+ const messages = yield* request.run({ toolkit, prompt: 'Echo twice.', history: [] });
86
+
87
+ expect(toolResultsOf(messages)).toHaveLength(2);
88
+ expect(textOf(messages)).toContain('All done.');
89
+ expect(request.toolCalls).toEqual(2);
90
+ }).pipe(
91
+ Effect.provide(
92
+ testLayer([
93
+ { parts: [toolCall('Echo', { value: 'first' })] },
94
+ { parts: [toolCall('Echo', { value: 'second' })] },
95
+ { parts: [text('All done.')] },
96
+ ]),
97
+ ),
98
+ ),
99
+ );
100
+ });
101
+
102
+ const textOf = (messages: readonly Message.Message[]): string =>
103
+ messages
104
+ .flatMap((message) => message.blocks)
105
+ .filter(ContentBlock.is('text'))
106
+ .map((block) => block.text)
107
+ .join('');
108
+
109
+ const toolResultsOf = (messages: readonly Message.Message[]) =>
110
+ messages.flatMap((message) => message.blocks).filter(ContentBlock.is('toolResult'));
@@ -26,7 +26,7 @@ import {
26
26
  callTool,
27
27
  withoutToolCallParising,
28
28
  } from '@dxos/ai';
29
- import { Operation, type Skill, Trace } from '@dxos/compute';
29
+ import { type Instructions, Operation, type Skill, Trace } from '@dxos/compute';
30
30
  import { Database, Obj, Registry } from '@dxos/echo';
31
31
  import { log } from '@dxos/log';
32
32
  import { ContentBlock, Message } from '@dxos/types';
@@ -76,6 +76,8 @@ export type RunProps<R = never> = {
76
76
  history?: Message.Message[];
77
77
  objects?: Obj.Unknown[];
78
78
  skills?: readonly Skill.Skill[];
79
+ /** Rendered inline into the system prompt; passed explicitly rather than inferred from `objects`. */
80
+ instructions?: readonly Instructions.Instructions[];
79
81
  toolkit?: OpaqueToolkit.OpaqueToolkit<R>;
80
82
  };
81
83
 
@@ -85,6 +87,7 @@ export type BeginProps = {
85
87
  history?: Message.Message[];
86
88
  objects?: Obj.Unknown[];
87
89
  skills?: readonly Skill.Skill[];
90
+ instructions?: readonly Instructions.Instructions[];
88
91
  };
89
92
 
90
93
  export type TurnProps<R = never> = {
@@ -191,13 +194,14 @@ export class Request {
191
194
  history = [],
192
195
  skills = [],
193
196
  objects = [],
197
+ instructions = [],
194
198
  }: BeginProps): Effect.Effect<void, RunError, RunRequirements> =>
195
199
  Effect.gen(this, function* () {
196
200
  this._started = Date.now();
197
201
  this._history = [...history];
198
202
  this._pending = [];
199
203
 
200
- const systemPrompt = yield* formatSystemPrompt({ system, skills, objects }).pipe(Effect.orDie);
204
+ const systemPrompt = yield* formatSystemPrompt({ system, skills, objects, instructions }).pipe(Effect.orDie);
201
205
 
202
206
  if (this._options.summarizationThreshold !== undefined) {
203
207
  const tokenCount = yield* AiPreprocessor.estimateTokens(
@@ -345,12 +349,15 @@ export class Request {
345
349
  history = [],
346
350
  objects = [],
347
351
  skills = [],
352
+ instructions = [],
348
353
  toolkit,
349
354
  }: RunProps<R>): Effect.Effect<Message.Message[], RunError, RunRequirements | R> =>
350
355
  Effect.gen(this, function* () {
351
- yield* this.begin({ prompt, system: systemTemplate, history, objects, skills });
356
+ yield* this.begin({ prompt, system: systemTemplate, history, objects, skills, instructions });
352
357
 
353
- const system = yield* formatSystemPrompt({ system: systemTemplate, skills, objects }).pipe(Effect.orDie);
358
+ const system = yield* formatSystemPrompt({ system: systemTemplate, skills, objects, instructions }).pipe(
359
+ Effect.orDie,
360
+ );
354
361
 
355
362
  do {
356
363
  const { done, finishReason } = yield* this.runAgentTurn({ system, toolkit });
@@ -0,0 +1,93 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, expect, it } from '@effect/vitest';
6
+ import * as Effect from 'effect/Effect';
7
+ import * as Layer from 'effect/Layer';
8
+
9
+ import { Instructions, Project } from '@dxos/compute';
10
+ import { operationServiceLayerNoop } from '@dxos/compute/testing';
11
+ import { Database, Obj, Ref, URI } from '@dxos/echo';
12
+ import { TestDatabaseLayer } from '@dxos/echo-client/testing';
13
+ import { registryLayerNoop } from '@dxos/echo/testing';
14
+ import { Text } from '@dxos/schema';
15
+
16
+ import { formatSystemPrompt } from './format';
17
+
18
+ const testLayer = () =>
19
+ Layer.mergeAll(
20
+ TestDatabaseLayer({ types: [Instructions.Instructions, Project.Project, Text.Text] }),
21
+ registryLayerNoop,
22
+ operationServiceLayerNoop,
23
+ );
24
+
25
+ describe('formatSystemPrompt', () => {
26
+ it.effect('renders Instructions inline (text + commands), not as object stubs', () =>
27
+ Effect.gen(function* () {
28
+ const instructions = yield* Database.add(
29
+ Instructions.make({
30
+ name: 'Instructions',
31
+ text: 'Answer like a pirate.',
32
+ commands: [
33
+ { sentinel: '$track', description: 'Track a follow-up', prompt: 'Append the item to the task list.' },
34
+ ],
35
+ }),
36
+ );
37
+
38
+ const prompt = yield* formatSystemPrompt({ system: 'Base prompt.', instructions: [instructions] });
39
+ expect(prompt).toContain('Base prompt.');
40
+ expect(prompt).toContain('## Instructions');
41
+ expect(prompt).toContain('Answer like a pirate.');
42
+ expect(prompt).toContain('`$track` (Track a follow-up): Append the item to the task list.');
43
+ // The instructions must not degrade to a load-it-yourself context stub.
44
+ expect(prompt).not.toContain('## Context Objects');
45
+ }).pipe(Effect.provide(testLayer())),
46
+ );
47
+
48
+ it.effect('degrades to a commands-only block when the text ref is broken', () =>
49
+ Effect.gen(function* () {
50
+ const instructions = yield* Database.add(
51
+ Instructions.make({
52
+ name: 'Instructions',
53
+ text: 'unreachable',
54
+ commands: [{ sentinel: '$go', prompt: 'Go.' }],
55
+ }),
56
+ );
57
+ yield* Database.flush();
58
+ Obj.update(instructions, (instructions) => {
59
+ instructions.text = Ref.fromURI(URI.make('dxn:echo:@:missing-text'));
60
+ });
61
+
62
+ const prompt = yield* formatSystemPrompt({ instructions: [instructions] });
63
+ expect(prompt).toContain('## Instructions');
64
+ expect(prompt).toContain('`$go`: Go.');
65
+ expect(prompt).not.toContain('unreachable');
66
+ }).pipe(Effect.provide(testLayer())),
67
+ );
68
+
69
+ it.effect('treats a bound Instructions object as an ordinary context stub', () =>
70
+ Effect.gen(function* () {
71
+ // Steering is declared by the `instructions` parameter, so a bound Instructions object is
72
+ // subject matter (e.g. "help me edit these") rather than something the session must obey.
73
+ const instructions = yield* Database.add(Instructions.make({ name: 'Draft', text: 'Answer like a pirate.' }));
74
+ const prompt = yield* formatSystemPrompt({ objects: [instructions] });
75
+ expect(prompt).toContain('## Context Objects');
76
+ expect(prompt).toContain('<label>Draft</label>');
77
+ expect(prompt).not.toContain('## Instructions');
78
+ expect(prompt).not.toContain('Answer like a pirate.');
79
+ }).pipe(Effect.provide(testLayer())),
80
+ );
81
+
82
+ it.effect('keeps non-instructions objects as context stubs, carrying their label', () =>
83
+ Effect.gen(function* () {
84
+ const doc = yield* Database.add(Text.make({ content: 'plain object' }));
85
+ const project = yield* Database.add(Project.make({ name: 'Voyage' }));
86
+ const prompt = yield* formatSystemPrompt({ objects: [doc, project] });
87
+ expect(prompt).toContain('## Context Objects');
88
+ expect(prompt).toContain(Obj.getURI(doc));
89
+ expect(prompt).toContain('<label>Voyage</label>');
90
+ expect(prompt).not.toContain('## Instructions');
91
+ }).pipe(Effect.provide(testLayer())),
92
+ );
93
+ });
@@ -7,7 +7,7 @@ import * as Function from 'effect/Function';
7
7
  import * as Option from 'effect/Option';
8
8
 
9
9
  import { type FunctionNotFoundError, type Operation, Template } from '@dxos/compute';
10
- import { type Database, Obj, type Registry } from '@dxos/echo';
10
+ import { Database, Obj, type Registry } from '@dxos/echo';
11
11
  import { ObjectVersion } from '@dxos/echo-client';
12
12
  import { type EntityNotFoundError } from '@dxos/echo/Err';
13
13
  import { type EntityId } from '@dxos/keys';
@@ -27,7 +27,8 @@ export const formatSystemPrompt = ({
27
27
  system,
28
28
  skills = [],
29
29
  objects = [],
30
- }: Pick<AiRequest.RunProps, 'system' | 'skills' | 'objects'>): Effect.Effect<
30
+ instructions = [],
31
+ }: Pick<AiRequest.RunProps, 'system' | 'skills' | 'objects' | 'instructions'>): Effect.Effect<
31
32
  string,
32
33
  FunctionNotFoundError | EntityNotFoundError,
33
34
  Database.Service | Registry.Service | Operation.Service
@@ -50,21 +51,57 @@ export const formatSystemPrompt = ({
50
51
  Effect.map((skills) => (skills.length > 0 ? ['## Skills Definitions', ...skills].join('\n\n') : undefined)),
51
52
  );
52
53
 
54
+ // Instructions steer the session, so their text (and sentinel commands) belongs in the system
55
+ // prompt itself — a context-object stub would force the model to tool-load them and follow nothing.
56
+ const instructionDefs = yield* Function.pipe(
57
+ instructions,
58
+ Effect.forEach((instructions) =>
59
+ Effect.gen(function* () {
60
+ // A broken text ref degrades to commands-only rather than failing the whole prompt.
61
+ const text = yield* Database.load(instructions.text).pipe(
62
+ Effect.map((doc) => doc.content.trim()),
63
+ Effect.catchTag('EntityNotFoundError', () => Effect.succeed('')),
64
+ );
65
+ const commands = (instructions.commands ?? []).map(
66
+ ({ sentinel, description, prompt }) =>
67
+ `- \`${sentinel}\`${description ? ` (${description})` : ''}: ${prompt}`,
68
+ );
69
+ const parts = [text];
70
+ if (commands.length > 0) {
71
+ parts.push(
72
+ ['When the user message contains one of these sentinel commands, follow its prompt:', ...commands].join(
73
+ '\n',
74
+ ),
75
+ );
76
+ }
77
+ return `<instructions>\n${parts.filter(Boolean).join('\n\n')}\n</instructions>`;
78
+ }),
79
+ ),
80
+ Effect.map((defs) => (defs.length > 0 ? ['## Instructions', ...defs].join('\n\n') : undefined)),
81
+ );
82
+
53
83
  const objectDefs = yield* Function.pipe(
54
84
  objects,
55
- Effect.forEach((object) =>
56
- Effect.succeed(trim`
57
- <object>
58
- <dxn>${Obj.getURI(object)}</dxn>
59
- <typename>${Obj.getTypename(object)}</typename>
60
- </object>
61
- `),
62
- ),
85
+ Effect.forEach((object) => {
86
+ // Carry the label so the model only tool-loads an object when it needs the contents,
87
+ // not just to learn what the reference is.
88
+ const label = Obj.getLabel(object);
89
+ return Effect.succeed(
90
+ trim`
91
+ <object>
92
+ <dxn>${Obj.getURI(object)}</dxn>
93
+ <typename>${Obj.getTypename(object)}</typename>${label ? `\n <label>${label}</label>` : ''}
94
+ </object>
95
+ `,
96
+ );
97
+ }),
63
98
  Effect.map((objects) => (objects.length > 0 ? ['## Context Objects', ...objects].join('\n\n') : undefined)),
64
99
  );
65
100
 
66
101
  return yield* Function.pipe(
67
- Effect.succeed([system, skillDefs, objectDefs].filter((def): def is string => def !== undefined)),
102
+ Effect.succeed(
103
+ [system, instructionDefs, skillDefs, objectDefs].filter((def): def is string => def !== undefined),
104
+ ),
68
105
  Effect.map((parts) => parts.join('\n\n')),
69
106
  );
70
107
  }).pipe(Effect.withSpan('formatSystemPrompt'));
@@ -4,7 +4,7 @@
4
4
 
5
5
  // @import-as-namespace
6
6
 
7
- import { Atom, Registry as AtomRegistry } from '@effect-atom/atom-react';
7
+ import { Atom, Registry as AtomRegistry } from '@effect-atom/atom';
8
8
  import * as EArray from 'effect/Array';
9
9
  import * as Function from 'effect/Function';
10
10
  import * as Runtime from 'effect/Runtime';
@@ -108,4 +108,128 @@ describe('AiSession.Session.getHistory', () => {
108
108
  expect(result.map((msg) => msg.id)).toEqual([forkMsg.id]);
109
109
  }).pipe(Effect.provide(TestLayer)),
110
110
  );
111
+
112
+ // Soft fork: the continuation names an earlier message as its lineage parent, so everything appended
113
+ // between the two is unreachable and must not reach the model.
114
+ it.effect('excludes turns a soft fork left unreachable', () =>
115
+ Effect.gen(function* () {
116
+ const { db } = yield* Database.Service;
117
+ const feed = db.add(Feed.make());
118
+
119
+ const prompt1 = makeMessage('first question');
120
+ const answer1 = makeMessage('first answer', 'assistant');
121
+ const prompt2 = makeMessage('abandoned question');
122
+ const answer2 = makeMessage('abandoned answer', 'assistant');
123
+ yield* Feed.append(feed, [prompt1, answer1, prompt2, answer2]);
124
+
125
+ // Continue from the first answer rather than the tip.
126
+ const retry = makeMessage('better question');
127
+ yield* Feed.append(feed, [retry], { parent: answer1 });
128
+
129
+ const runtime = yield* Effect.runtime<Database.Service>();
130
+ const session = new AiSession.Session({ feed, runtime });
131
+
132
+ const result = yield* Effect.promise(() => session.getHistory());
133
+
134
+ expect(result.map((msg) => msg.id)).toEqual([prompt1.id, answer1.id, retry.id]);
135
+ }).pipe(Effect.provide(TestLayer)),
136
+ );
137
+
138
+ it.effect('returns the whole feed when nothing has been forked', () =>
139
+ Effect.gen(function* () {
140
+ const { db } = yield* Database.Service;
141
+ const feed = db.add(Feed.make());
142
+
143
+ const messages = [makeMessage('one'), makeMessage('two', 'assistant'), makeMessage('three')];
144
+ yield* Feed.append(feed, messages);
145
+
146
+ const runtime = yield* Effect.runtime<Database.Service>();
147
+ const session = new AiSession.Session({ feed, runtime });
148
+
149
+ const result = yield* Effect.promise(() => session.getHistory());
150
+
151
+ expect(result.map((msg) => msg.id)).toEqual(messages.map((msg) => msg.id));
152
+ }).pipe(Effect.provide(TestLayer)),
153
+ );
154
+ });
155
+
156
+ //
157
+ // Fork point (soft fork write side).
158
+ //
159
+ // The rewind decision is made in the UI but the continuation is appended by the agent's process, which
160
+ // resolves the feed and never sees the chat. The feed carries the pending intent; the session consumes it
161
+ // on the turn's first message, turning the fork into lineage.
162
+ //
163
+
164
+ describe('AiSession.Session rewind', () => {
165
+ const TestLayer = TestDatabaseLayer({ types: [Feed.Feed, Message.Message, SessionLink.SessionLink] });
166
+
167
+ it.effect("parents the turn's first message to what precedes the rewind", () =>
168
+ Effect.gen(function* () {
169
+ const { db } = yield* Database.Service;
170
+ const feed = db.add(Feed.make());
171
+
172
+ const first = makeMessage('first');
173
+ const answer = makeMessage('answer', 'assistant');
174
+ const abandoned = makeMessage('abandoned');
175
+ yield* Feed.append(feed, [first, answer, abandoned]);
176
+
177
+ Obj.update(feed, (feed) => {
178
+ feed.rewindFrom = abandoned.id;
179
+ });
180
+
181
+ const runtime = yield* Effect.runtime<Database.Service>();
182
+ const session = new AiSession.Session({ feed, runtime });
183
+
184
+ // `rewindFrom` names the earliest discarded message, so the continuation parents to the one
185
+ // before it. Stands in for the request loop: `onOutput` is the single funnel every persisted
186
+ // message passes through, and the retry is this turn's first.
187
+ const retry = makeMessage('retry');
188
+ yield* Effect.promise(() => session.appendTurnMessage(retry));
189
+
190
+ expect(Feed.getParent(retry)).toBe(answer.id);
191
+ // Consumed: a second message in the same turn chains implicitly, not onto the parent again.
192
+ expect(feed.rewindFrom).toBeUndefined();
193
+
194
+ const followUp = makeMessage('follow up', 'assistant');
195
+ yield* Effect.promise(() => session.appendTurnMessage(followUp));
196
+ expect(Feed.getParent(followUp)).toBeUndefined();
197
+ }).pipe(Effect.provide(TestLayer)),
198
+ );
199
+
200
+ it.effect('leaves messages unparented when no rewind is pending', () =>
201
+ Effect.gen(function* () {
202
+ const { db } = yield* Database.Service;
203
+ const feed = db.add(Feed.make());
204
+ const runtime = yield* Effect.runtime<Database.Service>();
205
+ const session = new AiSession.Session({ feed, runtime });
206
+
207
+ const message = makeMessage('plain');
208
+ yield* Effect.promise(() => session.appendTurnMessage(message));
209
+ expect(Feed.getParent(message)).toBeUndefined();
210
+ }).pipe(Effect.provide(TestLayer)),
211
+ );
212
+
213
+ it.effect('the resolved history follows the fork once the continuation lands', () =>
214
+ Effect.gen(function* () {
215
+ const { db } = yield* Database.Service;
216
+ const feed = db.add(Feed.make());
217
+
218
+ const first = makeMessage('first');
219
+ const answer = makeMessage('answer', 'assistant');
220
+ const abandoned = makeMessage('abandoned');
221
+ yield* Feed.append(feed, [first, answer, abandoned]);
222
+ Obj.update(feed, (feed) => {
223
+ feed.rewindFrom = abandoned.id;
224
+ });
225
+
226
+ const runtime = yield* Effect.runtime<Database.Service>();
227
+ const session = new AiSession.Session({ feed, runtime });
228
+ const retry = makeMessage('retry');
229
+ yield* Effect.promise(() => session.appendTurnMessage(retry));
230
+
231
+ const history = yield* Effect.promise(() => session.getHistory());
232
+ expect(history.map((message) => message.id)).toEqual([first.id, answer.id, retry.id]);
233
+ }).pipe(Effect.provide(TestLayer)),
234
+ );
111
235
  });