This commit is contained in:
5
packages/optimizer/LICENSE
Normal file
5
packages/optimizer/LICENSE
Normal file
@@ -0,0 +1,5 @@
|
||||
Copyright (c) 2026 Marko Markovic. All rights reserved.
|
||||
|
||||
This software is proprietary and confidential. Unauthorized copying,
|
||||
modification, distribution, or use of this software, via any medium,
|
||||
is strictly prohibited.
|
||||
41
packages/optimizer/README.md
Normal file
41
packages/optimizer/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# @waggle/optimizer
|
||||
|
||||
Thin wrapper around [@ax-llm/ax](https://github.com/ax-llm/ax) that exposes a
|
||||
handful of typed LLM-program primitives (`summarizer`, `classifier`,
|
||||
`prompt_expander`). Consumed by the Waggle server's `optimizer-service.ts`
|
||||
to run Ax programs with vault-resolved API keys.
|
||||
|
||||
## Why this package still exists
|
||||
|
||||
The Skills 2.0 verification doc asked whether this package should be archived
|
||||
in favor of the evolution stack in `packages/agent/` (GEPA loop, evolve-schema,
|
||||
iterative-optimizer, judge, compose-evolution). The answer is **no** — the two
|
||||
systems solve different problems:
|
||||
|
||||
| `@waggle/optimizer` | `packages/agent/src/iterative-optimizer.ts` + friends |
|
||||
|---|---|
|
||||
| Runs a fixed Ax program (e.g. "summarize this text") once | Evolves a prompt across many trials by proposing mutations and scoring with a judge |
|
||||
| One-shot execution | Closed loop: generate → judge → gate → deploy |
|
||||
| API: `optimizer.summarize(text)` | API: `runEvolutionCycle()`, `iterateUntilBudget()` |
|
||||
| 132 lines | 500+ lines across 10+ files |
|
||||
|
||||
The agent-side GEPA / EvolveSchema stack is the "learn to write better prompts
|
||||
over time" system. This package is the "please execute this typed Ax program
|
||||
now" utility. The server uses the latter for deterministic summarization,
|
||||
classification, and prompt expansion tasks that do **not** need a feedback
|
||||
loop.
|
||||
|
||||
Production consumer: `packages/server/src/local/services/optimizer-service.ts`
|
||||
— see the `execute()` method.
|
||||
|
||||
## Programs
|
||||
|
||||
- **summarizer** — `textToSummarize → summaryText`
|
||||
- **classifier** — `textToClassify → intentCategory`
|
||||
- **prompt_expander** — `briefPrompt → expandedPrompt`
|
||||
|
||||
## When to extend
|
||||
|
||||
- **Need a new one-shot Ax program?** Add a signature in `src/signatures.ts`.
|
||||
- **Need a closed-loop optimizer that evolves prompts?** That's evolution —
|
||||
use `packages/agent/src/evolution-orchestrator.ts`, not this package.
|
||||
18
packages/optimizer/package.json
Normal file
18
packages/optimizer/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@waggle/optimizer",
|
||||
"version": "0.1.0",
|
||||
"description": "Waggle optimizer — GEPA prompt optimization with Ax signatures",
|
||||
"type": "module",
|
||||
"main": "src/optimizer.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ax-llm/ax": "^19.0.12"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
13
packages/optimizer/src/index.ts
Normal file
13
packages/optimizer/src/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export { PromptOptimizer, type OptimizerConfig, type ExecutionResult } from './optimizer.js';
|
||||
export {
|
||||
SUMMARIZER_SIGNATURE,
|
||||
CLASSIFIER_SIGNATURE,
|
||||
PROMPT_EXPANDER_SIGNATURE,
|
||||
createSummarizer,
|
||||
createClassifier,
|
||||
createPromptExpander,
|
||||
getProgram,
|
||||
PROGRAM_REGISTRY,
|
||||
type ProgramName,
|
||||
type ProgramEntry,
|
||||
} from './signatures.js';
|
||||
50
packages/optimizer/src/optimizer.ts
Normal file
50
packages/optimizer/src/optimizer.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { AxAIService } from '@ax-llm/ax';
|
||||
import { type ProgramName, getProgram, PROGRAM_REGISTRY } from './signatures.js';
|
||||
|
||||
export interface ExecutionResult {
|
||||
programName: ProgramName;
|
||||
input: Record<string, string>;
|
||||
output: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface OptimizerConfig {
|
||||
ai: AxAIService;
|
||||
}
|
||||
|
||||
export class PromptOptimizer {
|
||||
private ai: AxAIService;
|
||||
|
||||
constructor(config: OptimizerConfig) {
|
||||
this.ai = config.ai;
|
||||
}
|
||||
|
||||
async execute(programName: ProgramName, input: Record<string, string>): Promise<ExecutionResult> {
|
||||
const entry = getProgram(programName);
|
||||
const program = entry.create();
|
||||
const result = await program.forward(this.ai, input);
|
||||
return {
|
||||
programName,
|
||||
input,
|
||||
output: result as Record<string, string>,
|
||||
};
|
||||
}
|
||||
|
||||
async summarize(text: string): Promise<string> {
|
||||
const result = await this.execute('summarizer', { textToSummarize: text });
|
||||
return result.output.summaryText;
|
||||
}
|
||||
|
||||
async classify(text: string): Promise<string> {
|
||||
const result = await this.execute('classifier', { textToClassify: text });
|
||||
return result.output.intentCategory;
|
||||
}
|
||||
|
||||
async expandPrompt(text: string): Promise<string> {
|
||||
const result = await this.execute('prompt_expander', { briefPrompt: text });
|
||||
return result.output.expandedPrompt;
|
||||
}
|
||||
|
||||
listPrograms(): ProgramName[] {
|
||||
return PROGRAM_REGISTRY.map(p => p.name);
|
||||
}
|
||||
}
|
||||
69
packages/optimizer/src/signatures.ts
Normal file
69
packages/optimizer/src/signatures.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { AxGen, AxSignature } from '@ax-llm/ax';
|
||||
import type { AxAIService } from '@ax-llm/ax';
|
||||
|
||||
// --- Signature Definitions ---
|
||||
|
||||
export const SUMMARIZER_SIGNATURE = new AxSignature(
|
||||
'textToSummarize:string "The text content to summarize" -> summaryText:string "A concise summary of the input text"'
|
||||
);
|
||||
|
||||
export const CLASSIFIER_SIGNATURE = new AxSignature(
|
||||
'textToClassify:string "Text to classify into a category" -> intentCategory:class "question, command, observation, request, greeting"'
|
||||
);
|
||||
|
||||
export const PROMPT_EXPANDER_SIGNATURE = new AxSignature(
|
||||
'briefPrompt:string "A brief or vague user prompt" -> expandedPrompt:string "A detailed, well-structured prompt with clear instructions"'
|
||||
);
|
||||
|
||||
// --- Program Definitions ---
|
||||
|
||||
export function createSummarizer(): AxGen {
|
||||
const gen = new AxGen(SUMMARIZER_SIGNATURE);
|
||||
gen.setInstruction(
|
||||
'You are a precise summarizer. Produce a concise summary that captures the key points of the input text. ' +
|
||||
'Keep the summary under 3 sentences.'
|
||||
);
|
||||
return gen;
|
||||
}
|
||||
|
||||
export function createClassifier(): AxGen {
|
||||
const gen = new AxGen(CLASSIFIER_SIGNATURE);
|
||||
gen.setInstruction(
|
||||
'Classify the user input into exactly one intent category. ' +
|
||||
'question = asking for information, command = directing an action, ' +
|
||||
'observation = stating a fact, request = asking for help, greeting = social pleasantry.'
|
||||
);
|
||||
return gen;
|
||||
}
|
||||
|
||||
export function createPromptExpander(): AxGen {
|
||||
const gen = new AxGen(PROMPT_EXPANDER_SIGNATURE);
|
||||
gen.setInstruction(
|
||||
'Take a brief or vague user prompt and expand it into a detailed, well-structured prompt. ' +
|
||||
'Add context, specify the desired format, and clarify ambiguities. ' +
|
||||
'The expanded prompt should be actionable by an AI assistant.'
|
||||
);
|
||||
return gen;
|
||||
}
|
||||
|
||||
// --- Program Registry ---
|
||||
|
||||
export type ProgramName = 'summarizer' | 'classifier' | 'prompt_expander';
|
||||
|
||||
export interface ProgramEntry {
|
||||
name: ProgramName;
|
||||
create: () => AxGen;
|
||||
signature: AxSignature;
|
||||
}
|
||||
|
||||
export const PROGRAM_REGISTRY: ProgramEntry[] = [
|
||||
{ name: 'summarizer', create: createSummarizer, signature: SUMMARIZER_SIGNATURE },
|
||||
{ name: 'classifier', create: createClassifier, signature: CLASSIFIER_SIGNATURE },
|
||||
{ name: 'prompt_expander', create: createPromptExpander, signature: PROMPT_EXPANDER_SIGNATURE },
|
||||
];
|
||||
|
||||
export function getProgram(name: ProgramName): ProgramEntry {
|
||||
const entry = PROGRAM_REGISTRY.find(p => p.name === name);
|
||||
if (!entry) throw new Error(`Unknown program: ${name}`);
|
||||
return entry;
|
||||
}
|
||||
223
packages/optimizer/tests/optimizer.test.ts
Normal file
223
packages/optimizer/tests/optimizer.test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
SUMMARIZER_SIGNATURE,
|
||||
CLASSIFIER_SIGNATURE,
|
||||
PROMPT_EXPANDER_SIGNATURE,
|
||||
createSummarizer,
|
||||
createClassifier,
|
||||
createPromptExpander,
|
||||
getProgram,
|
||||
PROGRAM_REGISTRY,
|
||||
type ProgramName,
|
||||
} from '../src/signatures.js';
|
||||
import { PromptOptimizer } from '../src/optimizer.js';
|
||||
import type { AxAIService } from '@ax-llm/ax';
|
||||
|
||||
// Convert camelCase to Title Case (what Ax uses in prompts)
|
||||
function toTitleCase(camel: string): string {
|
||||
return camel.replace(/([A-Z])/g, ' $1').replace(/^./, s => s.toUpperCase()).trim();
|
||||
}
|
||||
|
||||
// Mock AI service that returns predictable outputs
|
||||
// Must implement enough of AxAIService for AxGen.forward() to work
|
||||
function createMockAI(responses: Record<string, string>): AxAIService {
|
||||
// Format response as Ax expects: Title Case field names followed by values
|
||||
const content = Object.entries(responses)
|
||||
.map(([k, v]) => `${toTitleCase(k)}: ${v}`)
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
chat: vi.fn().mockResolvedValue({
|
||||
results: [{ content, index: 0 }],
|
||||
modelUsage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 },
|
||||
}),
|
||||
embed: vi.fn(),
|
||||
getFeatures: vi.fn().mockReturnValue({
|
||||
functions: true,
|
||||
streaming: true,
|
||||
hasThinkingBudget: false,
|
||||
hasShowThoughts: false,
|
||||
structuredOutputs: false,
|
||||
media: {},
|
||||
caching: { supported: false, types: [] },
|
||||
thinking: false,
|
||||
multiTurn: true,
|
||||
}),
|
||||
getOptions: vi.fn().mockReturnValue({ debug: false, verbose: false }),
|
||||
setOptions: vi.fn(),
|
||||
getName: vi.fn().mockReturnValue('mock'),
|
||||
getId: vi.fn().mockReturnValue('mock-id'),
|
||||
getModelList: vi.fn().mockReturnValue([]),
|
||||
getLastUsedChatModel: vi.fn().mockReturnValue('mock-model'),
|
||||
getLastUsedEmbedModel: vi.fn().mockReturnValue(undefined),
|
||||
getLastUsedModelConfig: vi.fn().mockReturnValue(undefined),
|
||||
getMetrics: vi.fn().mockReturnValue(undefined),
|
||||
getLogger: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as AxAIService;
|
||||
}
|
||||
|
||||
describe('Prompt Optimization (Ax Integration)', () => {
|
||||
describe('Signature definitions', () => {
|
||||
it('summarizer signature has correct input/output fields', () => {
|
||||
const inputs = SUMMARIZER_SIGNATURE.getInputFields();
|
||||
const outputs = SUMMARIZER_SIGNATURE.getOutputFields();
|
||||
expect(inputs).toHaveLength(1);
|
||||
expect(inputs[0].name).toBe('textToSummarize');
|
||||
expect(outputs).toHaveLength(1);
|
||||
expect(outputs[0].name).toBe('summaryText');
|
||||
});
|
||||
|
||||
it('classifier signature has correct input/output fields', () => {
|
||||
const inputs = CLASSIFIER_SIGNATURE.getInputFields();
|
||||
const outputs = CLASSIFIER_SIGNATURE.getOutputFields();
|
||||
expect(inputs).toHaveLength(1);
|
||||
expect(inputs[0].name).toBe('textToClassify');
|
||||
expect(outputs).toHaveLength(1);
|
||||
expect(outputs[0].name).toBe('intentCategory');
|
||||
expect(outputs[0].type?.name).toBe('class');
|
||||
expect(outputs[0].type?.options).toEqual(['question', 'command', 'observation', 'request', 'greeting']);
|
||||
});
|
||||
|
||||
it('prompt expander signature has correct input/output fields', () => {
|
||||
const inputs = PROMPT_EXPANDER_SIGNATURE.getInputFields();
|
||||
const outputs = PROMPT_EXPANDER_SIGNATURE.getOutputFields();
|
||||
expect(inputs).toHaveLength(1);
|
||||
expect(inputs[0].name).toBe('briefPrompt');
|
||||
expect(outputs).toHaveLength(1);
|
||||
expect(outputs[0].name).toBe('expandedPrompt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Program creation', () => {
|
||||
it('creates summarizer program with instruction', () => {
|
||||
const program = createSummarizer();
|
||||
expect(program).toBeDefined();
|
||||
expect(program.getInstruction()).toContain('summarizer');
|
||||
});
|
||||
|
||||
it('creates classifier program with instruction', () => {
|
||||
const program = createClassifier();
|
||||
expect(program).toBeDefined();
|
||||
expect(program.getInstruction()).toContain('Classify');
|
||||
});
|
||||
|
||||
it('creates prompt expander program with instruction', () => {
|
||||
const program = createPromptExpander();
|
||||
expect(program).toBeDefined();
|
||||
expect(program.getInstruction()).toContain('expand');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Program registry', () => {
|
||||
it('contains all three programs', () => {
|
||||
expect(PROGRAM_REGISTRY).toHaveLength(3);
|
||||
const names = PROGRAM_REGISTRY.map(p => p.name);
|
||||
expect(names).toContain('summarizer');
|
||||
expect(names).toContain('classifier');
|
||||
expect(names).toContain('prompt_expander');
|
||||
});
|
||||
|
||||
it('getProgram returns correct entry', () => {
|
||||
const entry = getProgram('summarizer');
|
||||
expect(entry.name).toBe('summarizer');
|
||||
expect(typeof entry.create).toBe('function');
|
||||
expect(entry.signature).toBe(SUMMARIZER_SIGNATURE);
|
||||
});
|
||||
|
||||
it('getProgram throws for unknown program', () => {
|
||||
expect(() => getProgram('nonexistent' as ProgramName)).toThrow('Unknown program: nonexistent');
|
||||
});
|
||||
|
||||
it('each registry entry creates a valid program', () => {
|
||||
for (const entry of PROGRAM_REGISTRY) {
|
||||
const program = entry.create();
|
||||
expect(program).toBeDefined();
|
||||
expect(program.getInstruction()).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('PromptOptimizer', () => {
|
||||
it('lists all available programs', () => {
|
||||
const mockAI = createMockAI({});
|
||||
const optimizer = new PromptOptimizer({ ai: mockAI });
|
||||
const programs = optimizer.listPrograms();
|
||||
expect(programs).toEqual(['summarizer', 'classifier', 'prompt_expander']);
|
||||
});
|
||||
});
|
||||
|
||||
// API-dependent tests - these test actual execution through the Ax framework
|
||||
// They use a mock AI service, but still exercise the full AxGen.forward() pipeline
|
||||
describe('Signature execution (API-dependent)', () => {
|
||||
it('summarizer produces string output type', async () => {
|
||||
const mockAI = createMockAI({ summaryText: 'This is a test summary.' });
|
||||
const optimizer = new PromptOptimizer({ ai: mockAI });
|
||||
const result = await optimizer.execute('summarizer', {
|
||||
textToSummarize: 'A long text about AI agents and their capabilities in modern software.',
|
||||
});
|
||||
expect(result.programName).toBe('summarizer');
|
||||
expect(result.input.textToSummarize).toContain('AI agents');
|
||||
expect(typeof result.output.summaryText).toBe('string');
|
||||
});
|
||||
|
||||
it('classifier returns valid category', async () => {
|
||||
const mockAI = createMockAI({ intentCategory: 'question' });
|
||||
const optimizer = new PromptOptimizer({ ai: mockAI });
|
||||
const result = await optimizer.execute('classifier', {
|
||||
textToClassify: 'What is the weather like today?',
|
||||
});
|
||||
expect(result.programName).toBe('classifier');
|
||||
expect(['question', 'command', 'observation', 'request', 'greeting']).toContain(
|
||||
result.output.intentCategory
|
||||
);
|
||||
});
|
||||
|
||||
it('prompt expander produces expanded output', async () => {
|
||||
const mockAI = createMockAI({ expandedPrompt: 'Please write a comprehensive blog post about AI, covering...' });
|
||||
const optimizer = new PromptOptimizer({ ai: mockAI });
|
||||
const result = await optimizer.execute('prompt_expander', {
|
||||
briefPrompt: 'Write about AI',
|
||||
});
|
||||
expect(result.programName).toBe('prompt_expander');
|
||||
expect(typeof result.output.expandedPrompt).toBe('string');
|
||||
expect(result.output.expandedPrompt.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Intent classification across categories', () => {
|
||||
const categories = [
|
||||
{ input: 'What time is it?', expected: 'question' },
|
||||
{ input: 'Delete all files now', expected: 'command' },
|
||||
{ input: 'The sky is blue', expected: 'observation' },
|
||||
{ input: 'Can you help me with this?', expected: 'request' },
|
||||
{ input: 'Hello, how are you?', expected: 'greeting' },
|
||||
];
|
||||
|
||||
for (const { input, expected } of categories) {
|
||||
it(`classifies "${input}" as ${expected}`, async () => {
|
||||
const mockAI = createMockAI({ intentCategory: expected });
|
||||
const optimizer = new PromptOptimizer({ ai: mockAI });
|
||||
const category = await optimizer.classify(input);
|
||||
expect(category).toBe(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('Convenience methods', () => {
|
||||
it('summarize() returns summary string', async () => {
|
||||
const mockAI = createMockAI({ summaryText: 'Brief summary here.' });
|
||||
const optimizer = new PromptOptimizer({ ai: mockAI });
|
||||
const summary = await optimizer.summarize('Long text about many topics...');
|
||||
expect(typeof summary).toBe('string');
|
||||
expect(summary.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('expandPrompt() returns expanded string', async () => {
|
||||
const mockAI = createMockAI({ expandedPrompt: 'Detailed expanded prompt...' });
|
||||
const optimizer = new PromptOptimizer({ ai: mockAI });
|
||||
const expanded = await optimizer.expandPrompt('Fix bug');
|
||||
expect(typeof expanded).toBe('string');
|
||||
expect(expanded.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
21
packages/optimizer/tsconfig.json
Normal file
21
packages/optimizer/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "tests"]
|
||||
}
|
||||
9
packages/optimizer/vitest.config.ts
Normal file
9
packages/optimizer/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
testTimeout: 30_000,
|
||||
include: ['tests/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user