@dxos/assistant 0.8.2 → 0.8.3-main.7f5a14c

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.
@@ -0,0 +1,156 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { AiChat, AiInput, AiLanguageModel, AiTool, AiToolkit } from '@effect/ai';
6
+ import { AnthropicClient, AnthropicLanguageModel } from '@effect/ai-anthropic';
7
+ import { OpenAiClient, OpenAiLanguageModel } from '@effect/ai-openai';
8
+ import { NodeHttpClient } from '@effect/platform-node';
9
+ import { Config, Console, Effect, Layer, pipe, Schedule, Schema } from 'effect';
10
+ import { describe, it } from 'vitest';
11
+
12
+ import { log } from '@dxos/log';
13
+
14
+ // https://effect.website/docs/ai/tool-use/#5-bring-it-all-together
15
+ // https://github.com/Effect-TS/effect/blob/main/packages/ai/ai/CHANGELOG.md
16
+ // https://discord.com/channels/795981131316985866/1338871274398679130
17
+
18
+ /**
19
+ * Rationale:
20
+ * - Separation of concerns: config, execution pipline, error handling/retry, tool dispatch, async.
21
+ * - Uncouple tool definitions from tool implementation (late binding, composition).
22
+ * - Affinity with effect schema.
23
+ * - Leverage existing effect based conductor pipline.
24
+ * - Simple API for plugins/artifacts.
25
+ * - Ecosystem and design partner.
26
+ */
27
+ describe.runIf(!process.env.CI)('AiLanguageModel', () => {
28
+ it.runIf(process.env.OPENAI_API_KEY)('Debug: Verify API configuration', async ({ expect }) => {
29
+ const program = Effect.gen(function* () {
30
+ yield* Console.log('Testing API connectivity...');
31
+ const response = yield* AiLanguageModel.generateText({ prompt: 'Hello, respond with "API is working"' });
32
+ yield* Console.log('API Response received:', response.text);
33
+ return response.text;
34
+ }).pipe(
35
+ Effect.provide(OpenAiLanguageModel.model('gpt-4o')),
36
+ Effect.timeout('10 seconds'),
37
+ Effect.retry({ times: 1 }),
38
+ );
39
+
40
+ const result = await program.pipe(
41
+ Effect.provide([
42
+ OpenAiClient.layerConfig({
43
+ apiKey: Config.redacted('OPENAI_API_KEY'),
44
+ }).pipe(Layer.provide(NodeHttpClient.layerUndici)),
45
+ ]),
46
+ Effect.runPromise,
47
+ );
48
+
49
+ expect(result).to.contain('working');
50
+ });
51
+
52
+ // Tool definitions.
53
+ class Toolkit extends AiToolkit.make(
54
+ AiTool.make('Calculator', {
55
+ description: 'Test tool',
56
+ parameters: {
57
+ input: Schema.String.annotations({
58
+ description: 'The calculation to perform.',
59
+ }),
60
+ },
61
+ success: Schema.Struct({
62
+ result: Schema.Number,
63
+ }),
64
+ failure: Schema.Never,
65
+ }),
66
+ ) {}
67
+
68
+ // Tool handlers.
69
+ const ToolkitLayer = Toolkit.toLayer({
70
+ Calculator: ({ input }) =>
71
+ Effect.gen(function* () {
72
+ const result = (() => {
73
+ // Only allow basic arithmetic operations for safety.
74
+ const sanitizedInput = input.replace(/[^0-9+\-*/().\s]/g, '');
75
+ // eslint-disable-next-line no-new-func
76
+ return Function(`"use strict"; return (${sanitizedInput})`)();
77
+ })();
78
+
79
+ yield* Console.log(`Executing calculation: ${input} = ${result}`);
80
+ return { result };
81
+ }),
82
+ });
83
+
84
+ // Providers.
85
+ const OpenAiLayer = OpenAiClient.layerConfig({
86
+ apiKey: Config.redacted('OPENAI_API_KEY'),
87
+ }).pipe(Layer.provide(NodeHttpClient.layerUndici));
88
+
89
+ const AnthropicLayer = AnthropicClient.layerConfig({
90
+ apiKey: Config.redacted('ANTHROPIC_API_KEY'),
91
+ }).pipe(Layer.provide(NodeHttpClient.layerUndici));
92
+
93
+ it.runIf(process.env.OPENAI_API_KEY)('should make a tool call', async ({ expect }) => {
94
+ const createProgram = (prompt: string) =>
95
+ AiLanguageModel.generateText({
96
+ toolkit: Toolkit,
97
+ prompt,
98
+ }).pipe(
99
+ Effect.tap((response) => Console.log(response)),
100
+ Effect.provide(OpenAiLanguageModel.model('gpt-4o')),
101
+ Effect.retry(pipe(Schedule.exponential('1 second'), Schedule.intersect(Schedule.recurs(2)))),
102
+ Effect.timeout('30 seconds'),
103
+ );
104
+
105
+ const program = createProgram('What is six times seven? Use appropriate tools and just answer with the number.');
106
+ const result = await program.pipe(Effect.provide([ToolkitLayer, OpenAiLayer]), Effect.runPromise);
107
+ expect(result).toBeDefined();
108
+ });
109
+
110
+ // TODO(burdon): Is thisthe effectful way to do this?
111
+ const createChat = (prompt: string) =>
112
+ Effect.gen(function* () {
113
+ const chat = yield* AiChat.empty;
114
+ const toolkit = yield* Toolkit;
115
+
116
+ // Initial request.
117
+ let response = yield* chat.generateText({ toolkit, prompt });
118
+
119
+ // Agentic loop.
120
+ while (response.results.size > 0) {
121
+ response = yield* chat.generateText({ toolkit, prompt: AiInput.empty });
122
+ }
123
+
124
+ // Done.
125
+ return response.text;
126
+ });
127
+
128
+ it.runIf(process.env.OPENAI_API_KEY)('should process an agentic loop using OpenAI', async ({ expect }) => {
129
+ const chat = createChat('What is six times seven?');
130
+ const result = await chat.pipe(
131
+ Effect.provide(OpenAiLanguageModel.model('gpt-4o')),
132
+ Effect.provide(OpenAiLayer),
133
+ Effect.provide(ToolkitLayer),
134
+ Effect.runPromise,
135
+ );
136
+
137
+ log.info('result', { result });
138
+ expect(result).toContain('42');
139
+ });
140
+
141
+ // TODO(burdon): Fix Anthropic.
142
+ it.runIf(process.env.ANTHROPIC_API_KEY).skip('should process an agentic loop using Anthropic', async ({ expect }) => {
143
+ const chat = createChat('What is six times seven?');
144
+ const result = await chat.pipe(
145
+ Effect.provide(AnthropicLanguageModel.model('claude-3-5-sonnet-latest')),
146
+ Effect.provide(AnthropicLayer),
147
+ Effect.provide(ToolkitLayer),
148
+ Effect.runPromise,
149
+ );
150
+
151
+ log.info('result', { result });
152
+ expect(result).toContain('42');
153
+ });
154
+
155
+ // TODO(burdon): Implement MCP server for ECHO on CF.
156
+ });
@@ -5,7 +5,7 @@
5
5
  import { Schema } from 'effect';
