@statelyai/agent 1.0.0-beta.1 → 1.1.2

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,28 @@
1
+ import { z } from 'zod';
2
+ import { createAgent } from '../src';
3
+ import { openai } from '@ai-sdk/openai';
4
+ import { createMachine } from 'xstate';
5
+
6
+ const agent = createAgent({
7
+ model: openai('gpt-4o'),
8
+ events: {
9
+ doSomething: z.object({}).describe('Do something'),
10
+ },
11
+ });
12
+
13
+ async function main() {
14
+ const machine = createMachine({
15
+ on: {
16
+ doSomething: {},
17
+ },
18
+ });
19
+ const result = await agent.decide({
20
+ goal: 'Do not do anything',
21
+ state: { value: {}, context: {} },
22
+ machine,
23
+ });
24
+
25
+ console.log(result);
26
+ }
27
+
28
+ main();
@@ -35,7 +35,7 @@ const agent = createAgent({
35
35
 
36
36
  const machine = setup({
37
37
  types: {
38
- events: agent.eventTypes,
38
+ events: agent.types.events,
39
39
  input: {} as string,
40
40
  context: {} as {
41
41
  customerIssue: string;
@@ -23,25 +23,30 @@ const agent = createAgent({
23
23
  }),
24
24
  reset: z.object({}).describe('Reset the game to the initial state'),
25
25
  },
26
+ context: {
27
+ board: z
28
+ .array(z.union([z.literal(null), z.literal('x'), z.literal('o')]))
29
+ .describe('The 3x3 board represented as a 9-element array.'),
30
+ moves: z
31
+ .number()
32
+ .min(0)
33
+ .max(9)
34
+ .describe('The number of moves made in the game.'),
35
+ player: z
36
+ .union([z.literal('x'), z.literal('o')])
37
+ .describe('The current player (x or o)'),
38
+ gameReport: z.string(),
39
+ },
26
40
  });
27
41
 
28
42
  type Player = 'x' | 'o';
29
43
 
30
- interface GameContext {
31
- board: (Player | null)[];
32
- moves: number;
33
- player: Player;
34
- gameReport: string;
35
- events: string[];
36
- }
37
-
38
44
  const initialContext = {
39
45
  board: Array(9).fill(null) as Array<Player | null>,
40
46
  moves: 0,
41
47
  player: 'x' as Player,
42
48
  gameReport: '',
43
- events: [],
44
- } satisfies GameContext;
49
+ } satisfies typeof agent.types.context;
45
50
 
46
51
  function getWinner(board: typeof initialContext.board): Player | null {
47
52
  const lines = [
@@ -64,8 +69,8 @@ function getWinner(board: typeof initialContext.board): Player | null {
64
69
 
65
70
  export const ticTacToeMachine = setup({
66
71
  types: {
67
- context: {} as GameContext,
68
- events: agent.eventTypes,
72
+ context: agent.types.context,
73
+ events: agent.types.events,
69
74
  },
70
75
  actors: {
71
76
  agent: fromDecision(agent),
@@ -81,16 +86,8 @@ export const ticTacToeMachine = setup({
81
86
  },
82
87
  moves: ({ context }) => context.moves + 1,
83
88
  player: ({ context }) => (context.player === 'x' ? 'o' : 'x'),
84
- events: ({ context, event }) => {
85
- return [...context.events, JSON.stringify(event)];
86
- },
87
89
  }),
88
90
  resetGame: assign(initialContext),
89
- recordEvent: assign({
90
- events: ({ context, event }) => {
91
- return [...context.events, JSON.stringify(event)];
92
- },
93
- }),
94
91
  printBoard: ({ context }) => {
95
92
  // Print the context.board in a 3 x 3 grid format
96
93
  let boardString = '';
@@ -172,7 +169,7 @@ export const ticTacToeMachine = setup({
172
169
  src: 'gameReporter',
173
170
  input: ({ context }) => ({
174
171
  context: {
175
- events: context.events,
172
+ events: agent.getObservations().map((o) => o.event),
176
173
  board: context.board,
177
174
  },
178
175
  prompt: 'Provide a short game report analyzing the game.',
package/examples/todo.ts CHANGED
@@ -36,7 +36,9 @@ const machine = setup({
36
36
  todos: Todo[];
37
37
  command: string | null;
38
38
  },
39
- events: {} as typeof agent.eventTypes | { type: 'assist'; command: string },
39
+ events: {} as
40
+ | typeof agent.types.events
41
+ | { type: 'assist'; command: string },
40
42
  },
41
43
  actors: { agent: fromDecision(agent), getFromTerminal },
42
44
  }).createMachine({
package/examples/tutor.ts CHANGED
@@ -28,7 +28,7 @@ const machine = setup({
28
28
  context: {} as {
29
29
  conversation: string[];
30
30
  },
31
- events: agent.eventTypes,
31
+ events: agent.types.events,
32
32
  },
33
33
  actors: { agent: fromDecision(agent), getFromTerminal },
34
34
  }).createMachine({
@@ -35,7 +35,7 @@ const machine = setup({
35
35
  answer: string | null;
36
36
  validation: string | null;
37
37
  },
38
- events: agent.eventTypes,
38
+ events: agent.types.events,
39
39
  },
40
40
  actors: {
41
41
  getFromTerminal,
@@ -78,7 +78,7 @@ const machine = setup({
78
78
  history: string[];
79
79
  count: number;
80
80
  },
81
- events: agent.eventTypes,
81
+ events: agent.types.events,
82
82
  },
83
83
  actors: {
84
84
  agent: fromDecision(agent),
package/examples/wiki.ts CHANGED
@@ -20,7 +20,7 @@ async function main() {
20
20
  console.log(response1.text);
21
21
 
22
22
  const response2 = await agent.generateText({
23
- messages: true,
23
+ messages: (x) => x.select((ctx) => ctx.messages),
24
24
  prompt: 'What about the first one?',
25
25
  });
26
26
 
package/examples/word.ts CHANGED
@@ -36,7 +36,7 @@ const agent = createAgent({
36
36
  const wordGuesserMachine = setup({
37
37
  types: {
38
38
  context: {} as typeof context,
39
- events: agent.eventTypes,
39
+ events: agent.types.events,
40
40
  },
41
41
  actors: {
42
42
  agent: fromDecision(agent),
package/package.json CHANGED
@@ -1,21 +1,10 @@
1
1
  {
2
2
  "name": "@statelyai/agent",
3
- "version": "1.0.0-beta.1",
3
+ "version": "1.1.2",
4
4
  "description": "Stateful agents that make decisions based on finite-state machine models",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
7
7
  "types": "dist/index.d.ts",
8
- "scripts": {
9
- "build": "tsup src/index.ts --format cjs,esm --dts",
10
- "lint": "tsc --noEmit",
11
- "test": "vitest",
12
- "test:ci": "vitest --run",
13
- "example": "ts-node examples/helpers/runner.ts",
14
- "prepublishOnly": "tsup src/index.ts --format cjs,esm --dts",
15
- "changeset": "changeset",
16
- "release": "changeset publish",
17
- "version": "changeset version"
18
- },
19
8
  "keywords": [
20
9
  "ai",
21
10
  "state machine",
@@ -27,17 +16,18 @@
27
16
  "license": "MIT",
28
17
  "devDependencies": {
29
18
  "@changesets/changelog-github": "^0.5.0",
30
- "@changesets/cli": "^2.27.5",
19
+ "@changesets/cli": "^2.27.7",
31
20
  "@langchain/community": "^0.0.53",
32
21
  "@langchain/core": "^0.1.63",
33
22
  "@langchain/openai": "^0.0.28",
34
- "@types/node": "^20.14.6",
23
+ "@types/node": "^20.14.13",
24
+ "@types/object-hash": "^3.0.6",
35
25
  "dotenv": "^16.4.5",
36
26
  "json-schema-to-ts": "^3.1.0",
37
27
  "ts-node": "^10.9.2",
38
- "tsup": "^8.1.0",
39
- "typescript": "^5.4.5",
40
- "vitest": "^1.6.0",
28
+ "tsup": "^8.2.3",
29
+ "typescript": "^5.5.4",
30
+ "vitest": "^2.0.4",
41
31
  "wikipedia": "^2.1.2",
42
32
  "zod": "^3.23.8"
43
33
  },
@@ -45,10 +35,20 @@
45
35
  "access": "public"
46
36
  },
47
37
  "dependencies": {
48
- "@ai-sdk/openai": "^0.0.31",
38
+ "@ai-sdk/openai": "^0.0.40",
49
39
  "@xstate/graph": "^2.0.0",
50
- "ai": "^3.2.1",
51
- "xstate": "^5.13.2"
40
+ "ai": "^3.2.40",
41
+ "object-hash": "^3.0.0",
42
+ "xstate": "^5.16.0"
52
43
  },
53
- "packageManager": "pnpm@8.11.0"
54
- }
44
+ "scripts": {
45
+ "build": "tsup src/index.ts --format cjs,esm --dts",
46
+ "lint": "tsc --noEmit",
47
+ "test": "vitest",
48
+ "test:ci": "vitest --run",
49
+ "example": "ts-node examples/helpers/runner.ts",
50
+ "changeset": "changeset",
51
+ "release": "changeset publish",
52
+ "version": "changeset version"
53
+ }
54
+ }
package/readme.md CHANGED
@@ -1,8 +1,5 @@
1
1
  # Stately Agent
2
2
 
3
- > [!WARNING]
4
- > Alpha software! Not ready for production yet.
5
-
6
3
  Stately Agent is a flexible framework for building AI agents using state machines. Stately agents go beyond normal LLM-based AI agents by:
7
4
 
8
5
  - Using state machines to guide the agent's behavior, powered by [XState](https://stately.ai/docs/xstate)
@@ -10,4 +7,4 @@ Stately Agent is a flexible framework for building AI agents using state machine
10
7
  - Enabling custom **planning** abilities for agents to achieve specific goals based on state machine logic, observations, and feedback
11
8
  - Wrapping the [Vercel AI SDK](https://sdk.vercel.ai/) to easily support multiple model providers, such as OpenAI, Anthropic, Google, Mistral, Groq, Perplexity, and more
12
9
 
13
- _Documentation coming soon_
10
+ **Read the documentation: [stately.ai/docs/agents](https://stately.ai/docs/agents)**
package/src/agent.test.ts CHANGED
@@ -1,6 +1,13 @@
1
- import { test, expect } from 'vitest';
2
- import { createAgent, type AIAdapter } from './';
1
+ import { test, expect, vi } from 'vitest';
2
+ import {
3
+ AgentGenerateTextResult,
4
+ AgentMessage,
5
+ createAgent,
6
+ type AIAdapter,
7
+ } from './';
3
8
  import { createActor, createMachine } from 'xstate';
9
+ import { GenerateTextResult } from 'ai';
10
+ import { z } from 'zod';
4
11
 
5
12
  test('an agent has the expected interface', () => {
6
13
  const agent = createAgent({
@@ -13,11 +20,16 @@ test('an agent has the expected interface', () => {
13
20
  expect(agent.generateText).toBeDefined();
14
21
  expect(agent.streamText).toBeDefined();
15
22
 
16
- expect(agent.addFeedback).toBeDefined();
17
23
  expect(agent.addMessage).toBeDefined();
18
24
  expect(agent.addObservation).toBeDefined();
25
+ expect(agent.addFeedback).toBeDefined();
19
26
  expect(agent.addPlan).toBeDefined();
20
27
 
28
+ expect(agent.getMessages).toBeDefined();
29
+ expect(agent.getObservations).toBeDefined();
30
+ expect(agent.getFeedback).toBeDefined();
31
+ expect(agent.getPlans).toBeDefined();
32
+
21
33
  expect(agent.interact).toBeDefined();
22
34
  });
23
35
 
@@ -45,6 +57,11 @@ test('agent.addMessage() adds to message history', () => {
45
57
  content: 'msg 1',
46
58
  })
47
59
  );
60
+ expect(agent.getMessages()).toContainEqual(
61
+ expect.objectContaining({
62
+ content: 'msg 1',
63
+ })
64
+ );
48
65
 
49
66
  expect(agent.select((c) => c.messages)).toContainEqual(
50
67
  expect.objectContaining({
@@ -53,6 +70,13 @@ test('agent.addMessage() adds to message history', () => {
53
70
  timestamp: expect.any(Number),
54
71
  })
55
72
  );
73
+ expect(agent.getMessages()).toContainEqual(
74
+ expect.objectContaining({
75
+ content: 'response 1',
76
+ sessionId: expect.any(String),
77
+ timestamp: expect.any(Number),
78
+ })
79
+ );
56
80
  });
57
81
 
58
82
  test('agent.addFeedback() adds to feedback', () => {
@@ -83,6 +107,17 @@ test('agent.addFeedback() adds to feedback', () => {
83
107
  timestamp: expect.any(Number),
84
108
  })
85
109
  );
110
+ expect(agent.getFeedback()).toContainEqual(
111
+ expect.objectContaining({
112
+ attributes: {
113
+ score: -1,
114
+ },
115
+ goal: 'Win the game',
116
+ observationId: 'obs-1',
117
+ sessionId: expect.any(String),
118
+ timestamp: expect.any(Number),
119
+ })
120
+ );
86
121
  });
87
122
 
88
123
  test('agent.addObservation() adds to observations', () => {
@@ -111,6 +146,46 @@ test('agent.addObservation() adds to observations', () => {
111
146
  );
112
147
  });
113
148
 
149
+ test('agent.addObservation() adds to observations with machine hash', () => {
150
+ const agent = createAgent({
151
+ name: 'test',
152
+ events: {},
153
+ model: {} as any,
154
+ });
155
+
156
+ const machine = createMachine({
157
+ initial: 'playing',
158
+ states: {
159
+ playing: {
160
+ on: {
161
+ play: 'lost',
162
+ },
163
+ },
164
+ lost: {},
165
+ },
166
+ });
167
+
168
+ const observation = agent.addObservation({
169
+ prevState: { value: 'playing', context: {} },
170
+ event: { type: 'play', position: 3 },
171
+ state: { value: 'lost', context: {} },
172
+ machine,
173
+ });
174
+
175
+ expect(observation.sessionId).toEqual(agent.sessionId);
176
+
177
+ expect(agent.select((c) => c.observations)).toContainEqual(
178
+ expect.objectContaining({
179
+ prevState: { value: 'playing', context: {} },
180
+ event: { type: 'play', position: 3 },
181
+ state: { value: 'lost', context: {} },
182
+ machineHash: expect.any(String),
183
+ sessionId: expect.any(String),
184
+ timestamp: expect.any(Number),
185
+ })
186
+ );
187
+ });
188
+
114
189
  test('agent.interact() observes machine actors (no 2nd arg)', () => {
115
190
  const machine = createMachine({
116
191
  initial: 'a',
@@ -140,6 +215,12 @@ test('agent.interact() observes machine actors (no 2nd arg)', () => {
140
215
  state: expect.objectContaining({ value: 'a' }),
141
216
  })
142
217
  );
218
+ expect(agent.getObservations()).toContainEqual(
219
+ expect.objectContaining({
220
+ prevState: undefined,
221
+ state: expect.objectContaining({ value: 'a' }),
222
+ })
223
+ );
143
224
 
144
225
  actor.send({ type: 'NEXT' });
145
226
 
@@ -175,7 +256,8 @@ test('Agents can use a custom adapter', async () => {
175
256
  expect(res.text).toEqual('Response');
176
257
  });
177
258
 
178
- test.skip('You can listen for emitted agent events', () => {
259
+ test('You can listen for feedback events', () => {
260
+ const fn = vi.fn();
179
261
  const agent = createAgent({
180
262
  name: 'test',
181
263
  events: {},
@@ -183,5 +265,242 @@ test.skip('You can listen for emitted agent events', () => {
183
265
  model: {} as any,
184
266
  });
185
267
 
186
- agent.on('feedback', (x) => {});
268
+ agent.on('feedback', fn);
269
+
270
+ agent.addFeedback({
271
+ attributes: {
272
+ score: -1,
273
+ },
274
+ goal: 'Win the game',
275
+ observationId: 'obs-1',
276
+ });
277
+
278
+ expect(fn).toHaveBeenCalled();
279
+ });
280
+
281
+ test('You can listen for plan events', async () => {
282
+ const fn = vi.fn();
283
+ const agent = createAgent({
284
+ name: 'test',
285
+ model: {} as any,
286
+ events: {
287
+ WIN: z.object({}),
288
+ },
289
+ adapter: {
290
+ generateText: async (arg) => {
291
+ const keys = Object.keys(arg.tools!);
292
+
293
+ if (keys.length !== 1) {
294
+ throw new Error('Expected only 1 choice');
295
+ }
296
+
297
+ return {
298
+ toolResults: [
299
+ {
300
+ result: {
301
+ type: keys[0],
302
+ },
303
+ },
304
+ ],
305
+ } as any as AgentGenerateTextResult;
306
+ },
307
+ streamText: {} as any,
308
+ },
309
+ });
310
+
311
+ agent.on('plan', fn);
312
+
313
+ await agent.decide({
314
+ goal: 'Win the game',
315
+ state: {
316
+ value: 'playing',
317
+ context: {},
318
+ },
319
+ machine: createMachine({
320
+ initial: 'playing',
321
+ states: {
322
+ playing: {
323
+ on: {
324
+ WIN: {
325
+ target: 'won',
326
+ },
327
+ },
328
+ },
329
+ won: {},
330
+ },
331
+ }),
332
+ });
333
+
334
+ expect(fn).toHaveBeenCalledWith(
335
+ expect.objectContaining({
336
+ plan: expect.objectContaining({
337
+ nextEvent: {
338
+ type: 'WIN',
339
+ },
340
+ }),
341
+ })
342
+ );
343
+ });
344
+
345
+ test('agent.types provides context and event types', () => {
346
+ const agent = createAgent({
347
+ model: {} as any,
348
+ events: {
349
+ setScore: z.object({
350
+ score: z.number(),
351
+ }),
352
+ },
353
+ context: {
354
+ score: z.number(),
355
+ },
356
+ });
357
+
358
+ agent.types satisfies { context: any; events: any };
359
+
360
+ agent.types.context satisfies { score: number };
361
+
362
+ // @ts-expect-error
363
+ agent.types.context satisfies { score: string };
187
364
  });
365
+
366
+ test.each(['generateText', 'streamText'] as const)(
367
+ 'can provide a correlation ID (%s)',
368
+ async (method) => {
369
+ const agent = createAgent({
370
+ model: {} as any,
371
+ events: {},
372
+ adapter: {
373
+ [method]: async (opts: any) => {
374
+ const res = {
375
+ text: 'response',
376
+ };
377
+
378
+ opts.onFinish?.(res);
379
+
380
+ return res as AgentGenerateTextResult;
381
+ },
382
+ } as any as AIAdapter,
383
+ });
384
+
385
+ const promise = new Promise<AgentMessage>((res) => {
386
+ agent.onMessage((msg) => {
387
+ if (msg.role === 'assistant') {
388
+ res(msg);
389
+ }
390
+ });
391
+ });
392
+
393
+ await agent[method]({
394
+ prompt: 'hi',
395
+ correlationId: 'c-1',
396
+ });
397
+
398
+ const msg = await promise;
399
+
400
+ expect(msg.correlationId).toBe('c-1');
401
+ expect(msg.parentCorrelationId).toBe(undefined);
402
+ }
403
+ );
404
+
405
+ test.each(['generateText', 'streamText'] as const)(
406
+ 'correlation IDs are automatically generated if not provided (%s)',
407
+ async (method) => {
408
+ const agent = createAgent({
409
+ model: {} as any,
410
+ events: {},
411
+ adapter: {
412
+ [method]: async (opts: any) => {
413
+ const res = {
414
+ text: 'response',
415
+ };
416
+
417
+ opts.onFinish?.(res);
418
+
419
+ return res as AgentGenerateTextResult;
420
+ },
421
+ } as any as AIAdapter,
422
+ });
423
+
424
+ await agent[method]({
425
+ prompt: 'hi',
426
+ });
427
+
428
+ const messages = agent.getMessages();
429
+
430
+ expect(messages[0]?.correlationId).toEqual(expect.stringMatching(/.+/));
431
+ expect(messages[0]?.role).toBe('user');
432
+ expect(messages[1]?.correlationId).toEqual(expect.stringMatching(/.+/));
433
+ expect(messages[1]?.role).toBe('assistant');
434
+
435
+ expect(messages[0]!.correlationId).toEqual(messages[1]!.correlationId);
436
+ }
437
+ );
438
+
439
+ test.each(['generateText', 'streamText'] as const)(
440
+ 'can provide a parent correlation ID (%s)',
441
+ async (method) => {
442
+ const agent = createAgent({
443
+ model: {} as any,
444
+ events: {},
445
+ adapter: {
446
+ [method]: async (opts: any) => {
447
+ const res = {
448
+ text: 'response',
449
+ };
450
+
451
+ opts.onFinish?.(res);
452
+
453
+ return res as AgentGenerateTextResult;
454
+ },
455
+ } as any as AIAdapter,
456
+ });
457
+
458
+ await agent[method]({
459
+ prompt: 'hi',
460
+ correlationId: 'c-1',
461
+ parentCorrelationId: 'c-0',
462
+ });
463
+
464
+ const msg = agent.getMessages().find((msg) => msg.role === 'assistant')!;
465
+
466
+ expect(msg.correlationId).toBe('c-1');
467
+ expect(msg.parentCorrelationId).toBe('c-0');
468
+ }
469
+ );
470
+
471
+ test.each(['generateText', 'streamText'] as const)(
472
+ 'can add feedback to a correlation (%s)',
473
+ async (method) => {
474
+ const agent = createAgent({
475
+ name: 'test',
476
+ model: {} as any,
477
+ events: {},
478
+ adapter: {
479
+ [method]: async (opts: any) => {
480
+ const res = {
481
+ text: 'response',
482
+ };
483
+
484
+ opts.onFinish?.(res);
485
+
486
+ return res as AgentGenerateTextResult;
487
+ },
488
+ } as any as AIAdapter,
489
+ });
490
+
491
+ const res = await agent[method]({
492
+ prompt: 'test',
493
+ });
494
+
495
+ agent.addFeedback({
496
+ correlationId: res.correlationId,
497
+ reward: -1,
498
+ });
499
+
500
+ const message = agent.getMessages()[0]!;
501
+ const feedback = agent.getFeedback()[0]!;
502
+
503
+ expect(message.correlationId).toBeDefined();
504
+ expect(feedback.correlationId).toEqual(message.correlationId);
505
+ }
506
+ );