@owlmeans/llm 0.1.14 → 0.1.16-rc.0
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/agent-meta/instructions/llm-prompt-caching.instructions.md +100 -0
- package/agent-meta/instructions/llm.instructions.md +13 -3
- package/agent-meta/manifest.json +16 -2
- package/agent-meta/skills/llm/SKILL.md +54 -4
- package/agent-meta/skills/llm-prompt-caching/SKILL.md +135 -0
- package/build/consts.d.ts +36 -3
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +37 -4
- package/build/consts.js.map +1 -1
- package/build/execution/service.d.ts.map +1 -1
- package/build/execution/service.js +8 -3
- package/build/execution/service.js.map +1 -1
- package/build/execution/types.d.ts +20 -1
- package/build/execution/types.d.ts.map +1 -1
- package/build/execution/utils.d.ts +12 -1
- package/build/execution/utils.d.ts.map +1 -1
- package/build/execution/utils.js +22 -0
- package/build/execution/utils.js.map +1 -1
- package/build/helpers/cache.d.ts +17 -0
- package/build/helpers/cache.d.ts.map +1 -0
- package/build/helpers/cache.js +22 -0
- package/build/helpers/cache.js.map +1 -0
- package/build/helpers/index.d.ts +1 -0
- package/build/helpers/index.d.ts.map +1 -1
- package/build/helpers/index.js +1 -0
- package/build/helpers/index.js.map +1 -1
- package/build/helpers/spectate.d.ts.map +1 -1
- package/build/helpers/spectate.js +7 -0
- package/build/helpers/spectate.js.map +1 -1
- package/build/index.d.ts +1 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/build/model.d.ts +1 -1
- package/build/model.d.ts.map +1 -1
- package/build/model.js +90 -16
- package/build/model.js.map +1 -1
- package/build/plugins/anthropic.d.ts.map +1 -1
- package/build/plugins/anthropic.js +136 -21
- package/build/plugins/anthropic.js.map +1 -1
- package/build/plugins/openai.d.ts +9 -0
- package/build/plugins/openai.d.ts.map +1 -1
- package/build/plugins/openai.js +23 -1
- package/build/plugins/openai.js.map +1 -1
- package/build/plugins/types.d.ts +38 -5
- package/build/plugins/types.d.ts.map +1 -1
- package/build/prompt/index.d.ts +5 -0
- package/build/prompt/index.d.ts.map +1 -0
- package/build/prompt/index.js +4 -0
- package/build/prompt/index.js.map +1 -0
- package/build/prompt/plugins.d.ts +24 -0
- package/build/prompt/plugins.d.ts.map +1 -0
- package/build/prompt/plugins.js +64 -0
- package/build/prompt/plugins.js.map +1 -0
- package/build/prompt/render.d.ts +28 -0
- package/build/prompt/render.d.ts.map +1 -0
- package/build/prompt/render.js +39 -0
- package/build/prompt/render.js.map +1 -0
- package/build/prompt/service.d.ts +16 -0
- package/build/prompt/service.d.ts.map +1 -0
- package/build/prompt/service.js +145 -0
- package/build/prompt/service.js.map +1 -0
- package/build/prompt/types.d.ts +101 -0
- package/build/prompt/types.d.ts.map +1 -0
- package/build/prompt/types.js +2 -0
- package/build/prompt/types.js.map +1 -0
- package/build/service.d.ts.map +1 -1
- package/build/service.js +3 -0
- package/build/service.js.map +1 -1
- package/build/types.d.ts +53 -3
- package/build/types.d.ts.map +1 -1
- package/build/utils/prompt.d.ts +14 -0
- package/build/utils/prompt.d.ts.map +1 -1
- package/build/utils/prompt.js +32 -0
- package/build/utils/prompt.js.map +1 -1
- package/package.json +13 -6
- package/src/consts.ts +43 -5
- package/src/execution/service.ts +9 -3
- package/src/execution/types.ts +21 -2
- package/src/execution/utils.ts +28 -1
- package/src/helpers/cache.ts +33 -0
- package/src/helpers/index.ts +1 -0
- package/src/helpers/spectate.ts +10 -0
- package/src/index.ts +1 -0
- package/src/model.ts +119 -16
- package/src/plugins/anthropic.ts +159 -20
- package/src/plugins/openai.ts +26 -1
- package/src/plugins/types.ts +42 -5
- package/src/prompt/index.ts +5 -0
- package/src/prompt/plugins.ts +69 -0
- package/src/prompt/render.ts +48 -0
- package/src/prompt/service.ts +194 -0
- package/src/prompt/types.ts +114 -0
- package/src/service.ts +3 -0
- package/src/types.ts +54 -3
- package/src/utils/prompt.ts +33 -0
- package/tests/execution.spec.ts +42 -0
- package/tests/plugins.spec.ts +279 -14
- package/tests/prompt.spec.ts +194 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { PromptBlock } from '@owlmeans/llm-common'
|
|
3
|
+
import type { SkillDefinition } from '@owlmeans/llm-common'
|
|
4
|
+
import { anthropicPlugin, makePromptService } from '@owlmeans/llm'
|
|
5
|
+
import type { LlmPromptPlugin, ModelConfig, PromptService } from '@owlmeans/llm'
|
|
6
|
+
|
|
7
|
+
/** `cacheMinTokens: 1` puts the cacheable minimum at 4 characters so fixtures stay short. */
|
|
8
|
+
const model = anthropicPlugin.build({
|
|
9
|
+
alias: 'spec',
|
|
10
|
+
secret: 'sk-test',
|
|
11
|
+
callbacks: [],
|
|
12
|
+
config: { alias: 'spec', model: 'claude-haiku-4-5-20251001', cacheMinTokens: 1 } as ModelConfig,
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
let seq = 0
|
|
16
|
+
const service = (skills: SkillDefinition[] = [], plugins: LlmPromptPlugin[] = []): PromptService =>
|
|
17
|
+
makePromptService({ skills, plugins }, `spec-prompt-${seq++}`)
|
|
18
|
+
|
|
19
|
+
const compose = (svc: PromptService, input: Parameters<PromptService['compose']>[0] = {}) =>
|
|
20
|
+
svc.compose(input, [{ role: 'user', content: 'the task' }], { model, provider: anthropicPlugin })
|
|
21
|
+
|
|
22
|
+
const skill = (alias: string, body: string, extra: Partial<SkillDefinition> = {}): SkillDefinition =>
|
|
23
|
+
({ alias, body, ...extra })
|
|
24
|
+
|
|
25
|
+
describe('@owlmeans/llm — skill registry', () => {
|
|
26
|
+
test('resolve follows requires depth-first and de-duplicates', () => {
|
|
27
|
+
const svc = service([
|
|
28
|
+
skill('a', 'A', { requires: ['b', 'c'] }),
|
|
29
|
+
skill('b', 'B', { requires: ['c'] }),
|
|
30
|
+
skill('c', 'C'),
|
|
31
|
+
])
|
|
32
|
+
expect(svc.resolve(['a']).map(s => s.alias)).toEqual(['c', 'b', 'a'])
|
|
33
|
+
expect(svc.resolve(['a', 'b', 'c']).map(s => s.alias)).toEqual(['c', 'b', 'a'])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// A catalogue is assembled from several packages; a missing optional entry should
|
|
37
|
+
// degrade the prompt, not break the call.
|
|
38
|
+
test('an unknown alias is skipped rather than thrown', () => {
|
|
39
|
+
const svc = service([skill('known', 'K')])
|
|
40
|
+
expect(svc.resolve(['known', 'missing']).map(s => s.alias)).toEqual(['known'])
|
|
41
|
+
expect(svc.has('missing')).toBe(false)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('a cyclic requires graph terminates', () => {
|
|
45
|
+
const svc = service([skill('a', 'A', { requires: ['b'] }), skill('b', 'B', { requires: ['a'] })])
|
|
46
|
+
expect(svc.resolve(['a']).map(s => s.alias).sort()).toEqual(['a', 'b'])
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('@owlmeans/llm — prompt composition', () => {
|
|
51
|
+
test('blocks are emitted in stability order regardless of who contributed them', async () => {
|
|
52
|
+
const late: LlmPromptPlugin = {
|
|
53
|
+
alias: 'late', order: 99, compose: ctx => ctx.add(PromptBlock.Packages, 'package text'),
|
|
54
|
+
}
|
|
55
|
+
const result = await compose(
|
|
56
|
+
service([skill('s', 'skill text')], [late]),
|
|
57
|
+
{ role: 'role text', skills: ['s'], context: ['context text'] },
|
|
58
|
+
)
|
|
59
|
+
expect(result.blocks.map(block => block.block))
|
|
60
|
+
.toEqual([PromptBlock.Role, PromptBlock.Skills, PromptBlock.Packages, PromptBlock.Context])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// The whole design rests on this: a prompt cache is a byte-exact prefix match, so two
|
|
64
|
+
// calls that declare the same thing must render the same thing.
|
|
65
|
+
test('the same declaration composes to identical bytes', async () => {
|
|
66
|
+
const svc = service([skill('a', 'A'), skill('b', 'B')])
|
|
67
|
+
const first = await compose(svc, { role: 'R', skills: ['a', 'b'] })
|
|
68
|
+
const second = await compose(svc, { role: 'R', skills: ['b', 'a'] })
|
|
69
|
+
expect(JSON.stringify(second.system)).toBe(JSON.stringify(first.system))
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('skill order comes from the definitions, not from registration or request order', async () => {
|
|
73
|
+
const forward = service([skill('z', 'Z', { order: 1 }), skill('a', 'A', { order: 2 })])
|
|
74
|
+
const backward = service([skill('a', 'A', { order: 2 }), skill('z', 'Z', { order: 1 })])
|
|
75
|
+
const one = await compose(forward, { skills: ['a', 'z'] })
|
|
76
|
+
const two = await compose(backward, { skills: ['z', 'a'] })
|
|
77
|
+
expect(one.blocks[0]!.text).toBe(two.blocks[0]!.text)
|
|
78
|
+
expect(one.blocks[0]!.text.indexOf('## z')).toBeLessThan(one.blocks[0]!.text.indexOf('## a'))
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
// The reason packages get their own block: whatever a request happens to mention must
|
|
82
|
+
// not shift a single byte of the region every call shares.
|
|
83
|
+
test('a changing packages block leaves the cached region byte-identical', async () => {
|
|
84
|
+
const inject = (text: string): LlmPromptPlugin =>
|
|
85
|
+
({ alias: 'pkg', inspect: ctx => ctx.add(PromptBlock.Packages, text) })
|
|
86
|
+
const base = { role: 'R', skills: ['a'] }
|
|
87
|
+
const one = await compose(service([skill('a', 'A')], [inject('first')]), base)
|
|
88
|
+
const two = await compose(service([skill('a', 'A')], [inject('second')]), base)
|
|
89
|
+
|
|
90
|
+
const stable = (blocks: typeof one.blocks) =>
|
|
91
|
+
blocks.filter(b => b.block !== PromptBlock.Packages).map(b => b.text).join('|')
|
|
92
|
+
expect(stable(two.blocks)).toBe(stable(one.blocks))
|
|
93
|
+
expect(two.blocks.find(b => b.block === PromptBlock.Packages)?.text).toBe('second')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('per-call skills render into the volatile context block, not the cached one', async () => {
|
|
97
|
+
const result = await compose(
|
|
98
|
+
service([skill('static', 'S'), skill('percall', 'P')]),
|
|
99
|
+
{ skills: ['static'], callSkills: ['percall'] },
|
|
100
|
+
)
|
|
101
|
+
expect(result.blocks.find(b => b.block === PromptBlock.Skills)?.text).toContain('## static')
|
|
102
|
+
expect(result.blocks.find(b => b.block === PromptBlock.Skills)?.text).not.toContain('## percall')
|
|
103
|
+
expect(result.blocks.find(b => b.block === PromptBlock.Context)?.text).toContain('## percall')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('an inline skill overrides the registered one of the same alias', async () => {
|
|
107
|
+
const result = await compose(
|
|
108
|
+
service([skill('a', 'registered body')]),
|
|
109
|
+
{ skills: ['a'], inline: [skill('a', 'inline body')] },
|
|
110
|
+
)
|
|
111
|
+
expect(result.blocks[0]!.text).toContain('inline body')
|
|
112
|
+
expect(result.blocks[0]!.text).not.toContain('registered body')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('registering the same plugin alias twice replaces it instead of emitting twice', async () => {
|
|
116
|
+
const svc = service()
|
|
117
|
+
const plugin: LlmPromptPlugin = {
|
|
118
|
+
alias: 'dup', compose: ctx => ctx.add(PromptBlock.Skills, 'once'),
|
|
119
|
+
}
|
|
120
|
+
svc.use(plugin)
|
|
121
|
+
svc.use(plugin)
|
|
122
|
+
const result = await compose(svc)
|
|
123
|
+
expect(result.blocks[0]!.text).toBe('once')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// Detection plugins need every static contribution already in place.
|
|
127
|
+
test('every compose pass runs before the first inspect pass', async () => {
|
|
128
|
+
const seen: string[] = []
|
|
129
|
+
const plugins: LlmPromptPlugin[] = [
|
|
130
|
+
{ alias: 'x', order: 1, compose: () => { seen.push('compose:x') }, inspect: () => { seen.push('inspect:x') } },
|
|
131
|
+
{ alias: 'y', order: 2, compose: () => { seen.push('compose:y') } },
|
|
132
|
+
]
|
|
133
|
+
await compose(service([], plugins))
|
|
134
|
+
expect(seen).toEqual(['compose:x', 'compose:y', 'inspect:x'])
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
// Volatile parts are merged rather than emitted separately: the block carries no
|
|
138
|
+
// breakpoint, so keeping them separable buys nothing and reads worse to the model.
|
|
139
|
+
test('every volatile part lands in a single context chunk', async () => {
|
|
140
|
+
const result = await compose(
|
|
141
|
+
service([skill('a', 'A'), skill('percall', 'P')]),
|
|
142
|
+
{ skills: ['a'], callSkills: ['percall'], context: ['first note', 'second note'] },
|
|
143
|
+
)
|
|
144
|
+
const context = result.blocks.filter(block => block.block === PromptBlock.Context)
|
|
145
|
+
expect(context).toHaveLength(1)
|
|
146
|
+
expect(context[0]!.text).toContain('## percall')
|
|
147
|
+
expect(context[0]!.text).toContain('first note')
|
|
148
|
+
expect(context[0]!.text).toContain('second note')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('nothing declared composes to no system message at all', async () => {
|
|
152
|
+
const result = await compose(service())
|
|
153
|
+
expect(result.system).toBeNull()
|
|
154
|
+
expect(result.breakpoints).toBe(0)
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe('@owlmeans/llm — composed prompt caching', () => {
|
|
159
|
+
test('the system prompt is cached by default, and marks its boundaries', async () => {
|
|
160
|
+
const result = await compose(
|
|
161
|
+
service([skill('a', 'A')], [{ alias: 'pkg', inspect: ctx => ctx.add(PromptBlock.Packages, 'pkg') }]),
|
|
162
|
+
{ role: 'R', skills: ['a'] },
|
|
163
|
+
)
|
|
164
|
+
expect(result.breakpoints).toBe(2)
|
|
165
|
+
expect(Array.isArray(result.system?.content)).toBe(true)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test('opting out yields a plain joined string and spends no breakpoints', async () => {
|
|
169
|
+
const result = await compose(service([skill('a', 'A')]), { role: 'R', skills: ['a'], cacheSystem: false })
|
|
170
|
+
expect(result.breakpoints).toBe(0)
|
|
171
|
+
expect(typeof result.system?.content).toBe('string')
|
|
172
|
+
expect(result.system?.content).toContain('## a')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('a provider without explicit markers still gets the blocks, in order', async () => {
|
|
176
|
+
const svc = service([skill('a', 'A')])
|
|
177
|
+
const result = await svc.compose({ role: 'R', skills: ['a'] }, [], { model })
|
|
178
|
+
expect(result.breakpoints).toBe(0)
|
|
179
|
+
expect(result.system?.content).toBe('R\n\n## a\n\nA')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
// Two stable boundaries at most, so the messages always keep half the request budget.
|
|
183
|
+
test('the system prompt never spends more than half the request budget', async () => {
|
|
184
|
+
const noisy: LlmPromptPlugin = {
|
|
185
|
+
alias: 'noisy',
|
|
186
|
+
compose: ctx => {
|
|
187
|
+
ctx.add(PromptBlock.Packages, 'pkg')
|
|
188
|
+
ctx.add(PromptBlock.Context, 'ctx')
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
const result = await compose(service([skill('a', 'A')], [noisy]), { role: 'R', skills: ['a'] })
|
|
192
|
+
expect(result.breakpoints).toBeLessThanOrEqual(2)
|
|
193
|
+
})
|
|
194
|
+
})
|