6
6
  import { describe, test } from 'vitest';
7
7
 
8
- import { AIServiceEdgeClient, ToolResult, createTool } from '@dxos/ai';
8
+ import { AIServiceEdgeClient, ToolRegistry, ToolResult, createTool } from '@dxos/ai';
9
9
  import { AI_SERVICE_ENDPOINT, EXA_API_KEY } from '@dxos/ai/testing';
10
10
  import { ArtifactId } from '@dxos/artifact';
11
11
  import { EchoTestBuilder } from '@dxos/echo-db/testing';
@@ -23,7 +23,7 @@ import { createExaTool } from '../research/exa';
23
23
 
24
24
  // TODO(burdon): Don't run on CI.
25
25
  describe.skip('Blueprint', () => {
26
- const aiService = new AIServiceEdgeClient({
26
+ const aiClient = new AIServiceEdgeClient({
27
27
  endpoint: AI_SERVICE_ENDPOINT.REMOTE,
28
28
  defaultGenerationOptions: {
29
29
  // model: '@anthropic/claude-sonnet-4-20250514',
@@ -39,9 +39,10 @@ describe.skip('Blueprint', () => {
39
39
  .step('Write a pitch deck for the product')
40
40
  .build();
41
41
 
42
- const machine = new BlueprintMachine(blueprint);
42
+ const tools = new ToolRegistry([]);
43
+ const machine = new BlueprintMachine(tools, blueprint);
43
44
  setConsolePrinter(machine, true);
44
- await machine.runToCompletion({ aiService });
45
+ await machine.runToCompletion({ aiClient });
45
46
  });
46
47
 
47
48
  test('email bot', { timeout: 60_000 }, async () => {
@@ -82,22 +83,23 @@ describe.skip('Blueprint', () => {
82
83
  'Determine if the email is introduction, question, or spam. Bail if email does not fit into one of these categories.',
83
84
  )
84
85
  .step('If the email is spam, label it as spam and do not respond.', {
85
- tools: [labelTool],
86
+ tools: [labelTool.id],
86
87
  })
87
88
  .step(
88
89
  'If the email is an introduction, respond with a short introduction of yourself and ask for more information.',
89
90
  {
90
- tools: [replyTool],
91
+ tools: [replyTool.id],
91
92
  },
92
93
  )
93
94
  .step('If the email is a question, respond with a short answer and ask for more information.', {
94
- tools: [replyTool],
95
+ tools: [replyTool.id],
95
96
  })
96
97
  .build();
97
98
 
98
- const machine = new BlueprintMachine(blueprint);
99
+ const tools = new ToolRegistry([replyTool, labelTool]);
100
+ const machine = new BlueprintMachine(tools, blueprint);
99
101
  setConsolePrinter(machine);
100
- await machine.runToCompletion({ aiService, input: TEST_EMAILS[0] });
102
+ await machine.runToCompletion({ aiClient, input: TEST_EMAILS[0] });
101
103
  });
102
104
 
103
105
  test.only('research', { timeout: 120_000 }, async () => {
@@ -123,20 +125,27 @@ describe.skip('Blueprint', () => {
123
125
 
124
126
  await db.flush({ indexes: true });
125
127
 
128
+ const [exa, localSearch, graphWriter] = [
129
+ createExaTool({ apiKey: EXA_API_KEY }),
130
+ createLocalSearchTool(db),
131
+ createGraphWriterTool({ db, schema: DataTypes }),
132
+ ];
133
+
126
134
  const blueprint = BlueprintBuilder.create()
127
135
  .step('Research founders of the organization. Do deep research.', {
128
- tools: [createExaTool({ apiKey: EXA_API_KEY })],
136
+ tools: [exa.id],
129
137
  })
130
138
  .step('Based on your research select matching entires that are already in the graph', {
131
- tools: [createLocalSearchTool(db)],
139
+ tools: [localSearch.id],
132
140
  })
133
141
  .step('Add researched data to the graph', {
134
- tools: [createLocalSearchTool(db), createGraphWriterTool({ db, schemaTypes: DataTypes })],
142
+ tools: [localSearch.id, graphWriter.id],
135
143
  })
136
144
  .build();
137
145
 
138
- const machine = new BlueprintMachine(blueprint);
146
+ const tools = new ToolRegistry([exa, localSearch, graphWriter]);
147
+ const machine = new BlueprintMachine(tools, blueprint);
139
148
  setConsolePrinter(machine, true);
140
- await machine.runToCompletion({ aiService, input: org1 });
149
+ await machine.runToCompletion({ aiClient, input: org1 });
141
150
  });
142
151
  });
@@ -4,21 +4,29 @@
4
4
 
5
5
  import { Schema } from 'effect';
6
6
 
7
- import { type ExecutableTool, Tool } from '@dxos/ai';
8
- import { raise } from '@dxos/debug';
9
- import { ObjectId } from '@dxos/keys';
7
+ import { Key, Obj, Type } from '@dxos/echo';
10
8
 
11
9
  export const BlueprintStep = Schema.Struct({
12
- id: Schema.String,
10
+ id: Key.ObjectId,
13
11
  instructions: Schema.String,
14
- // TODO(burdon): ExecutableTool can't be serialized.
15
- tools: Schema.Array(Tool).pipe(Schema.mutable),
12
+ tools: Schema.optional(Schema.Array(Schema.String)),
16
13
  });
17
14
  export interface BlueprintStep extends Schema.Schema.Type<typeof BlueprintStep> {}
18
15
 
19
- export const Blueprint = Schema.Struct({
20
- steps: Schema.Array(BlueprintStep).pipe(Schema.mutable),
16
+ export const BlueprintDefinition = Schema.Struct({
17
+ steps: Schema.Array(BlueprintStep.pipe(Schema.omit('id'))),
21
18
  });
19
+ export interface BlueprintDefinition extends Schema.Schema.Type<typeof BlueprintDefinition> {}
20
+
21
+ export const Blueprint = Schema.Struct({
22
+ name: Schema.optional(Schema.String),
23
+ steps: Schema.Array(BlueprintStep),
24
+ }).pipe(
25
+ Type.Obj({
26
+ typename: 'dxos.org/type/Blueprint',
27
+ version: '0.1.0',
28
+ }),
29
+ );
22
30
  export interface Blueprint extends Schema.Schema.Type<typeof Blueprint> {}
23
31
 
24
32
  /**
@@ -30,9 +38,9 @@ export namespace BlueprintBuilder {
30
38
  class Builder {
31
39
  private readonly _steps: BlueprintStep[] = [];
32
40
 
33
- step(instructions: string, options?: { tools?: ExecutableTool[] }): Builder {
41
+ step(instructions: string, options?: { tools?: string[] }): Builder {
34
42
  this._steps.push({
35
- id: ObjectId.random(),
43
+ id: Key.ObjectId.random(),
36
44
  instructions,
37
45
  tools: options?.tools ?? [],
38
46
  });
@@ -41,9 +49,7 @@ export namespace BlueprintBuilder {
41
49
  }
42
50
 
43
51
  build(): Blueprint {
44
- return {
45
- steps: this._steps,
46
- };
52
+ return Obj.make(Blueprint, { steps: this._steps });
47
53
  }
48
54
  }
49
55
  }
@@ -52,40 +58,14 @@ export namespace BlueprintBuilder {
52
58
  * Blueprint parser API.
53
59
  */
54
60
  export namespace BlueprintParser {
55
- export type Step = {
56
- instructions: string;
57
- // TODO(burdon): Tool DXN? Additional metadata?
58
- tools?: string[];
59
- };
60
-
61
- export type DSL = {
62
- steps: Step[];
63
- };
64
-
65
- export const create = (tools: ExecutableTool[] = []) => new Parser(tools);
61
+ export const create = () => new Parser();
66
62
 
67
63
  class Parser {
68
- constructor(private readonly _tools: ExecutableTool[]) {}
69
-
70
- toJSON() {
71
- return {
72
- tools: this._tools.map((tool) => ({
73
- name: tool.name,
74
- namespace: tool.namespace,
75
- type: tool.type,
76
- })),
77
- };
78
- }
79
-
80
- parse({ steps }: DSL): Blueprint {
64
+ parse({ steps }: BlueprintDefinition): Blueprint {
81
65
  const builder = BlueprintBuilder.create();
82
-
83
66
  for (const step of steps) {
84
67
  builder.step(step.instructions, {
85
- // TODO(burdon): Tool resolution is duplicated in the session and ollama-client.
86
- tools: step.tools?.map(
87
- (tool) => this._tools.find(({ id }) => id === tool) ?? raise(new Error(`Tool not found: ${tool}`)),
88
- ),
68
+ tools: (step.tools ?? []) as string[],
89
69
  });
90
70
  }
91
71
 
@@ -2,41 +2,130 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
+ import { type ReadonlySignal, signal } from '@preact/signals-core';
5
6
  import chalk from 'chalk';
6
7
 
7
8
  import { ConsolePrinter } from '@dxos/ai';
9
+ import { type CleanupFn, combine } from '@dxos/async';
10
+ import { log } from '@dxos/log';
8
11
 
9
12
  import { type Blueprint } from './blueprint';
10
- import { type BlueprintMachine, type BlueprintMachineState } from './machine';
13
+ import {
14
+ type BlueprintEvent,
15
+ type BlueprintLogger,
16
+ type BlueprintMachine,
17
+ type BlueprintMachineState,
18
+ } from './machine';
11
19
 
12
20
  /* eslint-disable no-console */
13
21
 
14
- // Force chalk colors on for tests
22
+ // Force chalk colors on for tests.
15
23
  chalk.level = 2;
16
24
 
17
- export const setConsolePrinter = (machine: BlueprintMachine, extraLogging = false) => {
25
+ // TODO(burdon): Factor out.
26
+ type ConsoleLogSignature = (message: string, arg?: any) => void;
27
+
28
+ interface Logger {
29
+ log: ConsoleLogSignature;
30
+ }
31
+
32
+ const DEFAULT_LOGGER: Logger = { log: log.info };
33
+
34
+ /**
35
+ * Reactive bufferedlogger.
36
+ */
37
+ export class BufferedLogger implements Logger {
38
+ private _messages = signal<string[]>([]);
39
+
40
+ get messages(): ReadonlySignal<string[]> {
41
+ return this._messages;
42
+ }
43
+
44
+ clear() {
45
+ this._messages.value = [];
46
+ }
47
+
48
+ log(message: string) {
49
+ this._messages.value = [...this._messages.value, message];
50
+ }
51
+ }
52
+
53
+ // TODO(burdon): Reconcile with ConsolePrinter.
54
+ export class BlueprintLoggerAdapter implements BlueprintLogger {
55
+ constructor(private readonly logger: Logger = DEFAULT_LOGGER) {}
56
+
57
+ log(event: BlueprintEvent) {
58
+ switch (event.type) {
59
+ case 'begin':
60
+ this.logger.log('begin');
61
+ break;
62
+ case 'end':
63
+ this.logger.log('end');
64
+ break;
65
+ case 'step-start':
66
+ this.logger.log('step-start', { step: event.step });
67
+ break;
68
+ case 'step-complete':
69
+ this.logger.log('step-complete', { step: event.step });
70
+ break;
71
+ case 'message':
72
+ this.logger.log('message', { message: event.message });
73
+ break;
74
+ case 'block':
75
+ this.logger.log('block', { block: event.block });
76
+ break;
77
+ }
78
+ }
79
+ }
80
+
81
+ export const setLogger = (machine: BlueprintMachine, logger: BufferedLogger): CleanupFn => {
82
+ return combine(
83
+ machine.begin.on(() => {
84
+ logger.log('Starting...');
85
+ }),
86
+ machine.stepStart.on((step) => {
87
+ const index = machine.blueprint.steps.indexOf(step);
88
+ logger.log(`Step ${index + 1} of ${machine.blueprint.steps.length}`);
89
+ logger.log(`Instructions: ${step.instructions}`);
90
+ }),
91
+ machine.stepComplete.on((step) => {
92
+ const trace = machine.state.trace.find((t) => t.stepId === step.id);
93
+ logger.log(`Complete: ${trace?.comment}`);
94
+ }),
95
+ machine.end.on(() => {
96
+ logger.log('OK');
97
+ }),
98
+ );
99
+ };
100
+
101
+ export const setConsolePrinter = (machine: BlueprintMachine, verbose = false): CleanupFn => {
18
102
  const printer = new ConsolePrinter();
19
- if (extraLogging) {
103
+ return combine(
104
+ machine.begin.on(() => printTrace(machine.blueprint, machine.state)),
20
105
  machine.stepStart.on((step) =>
21
106
  console.log(
22
107
  `\n${chalk.magenta(`${chalk.bold(`STEP ${machine.blueprint.steps.indexOf(step) + 1} of ${machine.blueprint.steps.length}:`)} ${step.instructions}`)}\n`,
23
108
  ),
24
- );
25
- machine.message.on((msg) => printer.printMessage(msg));
26
- machine.block.on((block) => printer.printContentBlock(block));
27
- }
28
- machine.begin.on(() => printTrace(machine.blueprint, machine.state));
29
- machine.stepComplete.on(() => printTrace(machine.blueprint, machine.state));
109
+ ),
110
+ machine.stepComplete.on(() => printTrace(machine.blueprint, machine.state)),
111
+ machine.end.on(() => console.log('DONE')),
112
+ verbose
113
+ ? [
114
+ machine.message.on((msg) => printer.printMessage(msg)),
115
+ machine.block.on((block) => printer.printContentBlock(block)),
116
+ ]
117
+ : [],
118
+ );
30
119
  };
31
120
 
32
- const printTrace = (blueprint: Blueprint, state: BlueprintMachineState) => {
33
- console.log('\n==============================================\n');
34
- console.log(chalk.bold('\nThe Blueprint:'));
121
+ const BREAK_LINE = `\n${'_'.repeat(80)}\n`;
35
122
 
36
- blueprint.steps.forEach((step, index) => {
123
+ const printTrace = (blueprint: Blueprint, state: BlueprintMachineState) => {
124
+ console.group(chalk.bold('\nBlueprint'));
125
+ blueprint.steps.forEach((step) => {
37
126
  const traceStep = state.trace.find((t) => t.stepId === step.id);
38
127
 
39
- let color = chalk.gray; // Not executed
128
+ let color = chalk.gray; // Not executed.
40
129
  let bullet = '○';
41
130
  if (traceStep) {
42
131
  switch (traceStep.status) {
@@ -56,11 +145,11 @@ const printTrace = (blueprint: Blueprint, state: BlueprintMachineState) => {
56
145
  }
57
146
 
58
147
  console.log(color(`\n${bullet} ${step.instructions}`));
59
-
60
148
  if (traceStep?.comment) {
61
- console.log(chalk.white(` ↳ ${traceStep.comment}`));
149
+ console.log(chalk.white(` ↳ ${traceStep.comment}`));
62
150
  }
63
151
  });
64
- console.log('\n');
65
- console.log('\n==============================================\n');
152
+
153
+ console.groupEnd();
154
+ console.log(BREAK_LINE);
66
155
  };