@lunora/testing 1.0.0-alpha.40 → 1.0.0-alpha.42
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/dist/index.d.mts +183 -1
- package/dist/index.d.ts +183 -1
- package/dist/index.mjs +3 -1
- package/dist/packem_shared/agentHarness-GTPhWdQ8.mjs +144 -0
- package/dist/packem_shared/containsScorer-DGwtvUNp.mjs +94 -0
- package/dist/packem_shared/{lunoraTest-C0DZz2W9.mjs → lunoraTest-DbzbUAGQ.mjs} +5 -4
- package/package.json +4 -3
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,115 @@
|
|
|
1
|
+
import { AgentMessageRow, AgentRunInput, AgentGenerateResult, AgentRunResult, AgentDefinition } from '@lunora/agent';
|
|
1
2
|
import { ScheduledJob, RegisteredAction, RegisteredMutation, RegisteredQuery, ArgsValidator, InferArgs, ActionCtx, MutationCtx, QueryCtx, TableDefinition, Schema } from '@lunora/server';
|
|
2
3
|
export { type InboxOptions, type WaitForMailOptions, extractLink, listCapturedMail, waitForMail } from '@lunora/mail/testing';
|
|
4
|
+
/** A thread message as the in-memory runtime stores it (superset of {@link AgentMessageRow}). */
|
|
5
|
+
interface HarnessMessage extends AgentMessageRow {
|
|
6
|
+
messageKey: string;
|
|
7
|
+
threadKey: string;
|
|
8
|
+
}
|
|
9
|
+
/** A thread record as the in-memory runtime stores it. */
|
|
10
|
+
interface HarnessThread {
|
|
11
|
+
agent: string;
|
|
12
|
+
error?: string;
|
|
13
|
+
instanceId?: string;
|
|
14
|
+
key: string;
|
|
15
|
+
messageCount: number;
|
|
16
|
+
owner?: string;
|
|
17
|
+
status: string;
|
|
18
|
+
title?: string;
|
|
19
|
+
usage?: unknown;
|
|
20
|
+
}
|
|
21
|
+
/** One recorded function dispatch the loop made through `ctx.run`. */
|
|
22
|
+
interface HarnessDispatch {
|
|
23
|
+
args: Record<string, unknown> | undefined;
|
|
24
|
+
path: string;
|
|
25
|
+
}
|
|
26
|
+
/** Extra function-dispatch handler layered over the built-in `agents:*` runtime double. */
|
|
27
|
+
type HarnessFunction = (args?: Record<string, unknown>) => unknown;
|
|
28
|
+
interface AgentHarnessOptions {
|
|
29
|
+
/**
|
|
30
|
+
* Worker `env` bindings visible to tool `execute` and a dynamic
|
|
31
|
+
* `instructions` thunk. Default `{ LUNORA_TEST: true }`.
|
|
32
|
+
*/
|
|
33
|
+
env?: Record<string, unknown>;
|
|
34
|
+
/**
|
|
35
|
+
* The agent's `lunora/agents.ts` export name — used for thread attribution
|
|
36
|
+
* and to derive the child-agent `AGENT_*` bindings a sub-agent tool targets.
|
|
37
|
+
* Default `"agent"`.
|
|
38
|
+
*/
|
|
39
|
+
exportName?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Stub the app functions a tool's `execute` (or the agent's `memory.source`)
|
|
42
|
+
* dispatches through `ctx.run`, keyed by function path (`"weather:lookup"`).
|
|
43
|
+
* A handler layered here shadows the built-in `agents:*` runtime double for
|
|
44
|
+
* that path. An unstubbed non-`agents:*` dispatch throws, so a missing stub
|
|
45
|
+
* fails loudly rather than silently returning `undefined`.
|
|
46
|
+
*/
|
|
47
|
+
functions?: Record<string, HarnessFunction>;
|
|
48
|
+
/**
|
|
49
|
+
* Scripted model decisions, one {@link AgentGenerateResult} per LLM turn —
|
|
50
|
+
* the default script for {@link AgentHarness.run}. A terminal turn has an
|
|
51
|
+
* empty `toolCalls`; a tool-calling turn lists the calls the loop then runs
|
|
52
|
+
* against the agent's own tools. Override per run via `run(..., { script })`.
|
|
53
|
+
*/
|
|
54
|
+
script: AgentGenerateResult[];
|
|
55
|
+
}
|
|
56
|
+
/** Per-run overrides for {@link AgentHarness.run}. */
|
|
57
|
+
interface AgentRunOverrides {
|
|
58
|
+
/** The workflow instance id for this run (default: an auto-incrementing `wf-N`). */
|
|
59
|
+
instanceId?: string;
|
|
60
|
+
/** The model script for THIS run, replacing the harness default. */
|
|
61
|
+
script?: AgentGenerateResult[];
|
|
62
|
+
}
|
|
63
|
+
interface AgentHarness {
|
|
64
|
+
/**
|
|
65
|
+
* Every function dispatch the loop has made through `ctx.run`, in order —
|
|
66
|
+
* both the `agents:*` runtime calls and the tool/memory app dispatches. Use
|
|
67
|
+
* it to assert a tool called the function you expected with the args you
|
|
68
|
+
* expected.
|
|
69
|
+
*/
|
|
70
|
+
readonly dispatches: ReadonlyArray<HarnessDispatch>;
|
|
71
|
+
/** The persisted messages of a thread, ordered by `seq`. */
|
|
72
|
+
messages: (threadKey: string) => AgentMessageRow[];
|
|
73
|
+
/**
|
|
74
|
+
* Drive one durable agent run to completion against the in-memory journal +
|
|
75
|
+
* runtime double, returning the loop's {@link AgentRunResult}. Reuse the same
|
|
76
|
+
* `threadKey` across calls to continue a conversation (the persisted history
|
|
77
|
+
* carries over); each call runs under a fresh instance id, modelling a
|
|
78
|
+
* distinct workflow instance.
|
|
79
|
+
*/
|
|
80
|
+
run: (params: AgentRunInput, overrides?: AgentRunOverrides) => Promise<AgentRunResult>;
|
|
81
|
+
/** The stored thread record (status, error, instanceId, usage, …), or `undefined` before its first run. */
|
|
82
|
+
thread: (threadKey: string) => HarnessThread | undefined;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* A unit-test harness for a `defineAgent` tool-loop that runs WITHOUT a real
|
|
86
|
+
* model or network. It drives {@link runAgentLoop} over the agent's own
|
|
87
|
+
* `AgentGenerate` seam — the model is a script of per-turn decisions, tool calls
|
|
88
|
+
* run the agent's real `execute` functions inside an in-memory durable-step
|
|
89
|
+
* journal, and thread/message persistence goes through an in-memory `agents:*`
|
|
90
|
+
* runtime double. Mock any function a tool (or the agent's memory) dispatches
|
|
91
|
+
* through the `functions` option.
|
|
92
|
+
*
|
|
93
|
+
* ```ts
|
|
94
|
+
* const harness = agentHarness(support, {
|
|
95
|
+
* script: [toolCallTurn("c1", "lookup", { id: "o_1" }), finalTurn("It shipped.")],
|
|
96
|
+
* functions: { "orders:lookup": () => ({ status: "shipped" }) },
|
|
97
|
+
* });
|
|
98
|
+
* const result = await harness.run({ input: "where is my order?", threadKey: "t1" });
|
|
99
|
+
* expect(result.text).toBe("It shipped.");
|
|
100
|
+
* expect(harness.messages("t1").at(-1)?.content).toBe("It shipped.");
|
|
101
|
+
* ```
|
|
102
|
+
*
|
|
103
|
+
* The harness deliberately does NOT model the concurrency guard or a
|
|
104
|
+
* human-in-the-loop approval pause — it is for exercising an agent's tools and
|
|
105
|
+
* turn logic, not the durable orchestration itself (that is covered inside
|
|
106
|
+
* `@lunora/agent`).
|
|
107
|
+
*/
|
|
108
|
+
declare const agentHarness: (agent: AgentDefinition, options: AgentHarnessOptions) => AgentHarness;
|
|
109
|
+
/** A terminal (final-answer) turn — no tool calls. */
|
|
110
|
+
declare const finalTurn: (text: string, extra?: Partial<AgentGenerateResult>) => AgentGenerateResult;
|
|
111
|
+
/** A single-tool-call turn: the loop runs the named tool with `input`. */
|
|
112
|
+
declare const toolCallTurn: (id: string, name: string, input: unknown, text?: string) => AgentGenerateResult;
|
|
3
113
|
/** A pending job entry in the fake scheduler queue. */
|
|
4
114
|
interface FakeScheduledJob extends ScheduledJob {
|
|
5
115
|
/** The args the job was scheduled with. */
|
|
@@ -287,4 +397,76 @@ interface TestHarness {
|
|
|
287
397
|
* These are clearly documented follow-ups.
|
|
288
398
|
*/
|
|
289
399
|
declare const lunoraTest: (schema: TestSchema, options?: LunoraTestOptions) => TestHarness;
|
|
290
|
-
|
|
400
|
+
/** One sample handed to a {@link Scorer}. */
|
|
401
|
+
interface ScorerSample {
|
|
402
|
+
/** The reference/gold answer, when the scorer compares against one. */
|
|
403
|
+
expected?: string;
|
|
404
|
+
/** The prompt/input that produced the output (for LLM-judge context). */
|
|
405
|
+
input?: string;
|
|
406
|
+
/** Arbitrary per-sample metadata carried through. */
|
|
407
|
+
metadata?: Record<string, unknown>;
|
|
408
|
+
/** The model/agent output under test. */
|
|
409
|
+
output: string;
|
|
410
|
+
}
|
|
411
|
+
/** A scorer's verdict: a `[0, 1]` score and an optional human-readable reason. */
|
|
412
|
+
interface ScoreResult {
|
|
413
|
+
reason?: string;
|
|
414
|
+
score: number;
|
|
415
|
+
}
|
|
416
|
+
/** A named scorer — returns a `[0, 1]` score (or a {@link ScoreResult}) for a sample. */
|
|
417
|
+
interface Scorer {
|
|
418
|
+
name: string;
|
|
419
|
+
score: (sample: ScorerSample) => Promise<ScoreResult | number> | ScoreResult | number;
|
|
420
|
+
}
|
|
421
|
+
/** One dataset case: an input and its optional gold answer/metadata. */
|
|
422
|
+
interface EvalCase {
|
|
423
|
+
expected?: string;
|
|
424
|
+
input: string;
|
|
425
|
+
metadata?: Record<string, unknown>;
|
|
426
|
+
}
|
|
427
|
+
/** The per-case eval result: the produced output plus each scorer's verdict and the mean. */
|
|
428
|
+
interface EvalItemResult {
|
|
429
|
+
average: number;
|
|
430
|
+
input: string;
|
|
431
|
+
output: string;
|
|
432
|
+
scores: Record<string, ScoreResult>;
|
|
433
|
+
}
|
|
434
|
+
/** The whole eval run: per-case results plus the mean of their averages. */
|
|
435
|
+
interface EvalResult {
|
|
436
|
+
average: number;
|
|
437
|
+
items: EvalItemResult[];
|
|
438
|
+
}
|
|
439
|
+
/** Score 1 if the output contains `needle` (case-insensitive unless `caseSensitive`). */
|
|
440
|
+
declare const containsScorer: (needle: string, options?: {
|
|
441
|
+
caseSensitive?: boolean;
|
|
442
|
+
}) => Scorer;
|
|
443
|
+
/** Score 1 when `pattern` matches the output. */
|
|
444
|
+
declare const regexScorer: (pattern: RegExp, name?: string) => Scorer;
|
|
445
|
+
/** Score 1 when the trimmed output exactly equals the trimmed `expected`. */
|
|
446
|
+
declare const exactMatchScorer: () => Scorer;
|
|
447
|
+
/** Score the fraction of `keywords` (case-insensitive) present in the output. */
|
|
448
|
+
declare const keywordScorer: (keywords: ReadonlyArray<string>) => Scorer;
|
|
449
|
+
/**
|
|
450
|
+
* An LLM-as-judge scorer. `judge` is INJECTED — a `(prompt) => Promise<string>`
|
|
451
|
+
* you wire to your model (e.g. via `ctx.ai` / `generateText`), so this module
|
|
452
|
+
* stays model-agnostic and the judge is mockable in tests. It returns the model's
|
|
453
|
+
* numeric verdict (`[0, 1]`) plus its reason.
|
|
454
|
+
*/
|
|
455
|
+
declare const llmScorer: (options: {
|
|
456
|
+
criteria: string;
|
|
457
|
+
judge: (prompt: string) => Promise<string>;
|
|
458
|
+
name?: string;
|
|
459
|
+
}) => Scorer;
|
|
460
|
+
/** Run every scorer over one sample (concurrently) → each verdict keyed by name + their mean. */
|
|
461
|
+
declare const scoreSample: (sample: ScorerSample, scorers: ReadonlyArray<Scorer>) => Promise<{
|
|
462
|
+
average: number;
|
|
463
|
+
scores: Record<string, ScoreResult>;
|
|
464
|
+
}>;
|
|
465
|
+
/**
|
|
466
|
+
* Run a dataset through `produce` (e.g. an `agentHarness.run` wrapper returning
|
|
467
|
+
* the output text) and score each output with `scorers`. Cases run concurrently;
|
|
468
|
+
* give each its own thread/key inside `produce` if the producer is stateful.
|
|
469
|
+
* Returns per-case results plus the mean of their averages.
|
|
470
|
+
*/
|
|
471
|
+
declare const evaluate: (cases: ReadonlyArray<EvalCase>, produce: (input: string) => Promise<string> | string, scorers: ReadonlyArray<Scorer>) => Promise<EvalResult>;
|
|
472
|
+
export { type AgentHarness, type AgentHarnessOptions, type AgentRunOverrides, type EvalCase, type EvalItemResult, type EvalResult, type FakeScheduledJob, type FakeSchedulerControls, type FunctionRegistry, type HarnessDispatch, type HarnessMessage, type HarnessThread, type LunoraTestOptions, type ScheduledJobFailure, type ScoreResult, type Scorer, type ScorerSample, type SweepOptions, type TestHarness, type TestIdentity, type TestSubscription, agentHarness, containsScorer, evaluate, exactMatchScorer, finalTurn, keywordScorer, llmScorer, lunoraTest, regexScorer, scoreSample, toolCallTurn };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,115 @@
|
|
|
1
|
+
import { AgentMessageRow, AgentRunInput, AgentGenerateResult, AgentRunResult, AgentDefinition } from '@lunora/agent';
|
|
1
2
|
import { ScheduledJob, RegisteredAction, RegisteredMutation, RegisteredQuery, ArgsValidator, InferArgs, ActionCtx, MutationCtx, QueryCtx, TableDefinition, Schema } from '@lunora/server';
|
|
2
3
|
export { type InboxOptions, type WaitForMailOptions, extractLink, listCapturedMail, waitForMail } from '@lunora/mail/testing';
|
|
4
|
+
/** A thread message as the in-memory runtime stores it (superset of {@link AgentMessageRow}). */
|
|
5
|
+
interface HarnessMessage extends AgentMessageRow {
|
|
6
|
+
messageKey: string;
|
|
7
|
+
threadKey: string;
|
|
8
|
+
}
|
|
9
|
+
/** A thread record as the in-memory runtime stores it. */
|
|
10
|
+
interface HarnessThread {
|
|
11
|
+
agent: string;
|
|
12
|
+
error?: string;
|
|
13
|
+
instanceId?: string;
|
|
14
|
+
key: string;
|
|
15
|
+
messageCount: number;
|
|
16
|
+
owner?: string;
|
|
17
|
+
status: string;
|
|
18
|
+
title?: string;
|
|
19
|
+
usage?: unknown;
|
|
20
|
+
}
|
|
21
|
+
/** One recorded function dispatch the loop made through `ctx.run`. */
|
|
22
|
+
interface HarnessDispatch {
|
|
23
|
+
args: Record<string, unknown> | undefined;
|
|
24
|
+
path: string;
|
|
25
|
+
}
|
|
26
|
+
/** Extra function-dispatch handler layered over the built-in `agents:*` runtime double. */
|
|
27
|
+
type HarnessFunction = (args?: Record<string, unknown>) => unknown;
|
|
28
|
+
interface AgentHarnessOptions {
|
|
29
|
+
/**
|
|
30
|
+
* Worker `env` bindings visible to tool `execute` and a dynamic
|
|
31
|
+
* `instructions` thunk. Default `{ LUNORA_TEST: true }`.
|
|
32
|
+
*/
|
|
33
|
+
env?: Record<string, unknown>;
|
|
34
|
+
/**
|
|
35
|
+
* The agent's `lunora/agents.ts` export name — used for thread attribution
|
|
36
|
+
* and to derive the child-agent `AGENT_*` bindings a sub-agent tool targets.
|
|
37
|
+
* Default `"agent"`.
|
|
38
|
+
*/
|
|
39
|
+
exportName?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Stub the app functions a tool's `execute` (or the agent's `memory.source`)
|
|
42
|
+
* dispatches through `ctx.run`, keyed by function path (`"weather:lookup"`).
|
|
43
|
+
* A handler layered here shadows the built-in `agents:*` runtime double for
|
|
44
|
+
* that path. An unstubbed non-`agents:*` dispatch throws, so a missing stub
|
|
45
|
+
* fails loudly rather than silently returning `undefined`.
|
|
46
|
+
*/
|
|
47
|
+
functions?: Record<string, HarnessFunction>;
|
|
48
|
+
/**
|
|
49
|
+
* Scripted model decisions, one {@link AgentGenerateResult} per LLM turn —
|
|
50
|
+
* the default script for {@link AgentHarness.run}. A terminal turn has an
|
|
51
|
+
* empty `toolCalls`; a tool-calling turn lists the calls the loop then runs
|
|
52
|
+
* against the agent's own tools. Override per run via `run(..., { script })`.
|
|
53
|
+
*/
|
|
54
|
+
script: AgentGenerateResult[];
|
|
55
|
+
}
|
|
56
|
+
/** Per-run overrides for {@link AgentHarness.run}. */
|
|
57
|
+
interface AgentRunOverrides {
|
|
58
|
+
/** The workflow instance id for this run (default: an auto-incrementing `wf-N`). */
|
|
59
|
+
instanceId?: string;
|
|
60
|
+
/** The model script for THIS run, replacing the harness default. */
|
|
61
|
+
script?: AgentGenerateResult[];
|
|
62
|
+
}
|
|
63
|
+
interface AgentHarness {
|
|
64
|
+
/**
|
|
65
|
+
* Every function dispatch the loop has made through `ctx.run`, in order —
|
|
66
|
+
* both the `agents:*` runtime calls and the tool/memory app dispatches. Use
|
|
67
|
+
* it to assert a tool called the function you expected with the args you
|
|
68
|
+
* expected.
|
|
69
|
+
*/
|
|
70
|
+
readonly dispatches: ReadonlyArray<HarnessDispatch>;
|
|
71
|
+
/** The persisted messages of a thread, ordered by `seq`. */
|
|
72
|
+
messages: (threadKey: string) => AgentMessageRow[];
|
|
73
|
+
/**
|
|
74
|
+
* Drive one durable agent run to completion against the in-memory journal +
|
|
75
|
+
* runtime double, returning the loop's {@link AgentRunResult}. Reuse the same
|
|
76
|
+
* `threadKey` across calls to continue a conversation (the persisted history
|
|
77
|
+
* carries over); each call runs under a fresh instance id, modelling a
|
|
78
|
+
* distinct workflow instance.
|
|
79
|
+
*/
|
|
80
|
+
run: (params: AgentRunInput, overrides?: AgentRunOverrides) => Promise<AgentRunResult>;
|
|
81
|
+
/** The stored thread record (status, error, instanceId, usage, …), or `undefined` before its first run. */
|
|
82
|
+
thread: (threadKey: string) => HarnessThread | undefined;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* A unit-test harness for a `defineAgent` tool-loop that runs WITHOUT a real
|
|
86
|
+
* model or network. It drives {@link runAgentLoop} over the agent's own
|
|
87
|
+
* `AgentGenerate` seam — the model is a script of per-turn decisions, tool calls
|
|
88
|
+
* run the agent's real `execute` functions inside an in-memory durable-step
|
|
89
|
+
* journal, and thread/message persistence goes through an in-memory `agents:*`
|
|
90
|
+
* runtime double. Mock any function a tool (or the agent's memory) dispatches
|
|
91
|
+
* through the `functions` option.
|
|
92
|
+
*
|
|
93
|
+
* ```ts
|
|
94
|
+
* const harness = agentHarness(support, {
|
|
95
|
+
* script: [toolCallTurn("c1", "lookup", { id: "o_1" }), finalTurn("It shipped.")],
|
|
96
|
+
* functions: { "orders:lookup": () => ({ status: "shipped" }) },
|
|
97
|
+
* });
|
|
98
|
+
* const result = await harness.run({ input: "where is my order?", threadKey: "t1" });
|
|
99
|
+
* expect(result.text).toBe("It shipped.");
|
|
100
|
+
* expect(harness.messages("t1").at(-1)?.content).toBe("It shipped.");
|
|
101
|
+
* ```
|
|
102
|
+
*
|
|
103
|
+
* The harness deliberately does NOT model the concurrency guard or a
|
|
104
|
+
* human-in-the-loop approval pause — it is for exercising an agent's tools and
|
|
105
|
+
* turn logic, not the durable orchestration itself (that is covered inside
|
|
106
|
+
* `@lunora/agent`).
|
|
107
|
+
*/
|
|
108
|
+
declare const agentHarness: (agent: AgentDefinition, options: AgentHarnessOptions) => AgentHarness;
|
|
109
|
+
/** A terminal (final-answer) turn — no tool calls. */
|
|
110
|
+
declare const finalTurn: (text: string, extra?: Partial<AgentGenerateResult>) => AgentGenerateResult;
|
|
111
|
+
/** A single-tool-call turn: the loop runs the named tool with `input`. */
|
|
112
|
+
declare const toolCallTurn: (id: string, name: string, input: unknown, text?: string) => AgentGenerateResult;
|
|
3
113
|
/** A pending job entry in the fake scheduler queue. */
|
|
4
114
|
interface FakeScheduledJob extends ScheduledJob {
|
|
5
115
|
/** The args the job was scheduled with. */
|
|
@@ -287,4 +397,76 @@ interface TestHarness {
|
|
|
287
397
|
* These are clearly documented follow-ups.
|
|
288
398
|
*/
|
|
289
399
|
declare const lunoraTest: (schema: TestSchema, options?: LunoraTestOptions) => TestHarness;
|
|
290
|
-
|
|
400
|
+
/** One sample handed to a {@link Scorer}. */
|
|
401
|
+
interface ScorerSample {
|
|
402
|
+
/** The reference/gold answer, when the scorer compares against one. */
|
|
403
|
+
expected?: string;
|
|
404
|
+
/** The prompt/input that produced the output (for LLM-judge context). */
|
|
405
|
+
input?: string;
|
|
406
|
+
/** Arbitrary per-sample metadata carried through. */
|
|
407
|
+
metadata?: Record<string, unknown>;
|
|
408
|
+
/** The model/agent output under test. */
|
|
409
|
+
output: string;
|
|
410
|
+
}
|
|
411
|
+
/** A scorer's verdict: a `[0, 1]` score and an optional human-readable reason. */
|
|
412
|
+
interface ScoreResult {
|
|
413
|
+
reason?: string;
|
|
414
|
+
score: number;
|
|
415
|
+
}
|
|
416
|
+
/** A named scorer — returns a `[0, 1]` score (or a {@link ScoreResult}) for a sample. */
|
|
417
|
+
interface Scorer {
|
|
418
|
+
name: string;
|
|
419
|
+
score: (sample: ScorerSample) => Promise<ScoreResult | number> | ScoreResult | number;
|
|
420
|
+
}
|
|
421
|
+
/** One dataset case: an input and its optional gold answer/metadata. */
|
|
422
|
+
interface EvalCase {
|
|
423
|
+
expected?: string;
|
|
424
|
+
input: string;
|
|
425
|
+
metadata?: Record<string, unknown>;
|
|
426
|
+
}
|
|
427
|
+
/** The per-case eval result: the produced output plus each scorer's verdict and the mean. */
|
|
428
|
+
interface EvalItemResult {
|
|
429
|
+
average: number;
|
|
430
|
+
input: string;
|
|
431
|
+
output: string;
|
|
432
|
+
scores: Record<string, ScoreResult>;
|
|
433
|
+
}
|
|
434
|
+
/** The whole eval run: per-case results plus the mean of their averages. */
|
|
435
|
+
interface EvalResult {
|
|
436
|
+
average: number;
|
|
437
|
+
items: EvalItemResult[];
|
|
438
|
+
}
|
|
439
|
+
/** Score 1 if the output contains `needle` (case-insensitive unless `caseSensitive`). */
|
|
440
|
+
declare const containsScorer: (needle: string, options?: {
|
|
441
|
+
caseSensitive?: boolean;
|
|
442
|
+
}) => Scorer;
|
|
443
|
+
/** Score 1 when `pattern` matches the output. */
|
|
444
|
+
declare const regexScorer: (pattern: RegExp, name?: string) => Scorer;
|
|
445
|
+
/** Score 1 when the trimmed output exactly equals the trimmed `expected`. */
|
|
446
|
+
declare const exactMatchScorer: () => Scorer;
|
|
447
|
+
/** Score the fraction of `keywords` (case-insensitive) present in the output. */
|
|
448
|
+
declare const keywordScorer: (keywords: ReadonlyArray<string>) => Scorer;
|
|
449
|
+
/**
|
|
450
|
+
* An LLM-as-judge scorer. `judge` is INJECTED — a `(prompt) => Promise<string>`
|
|
451
|
+
* you wire to your model (e.g. via `ctx.ai` / `generateText`), so this module
|
|
452
|
+
* stays model-agnostic and the judge is mockable in tests. It returns the model's
|
|
453
|
+
* numeric verdict (`[0, 1]`) plus its reason.
|
|
454
|
+
*/
|
|
455
|
+
declare const llmScorer: (options: {
|
|
456
|
+
criteria: string;
|
|
457
|
+
judge: (prompt: string) => Promise<string>;
|
|
458
|
+
name?: string;
|
|
459
|
+
}) => Scorer;
|
|
460
|
+
/** Run every scorer over one sample (concurrently) → each verdict keyed by name + their mean. */
|
|
461
|
+
declare const scoreSample: (sample: ScorerSample, scorers: ReadonlyArray<Scorer>) => Promise<{
|
|
462
|
+
average: number;
|
|
463
|
+
scores: Record<string, ScoreResult>;
|
|
464
|
+
}>;
|
|
465
|
+
/**
|
|
466
|
+
* Run a dataset through `produce` (e.g. an `agentHarness.run` wrapper returning
|
|
467
|
+
* the output text) and score each output with `scorers`. Cases run concurrently;
|
|
468
|
+
* give each its own thread/key inside `produce` if the producer is stateful.
|
|
469
|
+
* Returns per-case results plus the mean of their averages.
|
|
470
|
+
*/
|
|
471
|
+
declare const evaluate: (cases: ReadonlyArray<EvalCase>, produce: (input: string) => Promise<string> | string, scorers: ReadonlyArray<Scorer>) => Promise<EvalResult>;
|
|
472
|
+
export { type AgentHarness, type AgentHarnessOptions, type AgentRunOverrides, type EvalCase, type EvalItemResult, type EvalResult, type FakeScheduledJob, type FakeSchedulerControls, type FunctionRegistry, type HarnessDispatch, type HarnessMessage, type HarnessThread, type LunoraTestOptions, type ScheduledJobFailure, type ScoreResult, type Scorer, type ScorerSample, type SweepOptions, type TestHarness, type TestIdentity, type TestSubscription, agentHarness, containsScorer, evaluate, exactMatchScorer, finalTurn, keywordScorer, llmScorer, lunoraTest, regexScorer, scoreSample, toolCallTurn };
|
package/dist/index.mjs
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { agentHarness, finalTurn, toolCallTurn } from './packem_shared/agentHarness-GTPhWdQ8.mjs';
|
|
2
|
+
export { lunoraTest } from './packem_shared/lunoraTest-DbzbUAGQ.mjs';
|
|
3
|
+
export { containsScorer, evaluate, exactMatchScorer, keywordScorer, llmScorer, regexScorer, scoreSample } from './packem_shared/containsScorer-DGwtvUNp.mjs';
|
|
2
4
|
export { extractLink, listCapturedMail, waitForMail } from '@lunora/mail/testing';
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { runAgentLoop, DEFAULT_AGENT_FUNCTION_PATHS } from '@lunora/agent';
|
|
2
|
+
|
|
3
|
+
const scriptedGenerate = (script) => {
|
|
4
|
+
const remaining = [...script];
|
|
5
|
+
return () => {
|
|
6
|
+
const next = remaining.shift();
|
|
7
|
+
if (!next) {
|
|
8
|
+
throw new Error("agentHarness: scripted generate exhausted — the run took more turns than the script provided.");
|
|
9
|
+
}
|
|
10
|
+
return Promise.resolve(next);
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
class DurableStepJournal {
|
|
14
|
+
invoked = [];
|
|
15
|
+
entries = /* @__PURE__ */ new Map();
|
|
16
|
+
async do(name, callback) {
|
|
17
|
+
const existing = this.entries.get(name);
|
|
18
|
+
if (existing) {
|
|
19
|
+
return existing.output;
|
|
20
|
+
}
|
|
21
|
+
this.invoked.push(name);
|
|
22
|
+
const output = await callback();
|
|
23
|
+
this.entries.set(name, { output });
|
|
24
|
+
return output;
|
|
25
|
+
}
|
|
26
|
+
// eslint-disable-next-line class-methods-use-this -- mirrors AgentStepLike.waitForEvent's host signature so the mock stays assignable
|
|
27
|
+
async waitForEvent(_name, _options) {
|
|
28
|
+
return new Promise(() => {
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const createRuntime = (extra) => {
|
|
33
|
+
const threads = /* @__PURE__ */ new Map();
|
|
34
|
+
const messages = /* @__PURE__ */ new Map();
|
|
35
|
+
const dispatches = [];
|
|
36
|
+
const ensureThread = (args) => {
|
|
37
|
+
const key = args?.["key"];
|
|
38
|
+
const instanceId = args?.["instanceId"];
|
|
39
|
+
const existing = threads.get(key);
|
|
40
|
+
if (existing) {
|
|
41
|
+
existing.status = "running";
|
|
42
|
+
delete existing.error;
|
|
43
|
+
if (instanceId !== void 0) {
|
|
44
|
+
existing.instanceId = instanceId;
|
|
45
|
+
}
|
|
46
|
+
return { created: false };
|
|
47
|
+
}
|
|
48
|
+
threads.set(key, {
|
|
49
|
+
agent: args?.["agent"],
|
|
50
|
+
instanceId,
|
|
51
|
+
key,
|
|
52
|
+
messageCount: 0,
|
|
53
|
+
owner: args?.["owner"],
|
|
54
|
+
status: "running",
|
|
55
|
+
title: args?.["title"]
|
|
56
|
+
});
|
|
57
|
+
return { created: true };
|
|
58
|
+
};
|
|
59
|
+
const appendMessage = (args) => {
|
|
60
|
+
const threadKey = args?.["threadKey"];
|
|
61
|
+
const messageKey = args?.["messageKey"];
|
|
62
|
+
const id = `${threadKey}:${messageKey}`;
|
|
63
|
+
const existing = messages.get(id);
|
|
64
|
+
if (existing) {
|
|
65
|
+
return { seq: existing.seq };
|
|
66
|
+
}
|
|
67
|
+
const thread = threads.get(threadKey);
|
|
68
|
+
if (!thread) {
|
|
69
|
+
throw new Error(`agentHarness: append to unknown thread "${threadKey}".`);
|
|
70
|
+
}
|
|
71
|
+
const seq = thread.messageCount;
|
|
72
|
+
thread.messageCount += 1;
|
|
73
|
+
messages.set(id, { ...args, seq });
|
|
74
|
+
return { seq };
|
|
75
|
+
};
|
|
76
|
+
const listMessages = (args) => {
|
|
77
|
+
const key = args?.["key"];
|
|
78
|
+
return [...messages.values()].filter((message) => message.threadKey === key).toSorted((a, b) => a.seq - b.seq);
|
|
79
|
+
};
|
|
80
|
+
const patchThread = (args) => {
|
|
81
|
+
const thread = threads.get(args?.["key"]);
|
|
82
|
+
if (thread) {
|
|
83
|
+
for (const [field, value] of Object.entries(args ?? {})) {
|
|
84
|
+
if (field !== "key" && value !== void 0) {
|
|
85
|
+
thread[field] = value;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return void 0;
|
|
90
|
+
};
|
|
91
|
+
const handlers = new Map([
|
|
92
|
+
[DEFAULT_AGENT_FUNCTION_PATHS.appendMessage, appendMessage],
|
|
93
|
+
[DEFAULT_AGENT_FUNCTION_PATHS.ensureThread, ensureThread],
|
|
94
|
+
[DEFAULT_AGENT_FUNCTION_PATHS.listMessages, listMessages],
|
|
95
|
+
[DEFAULT_AGENT_FUNCTION_PATHS.patchThread, patchThread],
|
|
96
|
+
...Object.entries(extra)
|
|
97
|
+
]);
|
|
98
|
+
const run = (reference, args) => {
|
|
99
|
+
const path = reference["__lunoraRef"];
|
|
100
|
+
dispatches.push({ args, path });
|
|
101
|
+
const handler = handlers.get(path);
|
|
102
|
+
if (!handler) {
|
|
103
|
+
throw new Error(`agentHarness: unstubbed dispatch "${path}" — pass a \`functions\` handler for it.`);
|
|
104
|
+
}
|
|
105
|
+
return Promise.resolve(handler(args));
|
|
106
|
+
};
|
|
107
|
+
return { dispatches, messages, run, threads };
|
|
108
|
+
};
|
|
109
|
+
const agentHarness = (agent, options) => {
|
|
110
|
+
const exportName = options.exportName ?? "agent";
|
|
111
|
+
const env = options.env ?? { LUNORA_TEST: true };
|
|
112
|
+
const runtime = createRuntime(options.functions ?? {});
|
|
113
|
+
let runCount = 0;
|
|
114
|
+
const run = (params, overrides) => {
|
|
115
|
+
runCount += 1;
|
|
116
|
+
const instanceId = overrides?.instanceId ?? `wf-${String(runCount)}`;
|
|
117
|
+
const script = overrides?.script ?? options.script;
|
|
118
|
+
return runAgentLoop({
|
|
119
|
+
agent,
|
|
120
|
+
env,
|
|
121
|
+
exportName,
|
|
122
|
+
generate: scriptedGenerate(script),
|
|
123
|
+
instanceId,
|
|
124
|
+
params,
|
|
125
|
+
paths: DEFAULT_AGENT_FUNCTION_PATHS,
|
|
126
|
+
run: runtime.run,
|
|
127
|
+
step: new DurableStepJournal()
|
|
128
|
+
});
|
|
129
|
+
};
|
|
130
|
+
return {
|
|
131
|
+
dispatches: runtime.dispatches,
|
|
132
|
+
messages: (threadKey) => [...runtime.messages.values()].filter((message) => message.threadKey === threadKey).toSorted((a, b) => a.seq - b.seq).map(({ messageKey: _messageKey, threadKey: _threadKey, ...row }) => row),
|
|
133
|
+
run,
|
|
134
|
+
thread: (threadKey) => runtime.threads.get(threadKey)
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
const finalTurn = (text, extra) => {
|
|
138
|
+
return { text, toolCalls: [], ...extra };
|
|
139
|
+
};
|
|
140
|
+
const toolCallTurn = (id, name, input, text = "") => {
|
|
141
|
+
return { text, toolCalls: [{ id, input, name }] };
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export { agentHarness, finalTurn, toolCallTurn };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { LunoraError } from '@lunora/errors';
|
|
2
|
+
|
|
3
|
+
const LEADING_SCORE = /^\s*(-?\d+(?:\.\d+)?)/u;
|
|
4
|
+
const clamp01 = (value) => Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0;
|
|
5
|
+
const normalizeScore = (result) => {
|
|
6
|
+
if (typeof result === "number") {
|
|
7
|
+
return { score: clamp01(result) };
|
|
8
|
+
}
|
|
9
|
+
return { score: clamp01(result.score), ...result.reason === void 0 ? {} : { reason: result.reason } };
|
|
10
|
+
};
|
|
11
|
+
const mean = (values) => values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
12
|
+
const containsScorer = (needle, options = {}) => {
|
|
13
|
+
return {
|
|
14
|
+
name: `contains:${needle}`,
|
|
15
|
+
score: ({ output }) => {
|
|
16
|
+
const haystack = options.caseSensitive ? output : output.toLowerCase();
|
|
17
|
+
const target = options.caseSensitive ? needle : needle.toLowerCase();
|
|
18
|
+
return haystack.includes(target) ? 1 : 0;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
const regexScorer = (pattern, name = "regex") => {
|
|
23
|
+
return { name, score: ({ output }) => pattern.test(output) ? 1 : 0 };
|
|
24
|
+
};
|
|
25
|
+
const exactMatchScorer = () => {
|
|
26
|
+
return {
|
|
27
|
+
name: "exact-match",
|
|
28
|
+
score: ({ expected, output }) => output.trim() === expected?.trim() ? 1 : 0
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
const keywordScorer = (keywords) => {
|
|
32
|
+
if (keywords.length === 0) {
|
|
33
|
+
throw new LunoraError("BAD_REQUEST", "@lunora/testing: keywordScorer requires at least one keyword");
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
name: "keyword-coverage",
|
|
37
|
+
score: ({ output }) => {
|
|
38
|
+
const lower = output.toLowerCase();
|
|
39
|
+
const hits = keywords.filter((keyword) => lower.includes(keyword.toLowerCase())).length;
|
|
40
|
+
return { reason: `${String(hits)}/${String(keywords.length)} keywords present`, score: hits / keywords.length };
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
const buildJudgePrompt = (criteria, sample) => [
|
|
45
|
+
`Rate the ASSISTANT OUTPUT against this criterion: ${criteria}`,
|
|
46
|
+
"Respond with a single number from 0 (fails) to 1 (fully meets), then a dash and a one-line reason.",
|
|
47
|
+
...sample.input === void 0 ? [] : ["", `Input: ${sample.input}`],
|
|
48
|
+
...sample.expected === void 0 ? [] : ["", `Reference answer: ${sample.expected}`],
|
|
49
|
+
"",
|
|
50
|
+
`Assistant output: ${sample.output}`
|
|
51
|
+
].join("\n");
|
|
52
|
+
const parseJudgeScore = (raw) => {
|
|
53
|
+
const match = LEADING_SCORE.exec(raw);
|
|
54
|
+
return { reason: raw.trim(), score: match ? clamp01(Number(match[1])) : 0 };
|
|
55
|
+
};
|
|
56
|
+
const llmScorer = (options) => {
|
|
57
|
+
return {
|
|
58
|
+
name: options.name ?? "llm-judge",
|
|
59
|
+
score: async (sample) => parseJudgeScore(await options.judge(buildJudgePrompt(options.criteria, sample)))
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
const scoreSample = async (sample, scorers) => {
|
|
63
|
+
const results = await Promise.all(
|
|
64
|
+
scorers.map(async (scorer) => {
|
|
65
|
+
return { name: scorer.name, result: normalizeScore(await scorer.score(sample)) };
|
|
66
|
+
})
|
|
67
|
+
);
|
|
68
|
+
const scores = {};
|
|
69
|
+
const seen = /* @__PURE__ */ new Map();
|
|
70
|
+
for (const { name, result } of results) {
|
|
71
|
+
const priorCount = seen.get(name) ?? 0;
|
|
72
|
+
seen.set(name, priorCount + 1);
|
|
73
|
+
scores[priorCount === 0 ? name : `${name}#${String(priorCount + 1)}`] = result;
|
|
74
|
+
}
|
|
75
|
+
return { average: mean(results.map(({ result }) => result.score)), scores };
|
|
76
|
+
};
|
|
77
|
+
const evaluate = async (cases, produce, scorers) => {
|
|
78
|
+
const items = await Promise.all(
|
|
79
|
+
cases.map(async (testCase) => {
|
|
80
|
+
const output = await produce(testCase.input);
|
|
81
|
+
const sample = {
|
|
82
|
+
input: testCase.input,
|
|
83
|
+
output,
|
|
84
|
+
...testCase.expected === void 0 ? {} : { expected: testCase.expected },
|
|
85
|
+
...testCase.metadata === void 0 ? {} : { metadata: testCase.metadata }
|
|
86
|
+
};
|
|
87
|
+
const { average, scores } = await scoreSample(sample, scorers);
|
|
88
|
+
return { average, input: testCase.input, output, scores };
|
|
89
|
+
})
|
|
90
|
+
);
|
|
91
|
+
return { average: mean(items.map((item) => item.average)), items };
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export { containsScorer, evaluate, exactMatchScorer, keywordScorer, llmScorer, regexScorer, scoreSample };
|
|
@@ -19,6 +19,7 @@ const createFakeScheduler = (getDispatch, getMutationContext, getFunctionRegistr
|
|
|
19
19
|
});
|
|
20
20
|
return id;
|
|
21
21
|
};
|
|
22
|
+
const targetPath = (target) => typeof target === "string" ? target : target.name ?? target.binding ?? "";
|
|
22
23
|
const scheduler = {
|
|
23
24
|
cancel: (id) => {
|
|
24
25
|
const existed = pending.has(id);
|
|
@@ -28,12 +29,12 @@ const createFakeScheduler = (getDispatch, getMutationContext, getFunctionRegistr
|
|
|
28
29
|
// eslint-disable-next-line unicorn/no-null -- Scheduler.get returns null when absent (public API contract)
|
|
29
30
|
get: (id) => Promise.resolve(pending.get(id) ?? null),
|
|
30
31
|
list: () => Promise.resolve([...pending.values()]),
|
|
31
|
-
runAfter: (delayMs,
|
|
32
|
-
const id = enqueue(nowMs + delayMs,
|
|
32
|
+
runAfter: (delayMs, target, args) => {
|
|
33
|
+
const id = enqueue(nowMs + delayMs, targetPath(target), args);
|
|
33
34
|
return Promise.resolve(id);
|
|
34
35
|
},
|
|
35
|
-
runAt: (timestampMs,
|
|
36
|
-
const id = enqueue(timestampMs,
|
|
36
|
+
runAt: (timestampMs, target, args) => {
|
|
37
|
+
const id = enqueue(timestampMs, targetPath(target), args);
|
|
37
38
|
return Promise.resolve(id);
|
|
38
39
|
}
|
|
39
40
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lunora/testing",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.42",
|
|
4
4
|
"description": "Testing toolkit for Lunora: an in-memory harness for queries, mutations, and actions",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare",
|
|
@@ -46,10 +46,11 @@
|
|
|
46
46
|
"access": "public"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@lunora/
|
|
49
|
+
"@lunora/agent": "1.0.0-alpha.1",
|
|
50
|
+
"@lunora/do": "1.0.0-alpha.30",
|
|
50
51
|
"@lunora/errors": "1.0.0-alpha.4",
|
|
51
52
|
"@lunora/mail": "1.0.0-alpha.14",
|
|
52
|
-
"@lunora/server": "1.0.0-alpha.
|
|
53
|
+
"@lunora/server": "1.0.0-alpha.24"
|
|
53
54
|
},
|
|
54
55
|
"engines": {
|
|
55
56
|
"node": "^22.15.0 || >=24.11.0"
|