@pikku/core 0.12.20 → 0.12.22
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/CHANGELOG.md +17 -0
- package/dist/dev/hot-reload.js +20 -6
- package/dist/errors/error-handler.d.ts +1 -1
- package/dist/errors/error-handler.js +2 -2
- package/dist/function/functions.types.d.ts +3 -2
- package/dist/handle-error.js +3 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/middleware/index.d.ts +2 -2
- package/dist/middleware/index.js +2 -2
- package/dist/middleware/telemetry.d.ts +2 -2
- package/dist/middleware/telemetry.js +2 -2
- package/dist/middleware-runner.d.ts +15 -27
- package/dist/middleware-runner.js +25 -30
- package/dist/permissions.d.ts +15 -22
- package/dist/permissions.js +30 -25
- package/dist/pikku-request.js +1 -1
- package/dist/pikku-state.js +2 -3
- package/dist/services/ai-agent-runner-service.d.ts +1 -0
- package/dist/services/in-memory-queue-service.js +1 -1
- package/dist/services/in-memory-workflow-service.d.ts +15 -13
- package/dist/services/in-memory-workflow-service.js +31 -13
- package/dist/services/local-content.js +8 -1
- package/dist/services/user-session-service.d.ts +1 -1
- package/dist/testing/service-tests.js +24 -0
- package/dist/types/core.types.d.ts +4 -0
- package/dist/types/state.types.d.ts +2 -18
- package/dist/wirings/ai-agent/ai-agent-memory.d.ts +24 -5
- package/dist/wirings/ai-agent/ai-agent-memory.js +128 -23
- package/dist/wirings/ai-agent/ai-agent-model-config.d.ts +10 -1
- package/dist/wirings/ai-agent/ai-agent-model-config.js +15 -35
- package/dist/wirings/ai-agent/ai-agent-prepare.js +4 -1
- package/dist/wirings/ai-agent/ai-agent-runner.js +45 -31
- package/dist/wirings/ai-agent/ai-agent-stream.js +63 -34
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +20 -0
- package/dist/wirings/channel/channel-handler.js +1 -1
- package/dist/wirings/channel/channel-store.d.ts +13 -0
- package/dist/wirings/channel/channel.types.d.ts +3 -0
- package/dist/wirings/channel/local/local-channel-runner.js +23 -5
- package/dist/wirings/channel/pikku-abstract-channel-handler.js +8 -0
- package/dist/wirings/channel/serverless/serverless-channel-runner.js +9 -0
- package/dist/wirings/cli/cli-runner.js +24 -5
- package/dist/wirings/cli/command-parser.js +19 -4
- package/dist/wirings/http/http-runner.js +72 -36
- package/dist/wirings/http/http.types.d.ts +1 -0
- package/dist/wirings/http/pikku-fetch-http-request.js +3 -1
- package/dist/wirings/http/web-request.js +32 -0
- package/dist/wirings/rpc/rpc-runner.js +13 -3
- package/dist/wirings/workflow/graph/graph-node.d.ts +8 -0
- package/dist/wirings/workflow/graph/graph-node.js +4 -0
- package/dist/wirings/workflow/graph/graph-runner.js +12 -10
- package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +4 -0
- package/dist/wirings/workflow/index.d.ts +1 -1
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +81 -16
- package/dist/wirings/workflow/pikku-workflow-service.js +266 -45
- package/dist/wirings/workflow/workflow.types.d.ts +34 -0
- package/package.json +1 -1
- package/run-tests.sh +4 -1
- package/src/dev/hot-reload.test.ts +62 -11
- package/src/dev/hot-reload.ts +28 -7
- package/src/errors/error-handler.ts +8 -2
- package/src/errors/error.test.ts +2 -0
- package/src/function/function-runner.test.ts +500 -10
- package/src/function/functions.types.ts +3 -2
- package/src/handle-error.test.ts +1 -1
- package/src/handle-error.ts +4 -1
- package/src/index.ts +12 -2
- package/src/middleware/index.ts +11 -2
- package/src/middleware/telemetry.ts +2 -2
- package/src/middleware-runner.test.ts +16 -16
- package/src/middleware-runner.ts +42 -30
- package/src/permissions.test.ts +27 -24
- package/src/permissions.ts +41 -25
- package/src/pikku-request.test.ts +35 -0
- package/src/pikku-request.ts +1 -1
- package/src/pikku-state.ts +2 -3
- package/src/run-tests-script.test.ts +18 -0
- package/src/services/ai-agent-runner-service.ts +1 -0
- package/src/services/in-memory-queue-service.ts +1 -1
- package/src/services/in-memory-session-store.ts +1 -2
- package/src/services/in-memory-workflow-service.test.ts +33 -11
- package/src/services/in-memory-workflow-service.ts +49 -13
- package/src/services/local-content.test.ts +54 -0
- package/src/services/local-content.ts +12 -1
- package/src/services/typed-credential-service.ts +3 -3
- package/src/services/typed-secret-service.ts +3 -3
- package/src/services/typed-variables-service.ts +3 -3
- package/src/services/user-session-service.ts +4 -4
- package/src/testing/service-tests.ts +30 -0
- package/src/types/core.types.ts +6 -2
- package/src/types/state.types.ts +2 -13
- package/src/wirings/ai-agent/ai-agent-memory.test.ts +324 -0
- package/src/wirings/ai-agent/ai-agent-memory.ts +187 -36
- package/src/wirings/ai-agent/ai-agent-model-config.test.ts +12 -90
- package/src/wirings/ai-agent/ai-agent-model-config.ts +14 -38
- package/src/wirings/ai-agent/ai-agent-prepare.test.ts +292 -0
- package/src/wirings/ai-agent/ai-agent-prepare.ts +4 -1
- package/src/wirings/ai-agent/ai-agent-registry.test.ts +230 -3
- package/src/wirings/ai-agent/ai-agent-runner.test.ts +625 -6
- package/src/wirings/ai-agent/ai-agent-runner.ts +65 -50
- package/src/wirings/ai-agent/ai-agent-stream.test.ts +544 -5
- package/src/wirings/ai-agent/ai-agent-stream.ts +71 -69
- package/src/wirings/ai-agent/ai-agent.types.ts +24 -0
- package/src/wirings/channel/channel-handler.test.ts +272 -0
- package/src/wirings/channel/channel-handler.ts +1 -1
- package/src/wirings/channel/channel-middleware-runner.test.ts +163 -0
- package/src/wirings/channel/channel-store.ts +19 -0
- package/src/wirings/channel/channel.types.ts +4 -0
- package/src/wirings/channel/local/local-channel-runner.test.ts +63 -0
- package/src/wirings/channel/local/local-channel-runner.ts +41 -5
- package/src/wirings/channel/local/local-eventhub-service.ts +3 -3
- package/src/wirings/channel/pikku-abstract-channel-handler.ts +9 -2
- package/src/wirings/channel/serverless/serverless-channel-runner.ts +23 -5
- package/src/wirings/cli/channel/cli-raw-channel-runner.ts +7 -2
- package/src/wirings/cli/cli-runner.test.ts +255 -2
- package/src/wirings/cli/cli-runner.ts +31 -10
- package/src/wirings/cli/cli.types.ts +4 -8
- package/src/wirings/cli/command-parser.test.ts +83 -0
- package/src/wirings/cli/command-parser.ts +23 -4
- package/src/wirings/http/http-runner.test.ts +296 -1
- package/src/wirings/http/http-runner.ts +87 -57
- package/src/wirings/http/http.types.ts +11 -26
- package/src/wirings/http/pikku-fetch-http-request.ts +8 -4
- package/src/wirings/http/pikku-fetch-http-response.test.ts +41 -0
- package/src/wirings/http/web-request.test.ts +115 -0
- package/src/wirings/http/web-request.ts +43 -5
- package/src/wirings/mcp/mcp-runner.test.ts +367 -0
- package/src/wirings/queue/queue.types.ts +2 -5
- package/src/wirings/rpc/rpc-runner.test.ts +511 -0
- package/src/wirings/rpc/rpc-runner.ts +15 -3
- package/src/wirings/rpc/wire-addon.test.ts +57 -0
- package/src/wirings/workflow/graph/graph-node.ts +12 -0
- package/src/wirings/workflow/graph/graph-runner.test.ts +98 -0
- package/src/wirings/workflow/graph/graph-runner.ts +28 -10
- package/src/wirings/workflow/graph/workflow-graph.types.ts +4 -0
- package/src/wirings/workflow/index.ts +1 -0
- package/src/wirings/workflow/pikku-workflow-service.test.ts +19 -2
- package/src/wirings/workflow/pikku-workflow-service.ts +370 -71
- package/src/wirings/workflow/workflow-queue-workers.test.ts +131 -0
- package/src/wirings/workflow/workflow.types.ts +68 -5
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { beforeEach, describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import { resetPikkuState } from '../../pikku-state.js'
|
|
5
|
+
import {
|
|
6
|
+
buildWorkingMemoryPrompt,
|
|
7
|
+
createWorkingMemoryMiddleware,
|
|
8
|
+
deepMergeWorkingMemory,
|
|
9
|
+
loadContextMessages,
|
|
10
|
+
parseWorkingMemory,
|
|
11
|
+
resolveMemoryServices,
|
|
12
|
+
saveMessages,
|
|
13
|
+
stripWorkingMemoryTags,
|
|
14
|
+
trimMessages,
|
|
15
|
+
} from './ai-agent-memory.js'
|
|
16
|
+
import type { AIMessage, CoreAIAgent } from './ai-agent.types.js'
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
resetPikkuState()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const makeMessage = (overrides: Partial<AIMessage>): AIMessage => ({
|
|
23
|
+
id: 'msg',
|
|
24
|
+
role: 'user',
|
|
25
|
+
content: 'hello',
|
|
26
|
+
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
|
27
|
+
...overrides,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('ai-agent-memory', () => {
|
|
31
|
+
test('resolveMemoryServices prefers named storage and falls back to aiStorage', () => {
|
|
32
|
+
const singletonServices = {
|
|
33
|
+
aiStorage: { kind: 'default' },
|
|
34
|
+
customStorage: { kind: 'custom' },
|
|
35
|
+
} as any
|
|
36
|
+
|
|
37
|
+
const defaultAgent = {} as CoreAIAgent
|
|
38
|
+
const namedAgent = { memory: { storage: 'customStorage' } } as CoreAIAgent
|
|
39
|
+
|
|
40
|
+
assert.equal(
|
|
41
|
+
resolveMemoryServices(defaultAgent, singletonServices).storage,
|
|
42
|
+
singletonServices.aiStorage
|
|
43
|
+
)
|
|
44
|
+
assert.equal(
|
|
45
|
+
resolveMemoryServices(namedAgent, singletonServices).storage,
|
|
46
|
+
singletonServices.customStorage
|
|
47
|
+
)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('deepMergeWorkingMemory merges nested objects and deletes null fields', () => {
|
|
51
|
+
const result = deepMergeWorkingMemory(
|
|
52
|
+
{
|
|
53
|
+
profile: {
|
|
54
|
+
name: 'Yasser',
|
|
55
|
+
stats: { commits: 1, reviews: 2 },
|
|
56
|
+
},
|
|
57
|
+
stale: true,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
profile: {
|
|
61
|
+
stats: { reviews: 3 },
|
|
62
|
+
},
|
|
63
|
+
stale: null,
|
|
64
|
+
active: false,
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
assert.deepEqual(result, {
|
|
69
|
+
profile: {
|
|
70
|
+
name: 'Yasser',
|
|
71
|
+
stats: { commits: 1, reviews: 3 },
|
|
72
|
+
},
|
|
73
|
+
active: false,
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('deepMergeWorkingMemory ignores prototype-polluting keys', () => {
|
|
78
|
+
const before = ({} as any).isAdmin
|
|
79
|
+
deepMergeWorkingMemory(
|
|
80
|
+
{},
|
|
81
|
+
JSON.parse('{"constructor":{"prototype":{"isAdmin":true}}}')
|
|
82
|
+
)
|
|
83
|
+
assert.equal(({} as any).isAdmin, before)
|
|
84
|
+
|
|
85
|
+
deepMergeWorkingMemory({}, JSON.parse('{"__proto__":{"isAdmin":true}}'))
|
|
86
|
+
assert.equal(({} as any).isAdmin, before)
|
|
87
|
+
|
|
88
|
+
deepMergeWorkingMemory({}, { prototype: { isAdmin: true } })
|
|
89
|
+
assert.equal(({} as any).isAdmin, before)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('buildWorkingMemoryPrompt includes schema fields and current state', () => {
|
|
93
|
+
const prompt = buildWorkingMemoryPrompt(
|
|
94
|
+
{ city: 'Berlin' },
|
|
95
|
+
{
|
|
96
|
+
properties: {
|
|
97
|
+
city: { type: 'string', description: 'Current city' },
|
|
98
|
+
age: { type: 'number' },
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
assert.match(prompt, /Working memory fields:/)
|
|
104
|
+
assert.match(prompt, /city \(string\) - Current city/)
|
|
105
|
+
assert.match(prompt, /age \(number\)/)
|
|
106
|
+
assert.match(prompt, /Current working memory:\n\{"city":"Berlin"\}/)
|
|
107
|
+
assert.match(prompt, /<working_memory>/)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('buildWorkingMemoryPrompt marks empty memory explicitly', () => {
|
|
111
|
+
const prompt = buildWorkingMemoryPrompt(null)
|
|
112
|
+
assert.match(prompt, /Current working memory: \(empty\)/)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('loadContextMessages injects working memory system prompt when enabled', async () => {
|
|
116
|
+
const storage = {
|
|
117
|
+
getWorkingMemory: async () => ({ city: 'Berlin' }),
|
|
118
|
+
} as any
|
|
119
|
+
|
|
120
|
+
const messages = await loadContextMessages(
|
|
121
|
+
{ workingMemory: true },
|
|
122
|
+
storage,
|
|
123
|
+
{ message: 'hello', threadId: 'thread-1', resourceId: 'resource-1' },
|
|
124
|
+
{ properties: { city: { type: 'string' } } }
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
assert.equal(messages.length, 1)
|
|
128
|
+
assert.equal(messages[0].role, 'system')
|
|
129
|
+
assert.match(String(messages[0].content), /Current working memory:/)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('loadContextMessages returns no messages without storage', async () => {
|
|
133
|
+
const messages = await loadContextMessages(
|
|
134
|
+
{ workingMemory: true },
|
|
135
|
+
undefined,
|
|
136
|
+
{ message: 'hello', threadId: 'thread-1', resourceId: 'resource-1' }
|
|
137
|
+
)
|
|
138
|
+
assert.deepEqual(messages, [])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test('saveMessages persists tool calls and strips working memory tags from returned text', async () => {
|
|
142
|
+
const savedMessages: AIMessage[][] = []
|
|
143
|
+
|
|
144
|
+
const storage = {
|
|
145
|
+
saveMessages: async (_threadId: string, messages: AIMessage[]) => {
|
|
146
|
+
savedMessages.push(messages)
|
|
147
|
+
},
|
|
148
|
+
} as any
|
|
149
|
+
|
|
150
|
+
const resultText = await saveMessages(
|
|
151
|
+
storage,
|
|
152
|
+
'thread-1',
|
|
153
|
+
'resource-1',
|
|
154
|
+
{ workingMemory: true },
|
|
155
|
+
makeMessage({ id: 'user-1' }),
|
|
156
|
+
{
|
|
157
|
+
text: 'Done <working_memory>{"city":"Berlin"}</working_memory>',
|
|
158
|
+
steps: [
|
|
159
|
+
{
|
|
160
|
+
toolCalls: [
|
|
161
|
+
{
|
|
162
|
+
name: 'tool-1',
|
|
163
|
+
args: { q: 'hello' },
|
|
164
|
+
result: 'world',
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
assert.equal(resultText, 'Done')
|
|
173
|
+
assert.equal(savedMessages.length, 1)
|
|
174
|
+
assert.equal(savedMessages[0].length, 4)
|
|
175
|
+
assert.equal(savedMessages[0][0].role, 'user')
|
|
176
|
+
assert.equal(savedMessages[0][1].role, 'assistant')
|
|
177
|
+
assert.equal(savedMessages[0][2].role, 'tool')
|
|
178
|
+
assert.equal(savedMessages[0][3].role, 'assistant')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('saveMessages returns text unchanged when no storage is configured', async () => {
|
|
182
|
+
const passthrough = await saveMessages(
|
|
183
|
+
undefined,
|
|
184
|
+
'thread-1',
|
|
185
|
+
'resource-1',
|
|
186
|
+
{ workingMemory: true },
|
|
187
|
+
null,
|
|
188
|
+
{ text: 'plain text', steps: [] }
|
|
189
|
+
)
|
|
190
|
+
assert.equal(passthrough, 'plain text')
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
test('createWorkingMemoryMiddleware merges, validates, and persists working memory', async () => {
|
|
194
|
+
const savedWorkingMemory: unknown[] = []
|
|
195
|
+
const warnings: string[] = []
|
|
196
|
+
|
|
197
|
+
const storage = {
|
|
198
|
+
getWorkingMemory: async () => ({ city: 'Paris', removeMe: 'x' }),
|
|
199
|
+
saveWorkingMemory: async (
|
|
200
|
+
threadId: string,
|
|
201
|
+
scope: string,
|
|
202
|
+
value: unknown
|
|
203
|
+
) => {
|
|
204
|
+
savedWorkingMemory.push({ threadId, scope, value })
|
|
205
|
+
},
|
|
206
|
+
} as any
|
|
207
|
+
|
|
208
|
+
const mw = createWorkingMemoryMiddleware({
|
|
209
|
+
storage,
|
|
210
|
+
threadId: 'thread-1',
|
|
211
|
+
workingMemorySchemaName: 'WorkingMemory',
|
|
212
|
+
schemaService: { validateSchema: async () => {} } as any,
|
|
213
|
+
logger: { warn: (message: string) => warnings.push(message) } as any,
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
const result = (await mw.modifyOutput!({} as any, {
|
|
217
|
+
text: 'Done <working_memory>{"city":"Berlin","removeMe":null}</working_memory>',
|
|
218
|
+
messages: [],
|
|
219
|
+
usage: {} as any,
|
|
220
|
+
})) as { text: string }
|
|
221
|
+
|
|
222
|
+
assert.equal(result.text, 'Done')
|
|
223
|
+
assert.deepEqual(savedWorkingMemory, [
|
|
224
|
+
{
|
|
225
|
+
threadId: 'thread-1',
|
|
226
|
+
scope: 'thread',
|
|
227
|
+
value: { city: 'Berlin' },
|
|
228
|
+
},
|
|
229
|
+
])
|
|
230
|
+
assert.deepEqual(warnings, [])
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
test('createWorkingMemoryMiddleware warns and skips persistence on schema failure', async () => {
|
|
234
|
+
const savedWorkingMemory: unknown[] = []
|
|
235
|
+
const warnings: string[] = []
|
|
236
|
+
|
|
237
|
+
const storage = {
|
|
238
|
+
getWorkingMemory: async () => ({}),
|
|
239
|
+
saveWorkingMemory: async (...args: unknown[]) => {
|
|
240
|
+
savedWorkingMemory.push(args)
|
|
241
|
+
},
|
|
242
|
+
} as any
|
|
243
|
+
|
|
244
|
+
const mw = createWorkingMemoryMiddleware({
|
|
245
|
+
storage,
|
|
246
|
+
threadId: 'thread-1',
|
|
247
|
+
workingMemorySchemaName: 'WorkingMemory',
|
|
248
|
+
schemaService: {
|
|
249
|
+
validateSchema: async () => {
|
|
250
|
+
throw new Error('bad schema')
|
|
251
|
+
},
|
|
252
|
+
} as any,
|
|
253
|
+
logger: { warn: (message: string) => warnings.push(message) } as any,
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
const result = (await mw.modifyOutput!({} as any, {
|
|
257
|
+
text: '<working_memory>{"city":"Berlin"}</working_memory>',
|
|
258
|
+
messages: [],
|
|
259
|
+
usage: {} as any,
|
|
260
|
+
})) as { text: string }
|
|
261
|
+
|
|
262
|
+
assert.equal(result.text, '')
|
|
263
|
+
assert.deepEqual(warnings, ['Working memory validation failed: bad schema'])
|
|
264
|
+
assert.deepEqual(savedWorkingMemory, [])
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
test('trimMessages sanitizes orphaned tool messages and keeps first user/system boundary', () => {
|
|
268
|
+
const messages: AIMessage[] = [
|
|
269
|
+
makeMessage({
|
|
270
|
+
id: 'assistant-1',
|
|
271
|
+
role: 'assistant',
|
|
272
|
+
content: 'tool call text',
|
|
273
|
+
toolCalls: [{ id: 'tc-1', name: 'tool', args: { x: 1 } }],
|
|
274
|
+
}),
|
|
275
|
+
makeMessage({
|
|
276
|
+
id: 'tool-1',
|
|
277
|
+
role: 'tool',
|
|
278
|
+
toolResults: [{ id: 'other', name: 'tool', result: 'wrong' }],
|
|
279
|
+
}),
|
|
280
|
+
makeMessage({
|
|
281
|
+
id: 'user-1',
|
|
282
|
+
role: 'user',
|
|
283
|
+
content: 'real user message',
|
|
284
|
+
}),
|
|
285
|
+
makeMessage({
|
|
286
|
+
id: 'assistant-2',
|
|
287
|
+
role: 'assistant',
|
|
288
|
+
content: 'final answer',
|
|
289
|
+
}),
|
|
290
|
+
]
|
|
291
|
+
|
|
292
|
+
const trimmed = trimMessages(messages, 1000)
|
|
293
|
+
assert.equal(trimmed[0].role, 'user')
|
|
294
|
+
assert.equal(trimmed[1].role, 'assistant')
|
|
295
|
+
assert.equal(trimmed[1].content, 'final answer')
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
test('trimMessages keeps latest message even when over token budget', () => {
|
|
299
|
+
const trimmed = trimMessages(
|
|
300
|
+
[makeMessage({ content: 'a'.repeat(1000) })],
|
|
301
|
+
1
|
|
302
|
+
)
|
|
303
|
+
assert.equal(trimmed.length, 1)
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
test('parseWorkingMemory and stripWorkingMemoryTags handle valid and invalid payloads', () => {
|
|
307
|
+
assert.deepEqual(
|
|
308
|
+
parseWorkingMemory(
|
|
309
|
+
'a <working_memory>{"city":"Berlin"}</working_memory> b'
|
|
310
|
+
),
|
|
311
|
+
{ city: 'Berlin' }
|
|
312
|
+
)
|
|
313
|
+
assert.equal(
|
|
314
|
+
parseWorkingMemory('<working_memory>{oops}</working_memory>'),
|
|
315
|
+
null
|
|
316
|
+
)
|
|
317
|
+
assert.equal(
|
|
318
|
+
stripWorkingMemoryTags(
|
|
319
|
+
'before <working_memory>{"city":"Berlin"}</working_memory> after'
|
|
320
|
+
),
|
|
321
|
+
'before after'.trim()
|
|
322
|
+
)
|
|
323
|
+
})
|
|
324
|
+
})
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
import type { AIStorageService } from '../../services/ai-storage-service.js'
|
|
10
10
|
import type { Logger } from '../../services/logger.js'
|
|
11
11
|
import type { SchemaService } from '../../services/schema-service.js'
|
|
12
|
+
import type { PikkuAIMiddlewareHooks } from './ai-agent.types.js'
|
|
12
13
|
|
|
13
14
|
export function resolveMemoryServices(
|
|
14
15
|
agent: CoreAIAgent,
|
|
@@ -23,12 +24,22 @@ export function resolveMemoryServices(
|
|
|
23
24
|
return { storage }
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
export function isWorkingMemoryEnabled(
|
|
28
|
+
memoryConfig: AIAgentMemoryConfig | undefined,
|
|
29
|
+
storage: AIStorageService | undefined
|
|
30
|
+
): boolean {
|
|
31
|
+
return !!memoryConfig?.workingMemory && !!storage
|
|
32
|
+
}
|
|
33
|
+
|
|
26
34
|
export function deepMergeWorkingMemory(
|
|
27
35
|
existing: Record<string, unknown>,
|
|
28
36
|
updates: Record<string, unknown>
|
|
29
37
|
): Record<string, unknown> {
|
|
30
38
|
const result = { ...existing }
|
|
31
39
|
for (const key of Object.keys(updates)) {
|
|
40
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
|
|
41
|
+
continue
|
|
42
|
+
}
|
|
32
43
|
const value = updates[key]
|
|
33
44
|
if (value === null) {
|
|
34
45
|
delete result[key]
|
|
@@ -79,7 +90,9 @@ export function buildWorkingMemoryPrompt(
|
|
|
79
90
|
|
|
80
91
|
parts.push(
|
|
81
92
|
'When you learn new information, output a partial JSON update in <working_memory> tags. ' +
|
|
82
|
-
'Only include
|
|
93
|
+
'Only include durable facts you have actually derived or the user has confirmed. ' +
|
|
94
|
+
'Do not output templates, placeholders, or narration. ' +
|
|
95
|
+
'Only include changed fields. Leave unknown fields untouched. Set a field to null to delete it.'
|
|
83
96
|
)
|
|
84
97
|
|
|
85
98
|
return parts.join('\n\n')
|
|
@@ -93,8 +106,15 @@ export async function loadContextMessages(
|
|
|
93
106
|
): Promise<AIMessage[]> {
|
|
94
107
|
const contextMessages: AIMessage[] = []
|
|
95
108
|
|
|
96
|
-
|
|
97
|
-
|
|
109
|
+
const workingMemoryStorage = isWorkingMemoryEnabled(memoryConfig, storage)
|
|
110
|
+
? storage
|
|
111
|
+
: undefined
|
|
112
|
+
|
|
113
|
+
if (workingMemoryStorage) {
|
|
114
|
+
const workingMem = await workingMemoryStorage.getWorkingMemory(
|
|
115
|
+
input.threadId,
|
|
116
|
+
'thread'
|
|
117
|
+
)
|
|
98
118
|
const prompt = buildWorkingMemoryPrompt(workingMem, workingMemoryJsonSchema)
|
|
99
119
|
contextMessages.push({
|
|
100
120
|
id: `wm-${randomUUID()}`,
|
|
@@ -115,6 +135,7 @@ export async function saveMessages(
|
|
|
115
135
|
userMessage: AIMessage | null | undefined,
|
|
116
136
|
result: {
|
|
117
137
|
text: string
|
|
138
|
+
uiSpec?: unknown
|
|
118
139
|
steps: {
|
|
119
140
|
toolCalls?: {
|
|
120
141
|
name: string
|
|
@@ -122,15 +143,11 @@ export async function saveMessages(
|
|
|
122
143
|
result: string
|
|
123
144
|
}[]
|
|
124
145
|
}[]
|
|
125
|
-
},
|
|
126
|
-
options?: {
|
|
127
|
-
workingMemoryJsonSchema?: Record<string, unknown>
|
|
128
|
-
workingMemorySchemaName?: string | null
|
|
129
|
-
logger?: Logger
|
|
130
|
-
schemaService?: SchemaService
|
|
131
146
|
}
|
|
132
147
|
): Promise<string> {
|
|
133
|
-
|
|
148
|
+
const responseText = memoryConfig?.workingMemory
|
|
149
|
+
? extractWorkingMemory(result.text).cleanedText
|
|
150
|
+
: result.text
|
|
134
151
|
|
|
135
152
|
if (storage) {
|
|
136
153
|
const newMessages: AIMessage[] = userMessage ? [userMessage] : []
|
|
@@ -161,39 +178,24 @@ export async function saveMessages(
|
|
|
161
178
|
}
|
|
162
179
|
}
|
|
163
180
|
|
|
181
|
+
const assistantContent =
|
|
182
|
+
result.uiSpec != null
|
|
183
|
+
? [
|
|
184
|
+
...(responseText
|
|
185
|
+
? [{ type: 'text' as const, text: responseText }]
|
|
186
|
+
: []),
|
|
187
|
+
{ type: 'generative-ui' as const, spec: result.uiSpec },
|
|
188
|
+
]
|
|
189
|
+
: responseText
|
|
190
|
+
|
|
164
191
|
newMessages.push({
|
|
165
192
|
id: randomUUID(),
|
|
166
193
|
role: 'assistant',
|
|
167
|
-
content:
|
|
194
|
+
content: assistantContent || undefined,
|
|
168
195
|
createdAt: new Date(),
|
|
169
196
|
})
|
|
170
197
|
|
|
171
198
|
await storage.saveMessages(threadId, newMessages)
|
|
172
|
-
|
|
173
|
-
if (memoryConfig?.workingMemory) {
|
|
174
|
-
const parsed = parseWorkingMemory(responseText)
|
|
175
|
-
if (parsed) {
|
|
176
|
-
const existing =
|
|
177
|
-
(await storage.getWorkingMemory(threadId, 'thread')) ?? {}
|
|
178
|
-
const merged = deepMergeWorkingMemory(existing, parsed)
|
|
179
|
-
|
|
180
|
-
if (options?.schemaService && options?.workingMemorySchemaName) {
|
|
181
|
-
try {
|
|
182
|
-
await options.schemaService.validateSchema(
|
|
183
|
-
options.workingMemorySchemaName,
|
|
184
|
-
merged
|
|
185
|
-
)
|
|
186
|
-
} catch (err) {
|
|
187
|
-
options.logger?.warn(
|
|
188
|
-
`Working memory validation failed: ${err instanceof Error ? err.message : String(err)}`
|
|
189
|
-
)
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
await storage.saveWorkingMemory(threadId, 'thread', merged)
|
|
194
|
-
responseText = stripWorkingMemoryTags(responseText)
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
199
|
}
|
|
198
200
|
|
|
199
201
|
return responseText
|
|
@@ -308,3 +310,152 @@ export function parseWorkingMemory(
|
|
|
308
310
|
export function stripWorkingMemoryTags(text: string): string {
|
|
309
311
|
return text.replace(/<working_memory>[\s\S]*?<\/working_memory>/g, '').trim()
|
|
310
312
|
}
|
|
313
|
+
|
|
314
|
+
export function extractWorkingMemory(text: string): {
|
|
315
|
+
workingMemory: Record<string, unknown> | null
|
|
316
|
+
cleanedText: string
|
|
317
|
+
} {
|
|
318
|
+
const workingMemory = parseWorkingMemory(text)
|
|
319
|
+
return {
|
|
320
|
+
workingMemory,
|
|
321
|
+
cleanedText: workingMemory ? stripWorkingMemoryTags(text) : text,
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function getPendingWorkingMemoryPrefixLength(text: string): number {
|
|
326
|
+
const prefixes = ['<working_memory>', '</working_memory>']
|
|
327
|
+
const lastLt = text.lastIndexOf('<')
|
|
328
|
+
if (lastLt === -1) return 0
|
|
329
|
+
|
|
330
|
+
const suffix = text.slice(lastLt)
|
|
331
|
+
for (const prefix of prefixes) {
|
|
332
|
+
if (prefix.startsWith(suffix)) {
|
|
333
|
+
return suffix.length
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return 0
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function stripWorkingMemoryForStreaming(text: string): string {
|
|
341
|
+
let output = ''
|
|
342
|
+
let cursor = 0
|
|
343
|
+
|
|
344
|
+
while (cursor < text.length) {
|
|
345
|
+
const openIndex = text.indexOf('<working_memory>', cursor)
|
|
346
|
+
if (openIndex === -1) {
|
|
347
|
+
const tail = text.slice(cursor)
|
|
348
|
+
const pendingPrefixLength = getPendingWorkingMemoryPrefixLength(tail)
|
|
349
|
+
output +=
|
|
350
|
+
pendingPrefixLength > 0 ? tail.slice(0, -pendingPrefixLength) : tail
|
|
351
|
+
return output
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
output += text.slice(cursor, openIndex)
|
|
355
|
+
const closeIndex = text.indexOf('</working_memory>', openIndex)
|
|
356
|
+
if (closeIndex === -1) {
|
|
357
|
+
return output
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
cursor = closeIndex + '</working_memory>'.length
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return output
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function createWorkingMemoryMiddleware(options: {
|
|
367
|
+
storage?: AIStorageService
|
|
368
|
+
threadId: string
|
|
369
|
+
workingMemorySchemaName?: string | null
|
|
370
|
+
logger?: Logger
|
|
371
|
+
schemaService?: SchemaService
|
|
372
|
+
}): PikkuAIMiddlewareHooks<{
|
|
373
|
+
rawText?: string
|
|
374
|
+
emittedVisibleText?: string
|
|
375
|
+
}> {
|
|
376
|
+
return {
|
|
377
|
+
modifyOutputStream: async (_services, { event, state }) => {
|
|
378
|
+
if (event.type !== 'text-delta') return event
|
|
379
|
+
|
|
380
|
+
const rawText = `${state.rawText ?? ''}${event.text}`
|
|
381
|
+
state.rawText = rawText
|
|
382
|
+
|
|
383
|
+
const visibleText = stripWorkingMemoryForStreaming(rawText)
|
|
384
|
+
const emittedVisibleText = state.emittedVisibleText ?? ''
|
|
385
|
+
|
|
386
|
+
if (!visibleText.startsWith(emittedVisibleText)) {
|
|
387
|
+
state.emittedVisibleText = visibleText
|
|
388
|
+
return { ...event, text: visibleText }
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const delta = visibleText.slice(emittedVisibleText.length)
|
|
392
|
+
state.emittedVisibleText = visibleText
|
|
393
|
+
if (!delta) return null
|
|
394
|
+
return { ...event, text: delta }
|
|
395
|
+
},
|
|
396
|
+
modifyOutput: async (_services, { text, messages, usage }) => {
|
|
397
|
+
const { workingMemory, cleanedText } = extractWorkingMemory(text)
|
|
398
|
+
if (workingMemory && options.storage) {
|
|
399
|
+
const existing =
|
|
400
|
+
(await options.storage.getWorkingMemory(
|
|
401
|
+
options.threadId,
|
|
402
|
+
'thread'
|
|
403
|
+
)) ?? {}
|
|
404
|
+
const merged = deepMergeWorkingMemory(existing, workingMemory)
|
|
405
|
+
|
|
406
|
+
let valid = true
|
|
407
|
+
if (options.schemaService && options.workingMemorySchemaName) {
|
|
408
|
+
try {
|
|
409
|
+
await options.schemaService.validateSchema(
|
|
410
|
+
options.workingMemorySchemaName,
|
|
411
|
+
merged
|
|
412
|
+
)
|
|
413
|
+
} catch (err) {
|
|
414
|
+
valid = false
|
|
415
|
+
options.logger?.warn(
|
|
416
|
+
`Working memory validation failed: ${err instanceof Error ? err.message : String(err)}`
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Only persist when the merged value passes schema validation —
|
|
422
|
+
// saving invalid data would poison subsequent getWorkingMemory reads.
|
|
423
|
+
if (valid) {
|
|
424
|
+
await options.storage.saveWorkingMemory(
|
|
425
|
+
options.threadId,
|
|
426
|
+
'thread',
|
|
427
|
+
merged
|
|
428
|
+
)
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
text: cleanedText,
|
|
434
|
+
messages,
|
|
435
|
+
}
|
|
436
|
+
},
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function getWorkingMemoryMiddleware(
|
|
441
|
+
memoryConfig: AIAgentMemoryConfig | undefined,
|
|
442
|
+
storage: AIStorageService | undefined,
|
|
443
|
+
options: {
|
|
444
|
+
threadId: string
|
|
445
|
+
workingMemorySchemaName?: string | null
|
|
446
|
+
logger?: Logger
|
|
447
|
+
schemaService?: SchemaService
|
|
448
|
+
}
|
|
449
|
+
): PikkuAIMiddlewareHooks[] {
|
|
450
|
+
if (!isWorkingMemoryEnabled(memoryConfig, storage)) return []
|
|
451
|
+
|
|
452
|
+
return [
|
|
453
|
+
createWorkingMemoryMiddleware({
|
|
454
|
+
storage,
|
|
455
|
+
threadId: options.threadId,
|
|
456
|
+
workingMemorySchemaName: options.workingMemorySchemaName,
|
|
457
|
+
logger: options.logger,
|
|
458
|
+
schemaService: options.schemaService,
|
|
459
|
+
}),
|
|
460
|
+
]
|
|
461
|
+
}
|