@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.
- package/dist/lib/extraction.mjs +169 -0
- package/dist/lib/extraction.mjs.map +1 -0
- package/dist/lib/index.mjs +1958 -0
- package/dist/lib/index.mjs.map +1 -0
- package/dist/types/src/request/AiRequest.d.ts +6 -3
- package/dist/types/src/request/AiRequest.d.ts.map +1 -1
- package/dist/types/src/request/AiRequest.test.d.ts +2 -0
- package/dist/types/src/request/AiRequest.test.d.ts.map +1 -0
- package/dist/types/src/request/format.d.ts +2 -2
- package/dist/types/src/request/format.d.ts.map +1 -1
- package/dist/types/src/request/format.test.d.ts +2 -0
- package/dist/types/src/request/format.test.d.ts.map +1 -0
- package/dist/types/src/session/AiContext.d.ts +1 -1
- package/dist/types/src/session/AiContext.d.ts.map +1 -1
- package/dist/types/src/session/AiSession.d.ts +25 -4
- package/dist/types/src/session/AiSession.d.ts.map +1 -1
- package/dist/types/src/session/harness-control.d.ts +4 -0
- package/dist/types/src/session/harness-control.d.ts.map +1 -1
- package/dist/types/src/tool-runtime/services.d.ts +7 -0
- package/dist/types/src/tool-runtime/services.d.ts.map +1 -1
- package/dist/types/src/tool-runtime/services.test.d.ts +2 -0
- package/dist/types/src/tool-runtime/services.test.d.ts.map +1 -0
- package/dist/types/src/util/execution-graph.d.ts.map +1 -1
- package/dist/types/src/util/tracing.d.ts +10 -0
- package/dist/types/src/util/tracing.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +25 -25
- package/src/extraction/quotes.test.ts +4 -4
- package/src/request/AiRequest.test.ts +110 -0
- package/src/request/AiRequest.ts +11 -4
- package/src/request/format.test.ts +93 -0
- package/src/request/format.ts +48 -11
- package/src/session/AiContext.ts +1 -1
- package/src/session/AiSession.test.ts +124 -0
- package/src/session/AiSession.ts +81 -8
- package/src/tool-runtime/services.test.ts +41 -0
- package/src/tool-runtime/services.ts +16 -1
- package/src/util/execution-graph.ts +1 -1
- package/dist/lib/neutral/chunk-J5LGTIGS.mjs +0 -10
- package/dist/lib/neutral/chunk-J5LGTIGS.mjs.map +0 -7
- package/dist/lib/neutral/extraction/index.mjs +0 -197
- package/dist/lib/neutral/extraction/index.mjs.map +0 -7
- package/dist/lib/neutral/index.mjs +0 -2047
- package/dist/lib/neutral/index.mjs.map +0 -7
- 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.
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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/
|
|
43
|
-
"@dxos/
|
|
44
|
-
"@dxos/
|
|
45
|
-
"@dxos/
|
|
46
|
-
"@dxos/
|
|
47
|
-
"@dxos/debug": "0.
|
|
48
|
-
"@dxos/echo": "0.
|
|
49
|
-
"@dxos/echo-client": "0.
|
|
50
|
-
"@dxos/echo-doc": "0.
|
|
51
|
-
"@dxos/echo-protocol": "0.
|
|
52
|
-
"@dxos/
|
|
53
|
-
"@dxos/
|
|
54
|
-
"@dxos/
|
|
55
|
-
"@dxos/
|
|
56
|
-
"@dxos/
|
|
57
|
-
"@dxos/
|
|
58
|
-
"@dxos/
|
|
59
|
-
"@dxos/
|
|
60
|
-
"@dxos/
|
|
61
|
-
"@dxos/
|
|
62
|
-
"@dxos/
|
|
63
|
-
"@dxos/
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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'));
|
package/src/request/AiRequest.ts
CHANGED
|
@@ -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(
|
|
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
|
+
});
|
package/src/request/format.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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(
|
|
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'));
|
package/src/session/AiContext.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
// @import-as-namespace
|
|
6
6
|
|
|
7
|
-
import { Atom, Registry as AtomRegistry } from '@effect-atom/atom
|
|
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
|
});
|