@owlmeans/agent 0.1.18-rc.39 → 0.1.18-rc.41
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/README.md +1 -1
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/agent/SKILL.md +96 -9
- package/build/model.d.ts.map +1 -1
- package/build/model.js +4 -1
- package/build/model.js.map +1 -1
- package/build/pipeline/index.d.ts +1 -0
- package/build/pipeline/index.d.ts.map +1 -1
- package/build/pipeline/index.js +1 -0
- package/build/pipeline/index.js.map +1 -1
- package/build/pipeline/results.d.ts +121 -0
- package/build/pipeline/results.d.ts.map +1 -0
- package/build/pipeline/results.js +572 -0
- package/build/pipeline/results.js.map +1 -0
- package/build/pipeline/runner.d.ts.map +1 -1
- package/build/pipeline/runner.js +170 -15
- package/build/pipeline/runner.js.map +1 -1
- package/build/pipeline/types.d.ts +140 -2
- package/build/pipeline/types.d.ts.map +1 -1
- package/build/stores/memory.d.ts +10 -1
- package/build/stores/memory.d.ts.map +1 -1
- package/build/stores/memory.js +0 -0
- package/build/stores/memory.js.map +1 -1
- package/build/stores/types.d.ts +23 -1
- package/build/stores/types.d.ts.map +1 -1
- package/package.json +6 -6
- package/src/model.ts +4 -1
- package/src/pipeline/index.ts +1 -0
- package/src/pipeline/results.ts +827 -0
- package/src/pipeline/runner.ts +184 -15
- package/src/pipeline/types.ts +155 -2
- package/src/stores/memory.ts +0 -0
- package/src/stores/types.ts +24 -1
- package/tests/pipeline-compat.spec.ts +195 -0
- package/tests/pipeline-plugins.spec.ts +194 -0
- package/tests/pipeline-results.spec.ts +456 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { AIMessageChunk } from '@langchain/core/messages'
|
|
3
|
+
import type { BaseChatModel } from '@langchain/core/language_models/chat_models'
|
|
4
|
+
import {
|
|
5
|
+
CumulativeFactKind, CumulativeResultSource, PipelineRunStatus, ResultViewMode,
|
|
6
|
+
} from '@owlmeans/agent-common'
|
|
7
|
+
import type { CumulativeResultFact, CumulativeResultsSpec, PipelineSpec } from '@owlmeans/agent-common'
|
|
8
|
+
import { makeExecutionService, makeLlmModel, makePromptService } from '@owlmeans/llm'
|
|
9
|
+
import type { LlmService, LlmSpectator } from '@owlmeans/llm'
|
|
10
|
+
import { ExecutionEffort } from '@owlmeans/llm-common'
|
|
11
|
+
import {
|
|
12
|
+
createMemoryCumulativeResultStore, createMemoryPipelineRunStore, cumulativeResultsPlugin,
|
|
13
|
+
makePipeline,
|
|
14
|
+
} from '../src/index.js'
|
|
15
|
+
import type {
|
|
16
|
+
CumulativeResultStore, CumulativeResultsPluginOptions, PipelineRunStore, PipelineStep, ResultExtractor,
|
|
17
|
+
StepResults,
|
|
18
|
+
} from '../src/index.js'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* CUMULATIVE PIPELINE RESULTS, end to end: a step is told what its predecessors produced, read
|
|
22
|
+
* from their files by code, and the ledger stays honest across a repair, a crash and a composition.
|
|
23
|
+
*
|
|
24
|
+
* The project is a Map of files in `deps` — the durable input every extractor reads — so a "new
|
|
25
|
+
* process" is simply a new plugin over the same files.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
interface State extends Record<string, unknown> { done?: string[] }
|
|
29
|
+
interface Deps { files: Map<string, string>, fail: Set<string>, seen: Record<string, StepResults | undefined> }
|
|
30
|
+
|
|
31
|
+
const types: ResultExtractor<State, Deps> = {
|
|
32
|
+
scope: ({ deps }) => [...deps.files.keys()].filter(path => path.startsWith('src/types/')),
|
|
33
|
+
extract: ({ deps, files }) => files.flatMap(path =>
|
|
34
|
+
[...(deps.files.get(path) ?? '').matchAll(/export interface (\w+) \{([^}]*)\}/g)].map(match => ({
|
|
35
|
+
kind: CumulativeFactKind.Type,
|
|
36
|
+
name: match[1]!,
|
|
37
|
+
members: match[2]!.split(';').map(member => member.trim()).filter(member => member !== ''),
|
|
38
|
+
specifier: '@app/types',
|
|
39
|
+
path,
|
|
40
|
+
}))),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const resources: ResultExtractor<State, Deps> = {
|
|
44
|
+
scope: ({ deps }) => [...deps.files.keys()].filter(path => path.startsWith('src/resources/')),
|
|
45
|
+
extract: ({ deps, files }) => files.flatMap(path =>
|
|
46
|
+
[...(deps.files.get(path) ?? '').matchAll(/resource (\w+) -> (\w+)/g)].map(match => ({
|
|
47
|
+
kind: CumulativeFactKind.Resource, name: match[1]!, ref: match[2]!, path,
|
|
48
|
+
}))),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** An extractor that re-finds EVERY type in the project — the duplicate an ownership rule stops. */
|
|
52
|
+
const everyType: ResultExtractor<State, Deps> = {
|
|
53
|
+
scope: ({ deps }) => [...deps.files.keys()],
|
|
54
|
+
extract: input => (types as { extract: (i: typeof input) => CumulativeResultFact[] }).extract(input),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const extractors = { types, resources, everyType }
|
|
58
|
+
|
|
59
|
+
const spec: PipelineSpec = {
|
|
60
|
+
alias: 'build',
|
|
61
|
+
version: 1,
|
|
62
|
+
steps: [
|
|
63
|
+
{ step: 'types' },
|
|
64
|
+
{ step: 'resources', after: ['types'] },
|
|
65
|
+
{ step: 'endpoints', after: ['resources'] },
|
|
66
|
+
{ step: 'review', after: ['endpoints'] },
|
|
67
|
+
],
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const declared: CumulativeResultsSpec = {
|
|
71
|
+
steps: {
|
|
72
|
+
types: { extractors: ['types'] },
|
|
73
|
+
resources: { extractors: ['resources'] },
|
|
74
|
+
endpoints: { extractors: ['everyType'] },
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const write = (deps: Deps, results: StepResults | undefined, path: string, text: string): void => {
|
|
79
|
+
deps.files.set(path, text)
|
|
80
|
+
results?.record({ files: [path] })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const steps = (patch: Partial<Record<string, PipelineStep<State, Deps>['run']>> = {}): PipelineStep<State, Deps>[] =>
|
|
84
|
+
spec.steps.map(({ step }) => ({
|
|
85
|
+
step,
|
|
86
|
+
run: patch[step] ?? (async (_state, ctx) => {
|
|
87
|
+
ctx.deps.seen[step] = ctx.results
|
|
88
|
+
if (ctx.deps.fail.has(step)) {
|
|
89
|
+
throw new Error(`${step} refused`)
|
|
90
|
+
}
|
|
91
|
+
if (step === 'types') {
|
|
92
|
+
write(ctx.deps, ctx.results, 'src/types/user.ts', 'export interface User { id: string; email: string }')
|
|
93
|
+
}
|
|
94
|
+
if (step === 'resources') {
|
|
95
|
+
write(ctx.deps, ctx.results, 'src/resources/users.ts', 'resource users -> User')
|
|
96
|
+
}
|
|
97
|
+
return { done: [...(_state.done ?? []), step] }
|
|
98
|
+
}),
|
|
99
|
+
}))
|
|
100
|
+
|
|
101
|
+
const deps = (fail: string[] = [], files = new Map<string, string>()): Deps =>
|
|
102
|
+
({ files, fail: new Set(fail), seen: {} })
|
|
103
|
+
|
|
104
|
+
const build = (
|
|
105
|
+
store: CumulativeResultStore,
|
|
106
|
+
runs: PipelineRunStore = createMemoryPipelineRunStore(),
|
|
107
|
+
options: Partial<CumulativeResultsPluginOptions<State, Deps>> = {},
|
|
108
|
+
handlers: PipelineStep<State, Deps>[] = steps(),
|
|
109
|
+
) => makePipeline<State, Deps>(spec, {
|
|
110
|
+
steps: handlers,
|
|
111
|
+
runs,
|
|
112
|
+
plugins: [cumulativeResultsPlugin<State, Deps>({ spec: declared, extractors, store, ...options })],
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const labels = (results: StepResults | undefined) => results?.view.sections.map(section => section.step)
|
|
116
|
+
|
|
117
|
+
describe('agent — cumulative results: what a step is told', () => {
|
|
118
|
+
test('a step sees its predecessors, in full within the window and by name beyond it', async () => {
|
|
119
|
+
const d = deps()
|
|
120
|
+
const result = await build(createMemoryCumulativeResultStore()).invoke({}, { runId: 'r', deps: d, scope: 's' })
|
|
121
|
+
|
|
122
|
+
expect(result.status).toBe(PipelineRunStatus.Done)
|
|
123
|
+
expect(labels(d.seen.types)).toEqual([])
|
|
124
|
+
expect(labels(d.seen.resources)).toEqual(['types'])
|
|
125
|
+
expect(d.seen.resources!.view.sections[0]!.text).toBe([
|
|
126
|
+
'### types',
|
|
127
|
+
'- type `User`: { id: string, email: string } — import from `@app/types` (file `src/types/user.ts`)',
|
|
128
|
+
].join('\n'))
|
|
129
|
+
|
|
130
|
+
// `types` is three edges behind `review`, past the default window of two.
|
|
131
|
+
const review = d.seen.review!
|
|
132
|
+
expect(labels(review)).toEqual(['types', 'resources'])
|
|
133
|
+
expect(review.view.sections[0]!.text).toStartWith('### types (names only)')
|
|
134
|
+
expect(review.entries().map(visible => visible.mode)).toEqual([
|
|
135
|
+
ResultViewMode.Compact, ResultViewMode.Full, ResultViewMode.Full,
|
|
136
|
+
])
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('code can query the facts behind the view by kind, name and step', async () => {
|
|
140
|
+
const d = deps()
|
|
141
|
+
await build(createMemoryCumulativeResultStore()).invoke({}, { runId: 'r', deps: d, scope: 's' })
|
|
142
|
+
|
|
143
|
+
const review = d.seen.review!
|
|
144
|
+
expect(review.facts({ kind: CumulativeFactKind.Type, name: 'User' })).toEqual([{
|
|
145
|
+
kind: 'type', name: 'User', members: ['id: string', 'email: string'],
|
|
146
|
+
specifier: '@app/types', path: 'src/types/user.ts',
|
|
147
|
+
}])
|
|
148
|
+
expect(review.facts({ step: 'resources' }).map(fact => fact.name)).toEqual(['users'])
|
|
149
|
+
// A compact rendering is a prompt budget; the query still answers in full.
|
|
150
|
+
expect(review.facts({ step: 'types' })[0]?.members).toHaveLength(2)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
test('a later extractor that re-finds an earlier fact leaves it with the earlier entry', async () => {
|
|
154
|
+
const store = createMemoryCumulativeResultStore()
|
|
155
|
+
await build(store).invoke({}, { runId: 'r', deps: deps(), scope: 's' })
|
|
156
|
+
|
|
157
|
+
const entries = await store.list('r')
|
|
158
|
+
const endpoints = entries.find(entry => entry.step === 'endpoints')
|
|
159
|
+
expect(endpoints?.facts).toEqual([])
|
|
160
|
+
expect(entries.find(entry => entry.step === 'types')?.facts.map(fact => fact.name)).toEqual(['User'])
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('the view reaches a model through the execution, with no other wiring', async () => {
|
|
164
|
+
const asked: string[] = []
|
|
165
|
+
const prompts = makePromptService({}, 'results-e2e-prompts')
|
|
166
|
+
const executions = makeExecutionService('results-e2e-exec')
|
|
167
|
+
const root = executions.root({
|
|
168
|
+
models: (() => { throw new Error('unused') }) as unknown as () => LlmService,
|
|
169
|
+
prompts: () => prompts,
|
|
170
|
+
policy: { effort: ExecutionEffort.Standard },
|
|
171
|
+
purpose: { type: 'spec' },
|
|
172
|
+
prompt: { role: 'You write resources.' },
|
|
173
|
+
})
|
|
174
|
+
const handlers = steps({
|
|
175
|
+
resources: async (_state, ctx) => {
|
|
176
|
+
const exec = executions.withResults(root, ctx.results?.view)
|
|
177
|
+
await makeLlmModel({ ...exec, model: fakeModel(asked) }, spectator).ask('write it', { action: 'spec' })
|
|
178
|
+
write(ctx.deps, ctx.results, 'src/resources/users.ts', 'resource users -> User')
|
|
179
|
+
},
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
await build(createMemoryCumulativeResultStore(), undefined, {}, handlers)
|
|
183
|
+
.invoke({}, { runId: 'r', deps: deps(), scope: 's' })
|
|
184
|
+
|
|
185
|
+
expect(asked[0]).toContain('You write resources.')
|
|
186
|
+
expect(asked[0]).toContain('Results of earlier steps')
|
|
187
|
+
expect(asked[0]).toContain('- type `User`: { id: string, email: string } — import from `@app/types`')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('seeds are known to every step before any step ran', async () => {
|
|
191
|
+
const d = deps()
|
|
192
|
+
await build(createMemoryCumulativeResultStore(), undefined, {
|
|
193
|
+
seeds: { platform: () => [{ kind: CumulativeFactKind.Symbol, name: 'makeContext', specifier: '@app/context' }] },
|
|
194
|
+
}).invoke({}, { runId: 'r', deps: d, scope: 's' })
|
|
195
|
+
|
|
196
|
+
expect(labels(d.seen.types)).toEqual(['platform'])
|
|
197
|
+
expect(d.seen.types!.view.sections[0]!.text).toStartWith('### platform (known before this run)')
|
|
198
|
+
expect(labels(d.seen.review)?.[0]).toBe('platform')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test('a declaration resolved to null leaves that run without results', async () => {
|
|
202
|
+
const d = deps()
|
|
203
|
+
await build(createMemoryCumulativeResultStore(), undefined, {
|
|
204
|
+
spec: run => run.runId === 'off' ? null : declared,
|
|
205
|
+
}).invoke({}, { runId: 'off', deps: d, scope: 's' })
|
|
206
|
+
|
|
207
|
+
expect(Object.keys(d.seen)).toHaveLength(4)
|
|
208
|
+
expect(Object.values(d.seen).every(results => results === undefined)).toBe(true)
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
describe('agent — cumulative results: budgets', () => {
|
|
213
|
+
const tight = (maxChars: number, full?: Record<string, string[]>): CumulativeResultsSpec => ({
|
|
214
|
+
...declared,
|
|
215
|
+
maxChars,
|
|
216
|
+
window: 5,
|
|
217
|
+
steps: {
|
|
218
|
+
types: { extractors: ['types'], ...(full?.types != null ? { full: full.types } : {}) },
|
|
219
|
+
resources: { extractors: ['resources'] },
|
|
220
|
+
},
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
test('the oldest entries go to names only, then out — never cut — and are named as left out', async () => {
|
|
224
|
+
const d = deps()
|
|
225
|
+
await build(createMemoryCumulativeResultStore(), undefined, { spec: tight(60) })
|
|
226
|
+
.invoke({}, { runId: 'r', deps: d, scope: 's' })
|
|
227
|
+
|
|
228
|
+
// Every full entry goes to names only before any names-only entry goes out.
|
|
229
|
+
const view = d.seen.review!.view
|
|
230
|
+
expect(view.chars).toBeLessThanOrEqual(60)
|
|
231
|
+
expect(view.omitted).toEqual(['types'])
|
|
232
|
+
expect(view.sections.map(section => section.step)).toEqual(['resources'])
|
|
233
|
+
expect(view.sections[0]!.text).toBe('### resources (names only)\n- resource: `users`')
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
test('an entry that names the consumer outright is the last to be cut', async () => {
|
|
237
|
+
const d = deps()
|
|
238
|
+
await build(createMemoryCumulativeResultStore(), undefined, { spec: tight(170, { types: ['review'] }) })
|
|
239
|
+
.invoke({}, { runId: 'r', deps: d, scope: 's' })
|
|
240
|
+
|
|
241
|
+
const review = d.seen.review!
|
|
242
|
+
expect(review.entries().find(visible => visible.entry.step === 'types')?.mode).toBe(ResultViewMode.Full)
|
|
243
|
+
expect(review.entries().find(visible => visible.entry.step === 'resources')?.mode)
|
|
244
|
+
.not.toBe(ResultViewMode.Full)
|
|
245
|
+
expect(review.view.chars).toBeLessThanOrEqual(170)
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
describe('agent — cumulative results: the ledger stays honest', () => {
|
|
250
|
+
test('a later step that rewrites an earlier step\'s file refreshes that entry', async () => {
|
|
251
|
+
const d = deps()
|
|
252
|
+
const handlers = steps({
|
|
253
|
+
endpoints: async (_state, ctx) => {
|
|
254
|
+
// A repair of what `types` produced.
|
|
255
|
+
write(ctx.deps, ctx.results, 'src/types/user.ts', 'export interface User { id: string; email: string; name: string }')
|
|
256
|
+
},
|
|
257
|
+
})
|
|
258
|
+
const store = createMemoryCumulativeResultStore()
|
|
259
|
+
await build(store, undefined, {}, handlers).invoke({}, { runId: 'r', deps: d, scope: 's' })
|
|
260
|
+
|
|
261
|
+
const entry = (await store.list('r')).find(item => item.step === 'types')!
|
|
262
|
+
expect(entry.revision).toBe(2)
|
|
263
|
+
expect(entry.facts[0]?.members).toEqual(['id: string', 'email: string', 'name: string'])
|
|
264
|
+
expect(d.seen.review!.facts({ name: 'User' })[0]?.members).toContain('name: string')
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
test('a resume whose ledger is gone rebuilds the finished steps\' entries from their files', async () => {
|
|
268
|
+
const runs = createMemoryPipelineRunStore()
|
|
269
|
+
const files = new Map<string, string>()
|
|
270
|
+
const failed = await build(createMemoryCumulativeResultStore(), runs)
|
|
271
|
+
.invoke({}, { runId: 'r', deps: deps(['endpoints'], files), scope: 's' })
|
|
272
|
+
expect(failed.status).toBe(PipelineRunStatus.Failed)
|
|
273
|
+
|
|
274
|
+
// A new process, and a store that kept nothing.
|
|
275
|
+
const d = deps([], files)
|
|
276
|
+
const resumed = await build(createMemoryCumulativeResultStore(), runs).resume('r', { deps: d })
|
|
277
|
+
|
|
278
|
+
expect(resumed.status).toBe(PipelineRunStatus.Done)
|
|
279
|
+
expect(resumed.completed).toEqual(['types', 'resources', 'endpoints', 'review'])
|
|
280
|
+
const endpoints = d.seen.endpoints!
|
|
281
|
+
expect(labels(endpoints)).toEqual(['types', 'resources'])
|
|
282
|
+
expect(endpoints.entries().map(visible => visible.entry.source)).toEqual([
|
|
283
|
+
CumulativeResultSource.Rebuilt, CumulativeResultSource.Rebuilt,
|
|
284
|
+
])
|
|
285
|
+
expect(endpoints.facts({ name: 'User' })).toHaveLength(1)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
test('a store that fails every write never fails the run, and this run still sees its entries', async () => {
|
|
289
|
+
const d = deps()
|
|
290
|
+
const broken: CumulativeResultStore = {
|
|
291
|
+
list: async () => { throw new Error('store down') },
|
|
292
|
+
put: async () => { throw new Error('store down') },
|
|
293
|
+
clear: async () => { throw new Error('store down') },
|
|
294
|
+
}
|
|
295
|
+
const result = await build(broken).invoke({}, { runId: 'r', deps: d, scope: 's' })
|
|
296
|
+
|
|
297
|
+
expect(result.status).toBe(PipelineRunStatus.Done)
|
|
298
|
+
expect(labels(d.seen.review)).toEqual(['types', 'resources'])
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
test('a restart forgets what the earlier attempt of the run said', async () => {
|
|
302
|
+
const store = createMemoryCumulativeResultStore()
|
|
303
|
+
const runs = createMemoryPipelineRunStore()
|
|
304
|
+
await build(store, runs).invoke({}, { runId: 'r', deps: deps(['endpoints']), scope: 's' })
|
|
305
|
+
expect((await store.list('r')).map(entry => entry.step).sort()).toEqual(['resources', 'types'])
|
|
306
|
+
|
|
307
|
+
await build(store, runs).invoke({}, { runId: 'r', deps: deps(['types']), scope: 's', restart: true })
|
|
308
|
+
|
|
309
|
+
expect(await store.list('r')).toEqual([])
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
test('a skipped step gets the entry its files still support; a failed optional one a partial', async () => {
|
|
313
|
+
const files = new Map([['src/types/user.ts', 'export interface User { id: string }']])
|
|
314
|
+
const d = deps([], files)
|
|
315
|
+
const skipping: PipelineStep<State, Deps>[] = steps().map(handler => handler.step === 'types'
|
|
316
|
+
? { ...handler, skipWhen: () => true }
|
|
317
|
+
: handler.step === 'resources'
|
|
318
|
+
? {
|
|
319
|
+
...handler,
|
|
320
|
+
run: async (_state, ctx) => {
|
|
321
|
+
write(ctx.deps, ctx.results, 'src/resources/users.ts', 'resource users -> User')
|
|
322
|
+
throw new Error('resources half done')
|
|
323
|
+
},
|
|
324
|
+
}
|
|
325
|
+
: handler)
|
|
326
|
+
const pipeline = makePipeline<State, Deps>(
|
|
327
|
+
{ ...spec, steps: spec.steps.map(step => step.step === 'resources' ? { ...step, optional: true } : step) },
|
|
328
|
+
{
|
|
329
|
+
steps: skipping,
|
|
330
|
+
runs: createMemoryPipelineRunStore(),
|
|
331
|
+
plugins: [cumulativeResultsPlugin<State, Deps>({ spec: declared, extractors })],
|
|
332
|
+
},
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
await pipeline.invoke({}, { runId: 'r', deps: d, scope: 's' })
|
|
336
|
+
|
|
337
|
+
const endpoints = d.seen.endpoints!
|
|
338
|
+
expect(endpoints.entries().map(visible => visible.entry.source)).toEqual([
|
|
339
|
+
CumulativeResultSource.Rebuilt, CumulativeResultSource.Extracted,
|
|
340
|
+
])
|
|
341
|
+
expect(endpoints.view.sections[1]!.text).toStartWith(
|
|
342
|
+
'### resources (partial: the step failed part-way; only what it left in the files is listed)',
|
|
343
|
+
)
|
|
344
|
+
expect(endpoints.facts({ name: 'users' })).toHaveLength(1)
|
|
345
|
+
})
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
describe('agent — cumulative results: model summaries', () => {
|
|
349
|
+
const withSummary: CumulativeResultsSpec = {
|
|
350
|
+
...declared,
|
|
351
|
+
steps: { ...declared.steps, types: { extractors: ['types'], summary: { instructions: 'Say why.' } } },
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
test('are asked for only where declared, and always labelled unverified', async () => {
|
|
355
|
+
const d = deps()
|
|
356
|
+
const asked: Array<{ step: string, prompt: string }> = []
|
|
357
|
+
await build(createMemoryCumulativeResultStore(), undefined, {
|
|
358
|
+
spec: withSummary,
|
|
359
|
+
summarize: async request => {
|
|
360
|
+
asked.push({ step: request.step, prompt: request.prompt })
|
|
361
|
+
return { summary: 'Ids stay strings.' }
|
|
362
|
+
},
|
|
363
|
+
}).invoke({}, { runId: 'r', deps: d, scope: 's' })
|
|
364
|
+
|
|
365
|
+
expect(asked.map(item => item.step)).toEqual(['types'])
|
|
366
|
+
expect(asked[0]!.prompt).toContain('Say why.')
|
|
367
|
+
expect(asked[0]!.prompt).toContain('- type `User`')
|
|
368
|
+
expect(d.seen.resources!.view.sections[0]!.text)
|
|
369
|
+
.toContain('Summary (not verified — written by a model, never checked against the files): Ids stay strings.')
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
test('a summary that fails costs the summary, never the step', async () => {
|
|
373
|
+
const d = deps()
|
|
374
|
+
const result = await build(createMemoryCumulativeResultStore(), undefined, {
|
|
375
|
+
spec: withSummary,
|
|
376
|
+
summarize: async () => { throw new Error('model down') },
|
|
377
|
+
}).invoke({}, { runId: 'r', deps: d, scope: 's' })
|
|
378
|
+
|
|
379
|
+
expect(result.status).toBe(PipelineRunStatus.Done)
|
|
380
|
+
expect(d.seen.resources!.view.sections[0]!.text).not.toContain('Summary')
|
|
381
|
+
expect(d.seen.resources!.facts({ name: 'User' })).toHaveLength(1)
|
|
382
|
+
})
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
describe('agent — cumulative results across a composition', () => {
|
|
386
|
+
test('a composed run shares its parent\'s ledger in both directions', async () => {
|
|
387
|
+
const store = createMemoryCumulativeResultStore()
|
|
388
|
+
const runs = createMemoryPipelineRunStore()
|
|
389
|
+
const seen: Record<string, StepResults | undefined> = {}
|
|
390
|
+
const child = makePipeline<State, Deps>(
|
|
391
|
+
{ alias: 'child', version: 1, steps: [{ step: 'c1' }, { step: 'c2', after: ['c1'] }] },
|
|
392
|
+
{
|
|
393
|
+
runs,
|
|
394
|
+
steps: [
|
|
395
|
+
{
|
|
396
|
+
step: 'c1',
|
|
397
|
+
run: async (_state, ctx) => {
|
|
398
|
+
seen.c1 = ctx.results
|
|
399
|
+
write(ctx.deps, ctx.results, 'src/resources/users.ts', 'resource users -> User')
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
{ step: 'c2', run: async (_state, ctx) => { seen.c2 = ctx.results } },
|
|
403
|
+
],
|
|
404
|
+
plugins: [cumulativeResultsPlugin<State, Deps>({
|
|
405
|
+
spec: { steps: { c1: { extractors: ['resources'] } } }, extractors, store,
|
|
406
|
+
})],
|
|
407
|
+
},
|
|
408
|
+
)
|
|
409
|
+
const parent = makePipeline<State, Deps>(
|
|
410
|
+
{
|
|
411
|
+
alias: 'parent', version: 1,
|
|
412
|
+
steps: [{ step: 'a' }, { step: 'nested', after: ['a'] }, { step: 'z', after: ['nested'] }],
|
|
413
|
+
},
|
|
414
|
+
{
|
|
415
|
+
runs,
|
|
416
|
+
steps: [
|
|
417
|
+
{
|
|
418
|
+
step: 'a',
|
|
419
|
+
run: async (_state, ctx) => {
|
|
420
|
+
write(ctx.deps, ctx.results, 'src/types/user.ts', 'export interface User { id: string }')
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
child.asStep<State>('nested', { input: () => ({}), output: () => ({}) }),
|
|
424
|
+
{ step: 'z', run: async (_state, ctx) => { seen.z = ctx.results } },
|
|
425
|
+
],
|
|
426
|
+
plugins: [cumulativeResultsPlugin<State, Deps>({
|
|
427
|
+
spec: { steps: { a: { extractors: ['types'] } } }, extractors, store,
|
|
428
|
+
})],
|
|
429
|
+
},
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
const result = await parent.invoke({}, { runId: 'p', deps: deps(), scope: 's' })
|
|
433
|
+
|
|
434
|
+
expect(result.status).toBe(PipelineRunStatus.Done)
|
|
435
|
+
// Down: the child's first step starts from what the parent had before the composing step.
|
|
436
|
+
expect(labels(seen.c1)).toEqual(['a'])
|
|
437
|
+
expect(labels(seen.c2)).toEqual(['a', 'nested/c1'])
|
|
438
|
+
// Up: the parent's later step sees what the child produced, placed inside the composing step.
|
|
439
|
+
expect(labels(seen.z)).toEqual(['a', 'nested/c1'])
|
|
440
|
+
expect(seen.z!.facts({ kind: CumulativeFactKind.Resource }).map(fact => fact.ref)).toEqual(['User'])
|
|
441
|
+
expect((await store.list('p')).every(entry => entry.ledger === 'p')).toBe(true)
|
|
442
|
+
})
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
const spectator = { log: async () => undefined } as unknown as LlmSpectator
|
|
446
|
+
|
|
447
|
+
/** Stands in for the MODEL — an external boundary — and records what it was sent, as JSON. */
|
|
448
|
+
const fakeModel = (asked: string[]): BaseChatModel => ({
|
|
449
|
+
getName: () => 'fake',
|
|
450
|
+
lc_kwargs: { model: 'fake' },
|
|
451
|
+
stream: async (messages: unknown[]) => {
|
|
452
|
+
asked.push(JSON.stringify(messages))
|
|
453
|
+
|
|
454
|
+
return (async function* () { yield new AIMessageChunk({ content: 'ok' }) })()
|
|
455
|
+
},
|
|
456
|
+
}) as unknown as BaseChatModel
|