groundswell 0.0.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/.claude/settings.local.json +9 -0
- package/.claude/system_prompts/task-breakdown.md +100 -0
- package/PRPs/001-hierarchical-workflow-engine.md +2438 -0
- package/PRPs/PRDs/001-hierarchical-workflow-engine.md +543 -0
- package/PRPs/PRDs/002-agent-prompt.md +390 -0
- package/PRPs/PRDs/003-agent-prompt.md +943 -0
- package/PRPs/PRDs/004-agent-prompt.md +1136 -0
- package/PRPs/PRDs/tasks-001.json +492 -0
- package/PRPs/README.md +83 -0
- package/PRPs/templates/prp_base.md +222 -0
- package/README.md +218 -0
- package/docs/agent.md +422 -0
- package/docs/prompt.md +419 -0
- package/docs/workflow.md +600 -0
- package/examples/README.md +244 -0
- package/examples/examples/01-basic-workflow.ts +100 -0
- package/examples/examples/02-decorator-options.ts +217 -0
- package/examples/examples/03-parent-child.ts +241 -0
- package/examples/examples/04-observers-debugger.ts +340 -0
- package/examples/examples/05-error-handling.ts +387 -0
- package/examples/examples/06-concurrent-tasks.ts +352 -0
- package/examples/examples/07-agent-loops.ts +432 -0
- package/examples/examples/08-sdk-features.ts +667 -0
- package/examples/examples/09-reflection.ts +573 -0
- package/examples/examples/10-introspection.ts +550 -0
- package/examples/index.ts +143 -0
- package/examples/utils/helpers.ts +57 -0
- package/llms_full.txt +5890 -0
- package/package.json +63 -0
- package/plan/P1P2/PRP.md +527 -0
- package/plan/P1P2/research/LRU_CACHE_BEST_PRACTICES.md +1929 -0
- package/plan/P1P2/research/LRU_CACHE_CODE_PATTERNS.md +857 -0
- package/plan/P1P2/research/LRU_CACHE_INTEGRATION_GUIDE.md +738 -0
- package/plan/P1P2/research/LRU_CACHE_RESEARCH_INDEX.md +424 -0
- package/plan/P1P2/research/REFLECTION_INDEX.md +291 -0
- package/plan/P1P2/research/REFLECTION_RESEARCH_REPORT.md +1342 -0
- package/plan/P1P2/research/RESEARCH_SUMMARY.md +342 -0
- package/plan/P1P2/research/anthropic-sdk.md +174 -0
- package/plan/P1P2/research/async-local-storage.md +200 -0
- package/plan/P1P2/research/reflection-code-patterns.md +1205 -0
- package/plan/P1P2/research/reflection-decision-matrix.md +421 -0
- package/plan/P1P2/research/reflection-implementation-guide.md +1341 -0
- package/plan/P1P2/research/reflection-integration-guide.md +834 -0
- package/plan/P1P2/research/reflection-patterns.md +1468 -0
- package/plan/P1P2/research/reflection-quick-reference.md +558 -0
- package/plan/P1P2/research/zod-schema.md +152 -0
- package/plan/P3P4/PRP.md +1388 -0
- package/plan/P3P4/research/caching-lru.md +116 -0
- package/plan/P3P4/research/introspection-tools.md +177 -0
- package/plan/P3P4/research/reflection-patterns.md +117 -0
- package/plan/P4P5/PRP.md +1136 -0
- package/plan/P4P5/research/RESEARCH_SUMMARY.md +151 -0
- package/plan/architecture/external_deps.md +358 -0
- package/plan/architecture/system_context.md +242 -0
- package/plan/backlog.json +867 -0
- package/plan/research/INTROSPECTION_RESEARCH_SUMMARY.md +378 -0
- package/plan/research/README-INTROSPECTION.md +352 -0
- package/plan/research/agent-introspection-patterns.md +1085 -0
- package/plan/research/introspection-security-guide.md +928 -0
- package/plan/research/introspection-tool-examples.md +875 -0
- package/scripts/generate-llms-full.ts +206 -0
- package/src/__tests__/integration/agent-workflow.test.ts +256 -0
- package/src/__tests__/integration/tree-mirroring.test.ts +114 -0
- package/src/__tests__/unit/agent.test.ts +169 -0
- package/src/__tests__/unit/cache-key.test.ts +182 -0
- package/src/__tests__/unit/cache.test.ts +172 -0
- package/src/__tests__/unit/context.test.ts +138 -0
- package/src/__tests__/unit/decorators.test.ts +100 -0
- package/src/__tests__/unit/introspection-tools.test.ts +277 -0
- package/src/__tests__/unit/prompt.test.ts +135 -0
- package/src/__tests__/unit/reflection.test.ts +210 -0
- package/src/__tests__/unit/tree-debugger.test.ts +85 -0
- package/src/__tests__/unit/workflow.test.ts +81 -0
- package/src/cache/cache-key.ts +244 -0
- package/src/cache/cache.ts +236 -0
- package/src/cache/index.ts +8 -0
- package/src/core/agent.ts +573 -0
- package/src/core/context.ts +119 -0
- package/src/core/event-tree.ts +260 -0
- package/src/core/factory.ts +123 -0
- package/src/core/index.ts +17 -0
- package/src/core/logger.ts +87 -0
- package/src/core/mcp-handler.ts +184 -0
- package/src/core/prompt.ts +150 -0
- package/src/core/workflow-context.ts +349 -0
- package/src/core/workflow.ts +302 -0
- package/src/debugger/index.ts +1 -0
- package/src/debugger/tree-debugger.ts +210 -0
- package/src/decorators/index.ts +3 -0
- package/src/decorators/observed-state.ts +95 -0
- package/src/decorators/step.ts +139 -0
- package/src/decorators/task.ts +96 -0
- package/src/examples/index.ts +2 -0
- package/src/examples/tdd-orchestrator.ts +65 -0
- package/src/examples/test-cycle-workflow.ts +64 -0
- package/src/index.ts +140 -0
- package/src/reflection/index.ts +5 -0
- package/src/reflection/reflection.ts +407 -0
- package/src/tools/index.ts +36 -0
- package/src/tools/introspection.ts +464 -0
- package/src/types/agent.ts +90 -0
- package/src/types/decorators.ts +25 -0
- package/src/types/error-strategy.ts +13 -0
- package/src/types/error.ts +20 -0
- package/src/types/events.ts +74 -0
- package/src/types/index.ts +55 -0
- package/src/types/logging.ts +24 -0
- package/src/types/observer.ts +18 -0
- package/src/types/prompt.ts +40 -0
- package/src/types/reflection.ts +117 -0
- package/src/types/sdk-primitives.ts +128 -0
- package/src/types/snapshot.ts +14 -0
- package/src/types/workflow-context.ts +163 -0
- package/src/types/workflow.ts +37 -0
- package/src/utils/id.ts +11 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/observable.ts +77 -0
- package/tasks.json +0 -0
- package/tsconfig.json +22 -0
- package/vitest.config.ts +16 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { Agent } from '../../core/agent.js';
|
|
3
|
+
import { MCPHandler } from '../../core/mcp-handler.js';
|
|
4
|
+
|
|
5
|
+
describe('Agent', () => {
|
|
6
|
+
it('should create with unique id', () => {
|
|
7
|
+
const a1 = new Agent();
|
|
8
|
+
const a2 = new Agent();
|
|
9
|
+
expect(a1.id).not.toBe(a2.id);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('should use default name when not provided', () => {
|
|
13
|
+
const agent = new Agent();
|
|
14
|
+
expect(agent.name).toBe('Agent');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should use custom name when provided', () => {
|
|
18
|
+
const agent = new Agent({ name: 'CustomAgent' });
|
|
19
|
+
expect(agent.name).toBe('CustomAgent');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should provide access to MCP handler', () => {
|
|
23
|
+
const agent = new Agent();
|
|
24
|
+
const handler = agent.getMcpHandler();
|
|
25
|
+
expect(handler).toBeInstanceOf(MCPHandler);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should register MCP servers from config', () => {
|
|
29
|
+
const agent = new Agent({
|
|
30
|
+
mcps: [
|
|
31
|
+
{
|
|
32
|
+
name: 'test-mcp',
|
|
33
|
+
transport: 'inprocess',
|
|
34
|
+
tools: [
|
|
35
|
+
{
|
|
36
|
+
name: 'test_tool',
|
|
37
|
+
description: 'A test tool',
|
|
38
|
+
input_schema: { type: 'object', properties: {} },
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const handler = agent.getMcpHandler();
|
|
46
|
+
expect(handler.getServerNames()).toContain('test-mcp');
|
|
47
|
+
expect(handler.hasTool('test-mcp__test_tool')).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('MCPHandler', () => {
|
|
52
|
+
it('should register and unregister servers', () => {
|
|
53
|
+
const handler = new MCPHandler();
|
|
54
|
+
|
|
55
|
+
handler.registerServer({
|
|
56
|
+
name: 'server1',
|
|
57
|
+
transport: 'inprocess',
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(handler.getServerNames()).toContain('server1');
|
|
61
|
+
|
|
62
|
+
handler.unregisterServer('server1');
|
|
63
|
+
expect(handler.getServerNames()).not.toContain('server1');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should throw when registering duplicate server', () => {
|
|
67
|
+
const handler = new MCPHandler();
|
|
68
|
+
|
|
69
|
+
handler.registerServer({
|
|
70
|
+
name: 'server1',
|
|
71
|
+
transport: 'inprocess',
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(() =>
|
|
75
|
+
handler.registerServer({
|
|
76
|
+
name: 'server1',
|
|
77
|
+
transport: 'inprocess',
|
|
78
|
+
})
|
|
79
|
+
).toThrow("MCP server 'server1' is already registered");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should convert tools to full names', () => {
|
|
83
|
+
const handler = new MCPHandler();
|
|
84
|
+
|
|
85
|
+
handler.registerServer({
|
|
86
|
+
name: 'myserver',
|
|
87
|
+
transport: 'inprocess',
|
|
88
|
+
tools: [
|
|
89
|
+
{
|
|
90
|
+
name: 'tool1',
|
|
91
|
+
description: 'Tool 1',
|
|
92
|
+
input_schema: { type: 'object', properties: {} },
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: 'tool2',
|
|
96
|
+
description: 'Tool 2',
|
|
97
|
+
input_schema: { type: 'object', properties: {} },
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const tools = handler.getTools();
|
|
103
|
+
expect(tools).toHaveLength(2);
|
|
104
|
+
expect(tools[0].name).toBe('myserver__tool1');
|
|
105
|
+
expect(tools[1].name).toBe('myserver__tool2');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should execute registered tool', async () => {
|
|
109
|
+
const handler = new MCPHandler();
|
|
110
|
+
|
|
111
|
+
handler.registerServer({
|
|
112
|
+
name: 'math',
|
|
113
|
+
transport: 'inprocess',
|
|
114
|
+
tools: [
|
|
115
|
+
{
|
|
116
|
+
name: 'add',
|
|
117
|
+
description: 'Add two numbers',
|
|
118
|
+
input_schema: {
|
|
119
|
+
type: 'object',
|
|
120
|
+
properties: {
|
|
121
|
+
a: { type: 'number' },
|
|
122
|
+
b: { type: 'number' },
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
handler.registerToolExecutor('math', 'add', async (input: unknown) => {
|
|
130
|
+
const { a, b } = input as { a: number; b: number };
|
|
131
|
+
return a + b;
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const result = await handler.executeTool('math__add', { a: 2, b: 3 });
|
|
135
|
+
expect(result.content).toBe('5');
|
|
136
|
+
expect(result.is_error).toBeUndefined();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should return error for unknown tool', async () => {
|
|
140
|
+
const handler = new MCPHandler();
|
|
141
|
+
const result = await handler.executeTool('unknown__tool', {});
|
|
142
|
+
expect(result.is_error).toBe(true);
|
|
143
|
+
expect(result.content).toContain('not found');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('should return error when tool throws', async () => {
|
|
147
|
+
const handler = new MCPHandler();
|
|
148
|
+
|
|
149
|
+
handler.registerServer({
|
|
150
|
+
name: 'failing',
|
|
151
|
+
transport: 'inprocess',
|
|
152
|
+
tools: [
|
|
153
|
+
{
|
|
154
|
+
name: 'fail',
|
|
155
|
+
description: 'Always fails',
|
|
156
|
+
input_schema: { type: 'object', properties: {} },
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
handler.registerToolExecutor('failing', 'fail', async () => {
|
|
162
|
+
throw new Error('Tool error');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const result = await handler.executeTool('failing__fail', {});
|
|
166
|
+
expect(result.is_error).toBe(true);
|
|
167
|
+
expect(result.content).toContain('Tool error');
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for cache key generation
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import {
|
|
8
|
+
deterministicStringify,
|
|
9
|
+
generateCacheKey,
|
|
10
|
+
getSchemaHash,
|
|
11
|
+
} from '../../cache/cache-key.js';
|
|
12
|
+
|
|
13
|
+
describe('deterministicStringify', () => {
|
|
14
|
+
it('should produce same output for same object regardless of key order', () => {
|
|
15
|
+
const obj1 = { b: 2, a: 1 };
|
|
16
|
+
const obj2 = { a: 1, b: 2 };
|
|
17
|
+
|
|
18
|
+
expect(deterministicStringify(obj1)).toBe(deterministicStringify(obj2));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should handle primitives correctly', () => {
|
|
22
|
+
expect(deterministicStringify(null)).toBe('null');
|
|
23
|
+
expect(deterministicStringify(undefined)).toBe('undefined');
|
|
24
|
+
expect(deterministicStringify(true)).toBe('true');
|
|
25
|
+
expect(deterministicStringify(false)).toBe('false');
|
|
26
|
+
expect(deterministicStringify(42)).toBe('42');
|
|
27
|
+
expect(deterministicStringify('hello')).toBe('"hello"');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should handle arrays correctly', () => {
|
|
31
|
+
expect(deterministicStringify([1, 2, 3])).toBe('[1,2,3]');
|
|
32
|
+
expect(deterministicStringify(['a', 'b'])).toBe('["a","b"]');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should handle nested objects with sorted keys', () => {
|
|
36
|
+
const obj = { z: { b: 2, a: 1 }, y: [3, 2, 1] };
|
|
37
|
+
const result = deterministicStringify(obj);
|
|
38
|
+
|
|
39
|
+
// Keys should be sorted: y before z, a before b
|
|
40
|
+
expect(result).toBe('{"y":[3,2,1],"z":{"a":1,"b":2}}');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should throw on circular references', () => {
|
|
44
|
+
const obj: Record<string, unknown> = { a: 1 };
|
|
45
|
+
obj.self = obj;
|
|
46
|
+
|
|
47
|
+
expect(() => deterministicStringify(obj)).toThrow(
|
|
48
|
+
'Converting circular structure to JSON'
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should handle Date objects', () => {
|
|
53
|
+
const date = new Date('2024-01-15T10:30:00.000Z');
|
|
54
|
+
const result = deterministicStringify(date);
|
|
55
|
+
|
|
56
|
+
expect(result).toBe('"2024-01-15T10:30:00.000Z"');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should handle Map objects', () => {
|
|
60
|
+
const map = new Map([
|
|
61
|
+
['z', 1],
|
|
62
|
+
['a', 2],
|
|
63
|
+
]);
|
|
64
|
+
const result = deterministicStringify(map);
|
|
65
|
+
|
|
66
|
+
// Keys should be sorted in the output
|
|
67
|
+
expect(result).toContain('Map{');
|
|
68
|
+
expect(result).toContain('"a"');
|
|
69
|
+
expect(result).toContain('"z"');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should handle Set objects', () => {
|
|
73
|
+
const set = new Set([3, 1, 2]);
|
|
74
|
+
const result = deterministicStringify(set);
|
|
75
|
+
|
|
76
|
+
expect(result).toContain('Set{');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('getSchemaHash', () => {
|
|
81
|
+
it('should produce consistent hash for same schema', () => {
|
|
82
|
+
const schema1 = z.object({ name: z.string() });
|
|
83
|
+
const schema2 = z.object({ name: z.string() });
|
|
84
|
+
|
|
85
|
+
expect(getSchemaHash(schema1)).toBe(getSchemaHash(schema2));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should produce different hashes for different schemas', () => {
|
|
89
|
+
const schema1 = z.object({ name: z.string() });
|
|
90
|
+
const schema2 = z.object({ age: z.number() });
|
|
91
|
+
|
|
92
|
+
expect(getSchemaHash(schema1)).not.toBe(getSchemaHash(schema2));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should handle undefined schema', () => {
|
|
96
|
+
expect(getSchemaHash(undefined)).toBe('no-schema');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should produce 64-character hex string', () => {
|
|
100
|
+
const schema = z.object({ value: z.number() });
|
|
101
|
+
const hash = getSchemaHash(schema);
|
|
102
|
+
|
|
103
|
+
expect(hash).toMatch(/^[a-f0-9]{64}$/);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('generateCacheKey', () => {
|
|
108
|
+
it('should produce same key for identical inputs', () => {
|
|
109
|
+
const inputs1 = {
|
|
110
|
+
user: 'Hello',
|
|
111
|
+
model: 'claude-sonnet-4-20250514',
|
|
112
|
+
data: { value: 42 },
|
|
113
|
+
};
|
|
114
|
+
const inputs2 = {
|
|
115
|
+
user: 'Hello',
|
|
116
|
+
model: 'claude-sonnet-4-20250514',
|
|
117
|
+
data: { value: 42 },
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
expect(generateCacheKey(inputs1)).toBe(generateCacheKey(inputs2));
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should produce different keys for different inputs', () => {
|
|
124
|
+
const inputs1 = {
|
|
125
|
+
user: 'Hello',
|
|
126
|
+
model: 'claude-sonnet-4-20250514',
|
|
127
|
+
};
|
|
128
|
+
const inputs2 = {
|
|
129
|
+
user: 'Goodbye',
|
|
130
|
+
model: 'claude-sonnet-4-20250514',
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
expect(generateCacheKey(inputs1)).not.toBe(generateCacheKey(inputs2));
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should produce 64-character hex string', () => {
|
|
137
|
+
const key = generateCacheKey({
|
|
138
|
+
user: 'Test',
|
|
139
|
+
model: 'claude-sonnet-4-20250514',
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(key).toMatch(/^[a-f0-9]{64}$/);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('should include tools in key generation (sorted)', () => {
|
|
146
|
+
const toolSchema = { type: 'object' as const, properties: {}, required: [] };
|
|
147
|
+
const inputs1 = {
|
|
148
|
+
user: 'Hello',
|
|
149
|
+
model: 'claude-sonnet-4-20250514',
|
|
150
|
+
tools: [
|
|
151
|
+
{ name: 'tool_b', description: 'B', input_schema: toolSchema },
|
|
152
|
+
{ name: 'tool_a', description: 'A', input_schema: toolSchema },
|
|
153
|
+
],
|
|
154
|
+
};
|
|
155
|
+
const inputs2 = {
|
|
156
|
+
user: 'Hello',
|
|
157
|
+
model: 'claude-sonnet-4-20250514',
|
|
158
|
+
tools: [
|
|
159
|
+
{ name: 'tool_a', description: 'A', input_schema: toolSchema },
|
|
160
|
+
{ name: 'tool_b', description: 'B', input_schema: toolSchema },
|
|
161
|
+
],
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// Same tools in different order should produce same key
|
|
165
|
+
expect(generateCacheKey(inputs1)).toBe(generateCacheKey(inputs2));
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should include schema hash in key', () => {
|
|
169
|
+
const schema = z.object({ result: z.string() });
|
|
170
|
+
const inputs1 = {
|
|
171
|
+
user: 'Hello',
|
|
172
|
+
model: 'claude-sonnet-4-20250514',
|
|
173
|
+
responseFormat: schema,
|
|
174
|
+
};
|
|
175
|
+
const inputs2 = {
|
|
176
|
+
user: 'Hello',
|
|
177
|
+
model: 'claude-sonnet-4-20250514',
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
expect(generateCacheKey(inputs1)).not.toBe(generateCacheKey(inputs2));
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for LLMCache
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
6
|
+
import { LLMCache } from '../../cache/cache.js';
|
|
7
|
+
|
|
8
|
+
describe('LLMCache', () => {
|
|
9
|
+
let cache: LLMCache;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
cache = new LLMCache({ maxItems: 10 });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe('get/set', () => {
|
|
16
|
+
it('should store and retrieve values', async () => {
|
|
17
|
+
await cache.set('key1', { data: 'test' });
|
|
18
|
+
const result = await cache.get('key1');
|
|
19
|
+
|
|
20
|
+
expect(result).toEqual({ data: 'test' });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should return undefined for missing keys', async () => {
|
|
24
|
+
const result = await cache.get('nonexistent');
|
|
25
|
+
|
|
26
|
+
expect(result).toBeUndefined();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should update existing keys', async () => {
|
|
30
|
+
await cache.set('key1', 'first');
|
|
31
|
+
await cache.set('key1', 'second');
|
|
32
|
+
|
|
33
|
+
const result = await cache.get('key1');
|
|
34
|
+
expect(result).toBe('second');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('has', () => {
|
|
39
|
+
it('should return true for existing keys', async () => {
|
|
40
|
+
await cache.set('key1', 'value');
|
|
41
|
+
|
|
42
|
+
expect(cache.has('key1')).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should return false for missing keys', () => {
|
|
46
|
+
expect(cache.has('nonexistent')).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('bust', () => {
|
|
51
|
+
it('should remove specific key', async () => {
|
|
52
|
+
await cache.set('key1', 'value1');
|
|
53
|
+
await cache.set('key2', 'value2');
|
|
54
|
+
|
|
55
|
+
await cache.bust('key1');
|
|
56
|
+
|
|
57
|
+
expect(cache.has('key1')).toBe(false);
|
|
58
|
+
expect(cache.has('key2')).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should not throw for missing keys', async () => {
|
|
62
|
+
await expect(cache.bust('nonexistent')).resolves.not.toThrow();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('bustPrefix', () => {
|
|
67
|
+
it('should remove all keys with given prefix', async () => {
|
|
68
|
+
await cache.set('key1', 'value1', { prefix: 'group-a' });
|
|
69
|
+
await cache.set('key2', 'value2', { prefix: 'group-a' });
|
|
70
|
+
await cache.set('key3', 'value3', { prefix: 'group-b' });
|
|
71
|
+
|
|
72
|
+
await cache.bustPrefix('group-a');
|
|
73
|
+
|
|
74
|
+
expect(cache.has('key1')).toBe(false);
|
|
75
|
+
expect(cache.has('key2')).toBe(false);
|
|
76
|
+
expect(cache.has('key3')).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should not throw for nonexistent prefix', async () => {
|
|
80
|
+
await expect(cache.bustPrefix('nonexistent')).resolves.not.toThrow();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('clear', () => {
|
|
85
|
+
it('should remove all entries', async () => {
|
|
86
|
+
await cache.set('key1', 'value1');
|
|
87
|
+
await cache.set('key2', 'value2');
|
|
88
|
+
|
|
89
|
+
await cache.clear();
|
|
90
|
+
|
|
91
|
+
expect(cache.size).toBe(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should reset metrics', async () => {
|
|
95
|
+
await cache.set('key1', 'value1');
|
|
96
|
+
await cache.get('key1'); // hit
|
|
97
|
+
await cache.get('key2'); // miss
|
|
98
|
+
|
|
99
|
+
await cache.clear();
|
|
100
|
+
|
|
101
|
+
const metrics = cache.metrics();
|
|
102
|
+
expect(metrics.hits).toBe(0);
|
|
103
|
+
expect(metrics.misses).toBe(0);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('metrics', () => {
|
|
108
|
+
it('should track hits and misses', async () => {
|
|
109
|
+
await cache.set('key1', 'value1');
|
|
110
|
+
|
|
111
|
+
await cache.get('key1'); // hit
|
|
112
|
+
await cache.get('key1'); // hit
|
|
113
|
+
await cache.get('key2'); // miss
|
|
114
|
+
|
|
115
|
+
const metrics = cache.metrics();
|
|
116
|
+
expect(metrics.hits).toBe(2);
|
|
117
|
+
expect(metrics.misses).toBe(1);
|
|
118
|
+
expect(metrics.hitRate).toBeCloseTo(66.67, 0);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should track size', async () => {
|
|
122
|
+
await cache.set('key1', 'value1');
|
|
123
|
+
await cache.set('key2', 'value2');
|
|
124
|
+
|
|
125
|
+
const metrics = cache.metrics();
|
|
126
|
+
expect(metrics.size).toBe(2);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('LRU eviction', () => {
|
|
131
|
+
it('should evict least recently used items when maxItems exceeded', async () => {
|
|
132
|
+
const smallCache = new LLMCache({ maxItems: 3 });
|
|
133
|
+
|
|
134
|
+
await smallCache.set('key1', 'value1');
|
|
135
|
+
await smallCache.set('key2', 'value2');
|
|
136
|
+
await smallCache.set('key3', 'value3');
|
|
137
|
+
await smallCache.set('key4', 'value4');
|
|
138
|
+
|
|
139
|
+
// key1 should be evicted (least recently used)
|
|
140
|
+
expect(smallCache.has('key1')).toBe(false);
|
|
141
|
+
expect(smallCache.has('key4')).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should update LRU order on get', async () => {
|
|
145
|
+
const smallCache = new LLMCache({ maxItems: 3 });
|
|
146
|
+
|
|
147
|
+
await smallCache.set('key1', 'value1');
|
|
148
|
+
await smallCache.set('key2', 'value2');
|
|
149
|
+
await smallCache.set('key3', 'value3');
|
|
150
|
+
|
|
151
|
+
// Access key1 to make it most recently used
|
|
152
|
+
await smallCache.get('key1');
|
|
153
|
+
|
|
154
|
+
// Add new item, should evict key2 instead of key1
|
|
155
|
+
await smallCache.set('key4', 'value4');
|
|
156
|
+
|
|
157
|
+
expect(smallCache.has('key1')).toBe(true);
|
|
158
|
+
expect(smallCache.has('key2')).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('keys', () => {
|
|
163
|
+
it('should return iterator of all keys', async () => {
|
|
164
|
+
await cache.set('key1', 'value1');
|
|
165
|
+
await cache.set('key2', 'value2');
|
|
166
|
+
|
|
167
|
+
const keys = Array.from(cache.keys());
|
|
168
|
+
expect(keys).toContain('key1');
|
|
169
|
+
expect(keys).toContain('key2');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
getExecutionContext,
|
|
4
|
+
requireExecutionContext,
|
|
5
|
+
runInContext,
|
|
6
|
+
runInContextSync,
|
|
7
|
+
hasExecutionContext,
|
|
8
|
+
createChildContext,
|
|
9
|
+
type AgentExecutionContext,
|
|
10
|
+
} from '../../core/context.js';
|
|
11
|
+
import type { WorkflowNode, WorkflowEvent } from '../../types/index.js';
|
|
12
|
+
|
|
13
|
+
describe('AgentExecutionContext', () => {
|
|
14
|
+
const createMockNode = (name: string): WorkflowNode => ({
|
|
15
|
+
id: `node-${name}`,
|
|
16
|
+
name,
|
|
17
|
+
parent: null,
|
|
18
|
+
children: [],
|
|
19
|
+
status: 'running',
|
|
20
|
+
logs: [],
|
|
21
|
+
events: [],
|
|
22
|
+
stateSnapshot: null,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const createMockContext = (name: string): AgentExecutionContext => ({
|
|
26
|
+
workflowNode: createMockNode(name),
|
|
27
|
+
emitEvent: () => {},
|
|
28
|
+
workflowId: `workflow-${name}`,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should return undefined outside of context', () => {
|
|
32
|
+
expect(getExecutionContext()).toBeUndefined();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should throw when requiring context outside of context', () => {
|
|
36
|
+
expect(() => requireExecutionContext('test operation')).toThrow(
|
|
37
|
+
'test operation called outside of workflow context'
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should detect context availability', () => {
|
|
42
|
+
expect(hasExecutionContext()).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should provide context within runInContext', async () => {
|
|
46
|
+
const ctx = createMockContext('test');
|
|
47
|
+
|
|
48
|
+
await runInContext(ctx, async () => {
|
|
49
|
+
expect(hasExecutionContext()).toBe(true);
|
|
50
|
+
expect(getExecutionContext()).toBe(ctx);
|
|
51
|
+
expect(requireExecutionContext('test')).toBe(ctx);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Context should be gone after run completes
|
|
55
|
+
expect(hasExecutionContext()).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should provide context within sync runInContextSync', () => {
|
|
59
|
+
const ctx = createMockContext('test');
|
|
60
|
+
|
|
61
|
+
runInContextSync(ctx, () => {
|
|
62
|
+
expect(hasExecutionContext()).toBe(true);
|
|
63
|
+
expect(getExecutionContext()).toBe(ctx);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(hasExecutionContext()).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should propagate context through nested async calls', async () => {
|
|
70
|
+
const ctx = createMockContext('root');
|
|
71
|
+
|
|
72
|
+
const nested = async () => {
|
|
73
|
+
const innerCtx = getExecutionContext();
|
|
74
|
+
return innerCtx?.workflowNode.name;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
await runInContext(ctx, async () => {
|
|
78
|
+
const name = await nested();
|
|
79
|
+
expect(name).toBe('root');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should create child context with new node', async () => {
|
|
84
|
+
const parentCtx = createMockContext('parent');
|
|
85
|
+
const childNode = createMockNode('child');
|
|
86
|
+
|
|
87
|
+
await runInContext(parentCtx, async () => {
|
|
88
|
+
const childCtx = createChildContext(childNode);
|
|
89
|
+
expect(childCtx).toBeDefined();
|
|
90
|
+
expect(childCtx?.workflowNode).toBe(childNode);
|
|
91
|
+
expect(childCtx?.workflowId).toBe(parentCtx.workflowId);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should return undefined for child context when not in context', () => {
|
|
96
|
+
const childNode = createMockNode('child');
|
|
97
|
+
const childCtx = createChildContext(childNode);
|
|
98
|
+
expect(childCtx).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should allow nested contexts', async () => {
|
|
102
|
+
const outerCtx = createMockContext('outer');
|
|
103
|
+
const innerCtx = createMockContext('inner');
|
|
104
|
+
|
|
105
|
+
await runInContext(outerCtx, async () => {
|
|
106
|
+
expect(getExecutionContext()?.workflowNode.name).toBe('outer');
|
|
107
|
+
|
|
108
|
+
await runInContext(innerCtx, async () => {
|
|
109
|
+
expect(getExecutionContext()?.workflowNode.name).toBe('inner');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Should restore outer context
|
|
113
|
+
expect(getExecutionContext()?.workflowNode.name).toBe('outer');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should capture emitted events', async () => {
|
|
118
|
+
const events: WorkflowEvent[] = [];
|
|
119
|
+
const node = createMockNode('test');
|
|
120
|
+
const ctx: AgentExecutionContext = {
|
|
121
|
+
workflowNode: node,
|
|
122
|
+
emitEvent: (event) => events.push(event),
|
|
123
|
+
workflowId: 'wf-1',
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
await runInContext(ctx, async () => {
|
|
127
|
+
const currentCtx = requireExecutionContext('emit');
|
|
128
|
+
currentCtx.emitEvent({
|
|
129
|
+
type: 'stepStart',
|
|
130
|
+
node: currentCtx.workflowNode,
|
|
131
|
+
step: 'test-step',
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(events).toHaveLength(1);
|
|
136
|
+
expect(events[0].type).toBe('stepStart');
|
|
137
|
+
});
|
|
138
|
+
});
|