@effect-uai/core 0.1.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/LICENSE +21 -0
- package/README.md +43 -0
- package/dist/AiError-CqmYjXyx.d.mts +110 -0
- package/dist/AiError-CqmYjXyx.d.mts.map +1 -0
- package/dist/Items-D1C2686t.d.mts +372 -0
- package/dist/Items-D1C2686t.d.mts.map +1 -0
- package/dist/Loop-CzSJo1h8.d.mts +87 -0
- package/dist/Loop-CzSJo1h8.d.mts.map +1 -0
- package/dist/Outcome-C2JYknCu.d.mts +40 -0
- package/dist/Outcome-C2JYknCu.d.mts.map +1 -0
- package/dist/StructuredFormat-B5ueioNr.d.mts +88 -0
- package/dist/StructuredFormat-B5ueioNr.d.mts.map +1 -0
- package/dist/Tool-5wxOCuOh.d.mts +86 -0
- package/dist/Tool-5wxOCuOh.d.mts.map +1 -0
- package/dist/ToolEvent-B2N10hr3.d.mts +29 -0
- package/dist/ToolEvent-B2N10hr3.d.mts.map +1 -0
- package/dist/Turn-rlTfuHaQ.d.mts +211 -0
- package/dist/Turn-rlTfuHaQ.d.mts.map +1 -0
- package/dist/chunk-CfYAbeIz.mjs +13 -0
- package/dist/domain/AiError.d.mts +2 -0
- package/dist/domain/AiError.mjs +40 -0
- package/dist/domain/AiError.mjs.map +1 -0
- package/dist/domain/Items.d.mts +2 -0
- package/dist/domain/Items.mjs +238 -0
- package/dist/domain/Items.mjs.map +1 -0
- package/dist/domain/Turn.d.mts +2 -0
- package/dist/domain/Turn.mjs +82 -0
- package/dist/domain/Turn.mjs.map +1 -0
- package/dist/index.d.mts +14 -0
- package/dist/index.mjs +14 -0
- package/dist/language-model/LanguageModel.d.mts +60 -0
- package/dist/language-model/LanguageModel.d.mts.map +1 -0
- package/dist/language-model/LanguageModel.mjs +33 -0
- package/dist/language-model/LanguageModel.mjs.map +1 -0
- package/dist/loop/Loop.d.mts +2 -0
- package/dist/loop/Loop.mjs +172 -0
- package/dist/loop/Loop.mjs.map +1 -0
- package/dist/match/Match.d.mts +16 -0
- package/dist/match/Match.d.mts.map +1 -0
- package/dist/match/Match.mjs +15 -0
- package/dist/match/Match.mjs.map +1 -0
- package/dist/observability/Metrics.d.mts +45 -0
- package/dist/observability/Metrics.d.mts.map +1 -0
- package/dist/observability/Metrics.mjs +52 -0
- package/dist/observability/Metrics.mjs.map +1 -0
- package/dist/streaming/JSONL.d.mts +34 -0
- package/dist/streaming/JSONL.d.mts.map +1 -0
- package/dist/streaming/JSONL.mjs +51 -0
- package/dist/streaming/JSONL.mjs.map +1 -0
- package/dist/streaming/Lines.d.mts +27 -0
- package/dist/streaming/Lines.d.mts.map +1 -0
- package/dist/streaming/Lines.mjs +32 -0
- package/dist/streaming/Lines.mjs.map +1 -0
- package/dist/streaming/SSE.d.mts +31 -0
- package/dist/streaming/SSE.d.mts.map +1 -0
- package/dist/streaming/SSE.mjs +58 -0
- package/dist/streaming/SSE.mjs.map +1 -0
- package/dist/structured-format/StructuredFormat.d.mts +2 -0
- package/dist/structured-format/StructuredFormat.mjs +68 -0
- package/dist/structured-format/StructuredFormat.mjs.map +1 -0
- package/dist/testing/MockProvider.d.mts +48 -0
- package/dist/testing/MockProvider.d.mts.map +1 -0
- package/dist/testing/MockProvider.mjs +95 -0
- package/dist/testing/MockProvider.mjs.map +1 -0
- package/dist/tool/HistoryCheck.d.mts +24 -0
- package/dist/tool/HistoryCheck.d.mts.map +1 -0
- package/dist/tool/HistoryCheck.mjs +39 -0
- package/dist/tool/HistoryCheck.mjs.map +1 -0
- package/dist/tool/Outcome.d.mts +2 -0
- package/dist/tool/Outcome.mjs +45 -0
- package/dist/tool/Outcome.mjs.map +1 -0
- package/dist/tool/Resolvers.d.mts +44 -0
- package/dist/tool/Resolvers.d.mts.map +1 -0
- package/dist/tool/Resolvers.mjs +67 -0
- package/dist/tool/Resolvers.mjs.map +1 -0
- package/dist/tool/Tool.d.mts +2 -0
- package/dist/tool/Tool.mjs +79 -0
- package/dist/tool/Tool.mjs.map +1 -0
- package/dist/tool/ToolEvent.d.mts +2 -0
- package/dist/tool/ToolEvent.mjs +8 -0
- package/dist/tool/ToolEvent.mjs.map +1 -0
- package/dist/tool/Toolkit.d.mts +34 -0
- package/dist/tool/Toolkit.d.mts.map +1 -0
- package/dist/tool/Toolkit.mjs +105 -0
- package/dist/tool/Toolkit.mjs.map +1 -0
- package/package.json +127 -0
- package/src/domain/AiError.ts +93 -0
- package/src/domain/Items.ts +260 -0
- package/src/domain/Turn.ts +174 -0
- package/src/index.ts +13 -0
- package/src/language-model/LanguageModel.ts +73 -0
- package/src/loop/Loop.test.ts +412 -0
- package/src/loop/Loop.ts +295 -0
- package/src/match/Match.ts +9 -0
- package/src/observability/Metrics.ts +87 -0
- package/src/streaming/JSONL.test.ts +85 -0
- package/src/streaming/JSONL.ts +96 -0
- package/src/streaming/Lines.ts +34 -0
- package/src/streaming/SSE.test.ts +72 -0
- package/src/streaming/SSE.ts +114 -0
- package/src/structured-format/StructuredFormat.ts +160 -0
- package/src/testing/MockProvider.ts +161 -0
- package/src/tool/HistoryCheck.ts +49 -0
- package/src/tool/Outcome.ts +101 -0
- package/src/tool/Resolvers.test.ts +426 -0
- package/src/tool/Resolvers.ts +166 -0
- package/src/tool/Tool.ts +150 -0
- package/src/tool/ToolEvent.ts +37 -0
- package/src/tool/Toolkit.test.ts +45 -0
- package/src/tool/Toolkit.ts +228 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { Stream } from "effect"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One Server-Sent Event. Fields per the WHATWG spec:
|
|
5
|
+
* - `event`: optional event name (default "message" on the wire)
|
|
6
|
+
* - `data`: payload, with multiple `data:` lines joined by `\n`
|
|
7
|
+
* - `id`: optional last-event id
|
|
8
|
+
*/
|
|
9
|
+
export interface Event {
|
|
10
|
+
readonly event?: string
|
|
11
|
+
readonly data: string
|
|
12
|
+
readonly id?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Generic stream helpers (kept module-local for now; promote to a shared
|
|
17
|
+
// Stream module once a third caller appears).
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/** Decode `Uint8Array` chunks as UTF-8, handling multi-byte boundaries. */
|
|
21
|
+
const decodeText = <E, R>(self: Stream.Stream<Uint8Array, E, R>): Stream.Stream<string, E, R> =>
|
|
22
|
+
self.pipe(
|
|
23
|
+
Stream.mapAccum(
|
|
24
|
+
(): TextDecoder => new TextDecoder("utf-8"),
|
|
25
|
+
(decoder, chunk: Uint8Array) => [decoder, [decoder.decode(chunk, { stream: true })]] as const,
|
|
26
|
+
{
|
|
27
|
+
onHalt: (decoder: TextDecoder) => {
|
|
28
|
+
const tail = decoder.decode()
|
|
29
|
+
return tail.length > 0 ? [tail] : []
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
/** Split a text stream on a separator, buffering across chunk boundaries. */
|
|
36
|
+
const splitOn =
|
|
37
|
+
(separator: string) =>
|
|
38
|
+
<E, R>(self: Stream.Stream<string, E, R>): Stream.Stream<string, E, R> =>
|
|
39
|
+
self.pipe(
|
|
40
|
+
Stream.mapAccum(
|
|
41
|
+
(): string => "",
|
|
42
|
+
(buffer, chunk: string) => {
|
|
43
|
+
const parts = (buffer + chunk).split(separator)
|
|
44
|
+
const tail = parts[parts.length - 1] ?? ""
|
|
45
|
+
return [tail, parts.slice(0, -1)] as const
|
|
46
|
+
},
|
|
47
|
+
{ onHalt: (tail: string) => (tail.length > 0 ? [tail] : []) },
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Parser
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
const parseField = (line: string): readonly [string, string] => {
|
|
56
|
+
const colon = line.indexOf(":")
|
|
57
|
+
if (colon < 0) return [line, ""]
|
|
58
|
+
const value = line.slice(colon + 1)
|
|
59
|
+
return [line.slice(0, colon), value.startsWith(" ") ? value.slice(1) : value]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const parseBlock = (block: string): Event | null => {
|
|
63
|
+
const lines = block.split("\n").filter((l) => l.length > 0 && !l.startsWith(":"))
|
|
64
|
+
if (lines.length === 0) return null
|
|
65
|
+
|
|
66
|
+
const fields = lines.map(parseField)
|
|
67
|
+
const dataLines = fields.filter(([f]) => f === "data").map(([, v]) => v)
|
|
68
|
+
const event = fields.find(([f]) => f === "event")?.[1]
|
|
69
|
+
const id = fields.find(([f]) => f === "id")?.[1]
|
|
70
|
+
|
|
71
|
+
const out: { event?: string; data: string; id?: string } = {
|
|
72
|
+
data: dataLines.join("\n"),
|
|
73
|
+
}
|
|
74
|
+
if (event !== undefined) out.event = event
|
|
75
|
+
if (id !== undefined) out.id = id
|
|
76
|
+
return out as Event
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Public API
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Decode a `Stream<Uint8Array>` (e.g. an HTTP response body) into a
|
|
85
|
+
* `Stream<SSE.Event>`. Handles partial UTF-8 sequences, CRLF/LF line
|
|
86
|
+
* endings, and events split across chunk boundaries.
|
|
87
|
+
*/
|
|
88
|
+
export const fromBytes = <E, R>(
|
|
89
|
+
self: Stream.Stream<Uint8Array, E, R>,
|
|
90
|
+
): Stream.Stream<Event, E, R> =>
|
|
91
|
+
self.pipe(
|
|
92
|
+
decodeText,
|
|
93
|
+
Stream.map((s) => s.replace(/\r/g, "")), // SSE allows CRLF; normalize to LF
|
|
94
|
+
splitOn("\n\n"),
|
|
95
|
+
Stream.map(parseBlock),
|
|
96
|
+
Stream.filter((ev): ev is Event => ev !== null),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
const eventToString = (ev: Event): string => {
|
|
100
|
+
const parts: string[] = []
|
|
101
|
+
if (ev.event !== undefined) parts.push(`event: ${ev.event}`)
|
|
102
|
+
if (ev.id !== undefined) parts.push(`id: ${ev.id}`)
|
|
103
|
+
for (const line of ev.data.split("\n")) parts.push(`data: ${line}`)
|
|
104
|
+
return parts.join("\n") + "\n\n"
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const encoder = new TextEncoder()
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Encode a `Stream<Event>` as `Stream<Uint8Array>` ready to send on an
|
|
111
|
+
* HTTP response with `Content-Type: text/event-stream`.
|
|
112
|
+
*/
|
|
113
|
+
export const toBytes = <E, R>(self: Stream.Stream<Event, E, R>): Stream.Stream<Uint8Array, E, R> =>
|
|
114
|
+
Stream.map(self, (ev) => encoder.encode(eventToString(ev)))
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
|
|
2
|
+
import { Data, Effect, Match, Schema, Stream, pipe } from "effect"
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Types
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Cross-validator schema constraint for structured outputs. Any schema
|
|
10
|
+
* implementing both Standard Schema (runtime validation) and Standard
|
|
11
|
+
* JSON Schema (wire encoding) works directly: Zod 4+, Valibot, ArkType,
|
|
12
|
+
* and Effect Schema after `fromEffectSchema`.
|
|
13
|
+
*/
|
|
14
|
+
export type StructuredSchema<Output = unknown> = StandardSchemaV1<unknown, Output> &
|
|
15
|
+
StandardJSONSchemaV1<unknown, Output>
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A schema-bound output the user wants the model to produce. Pairs the
|
|
19
|
+
* cross-validator schema with metadata providers need (name, description,
|
|
20
|
+
* strict-mode flag).
|
|
21
|
+
*/
|
|
22
|
+
export interface StructuredFormat<A> {
|
|
23
|
+
readonly name: string
|
|
24
|
+
readonly description?: string
|
|
25
|
+
readonly schema: StructuredSchema<A>
|
|
26
|
+
/**
|
|
27
|
+
* Provider strict-mode flag. OpenAI, Anthropic, and Mistral honour it
|
|
28
|
+
* (constrained decoding); other providers ignore.
|
|
29
|
+
*/
|
|
30
|
+
readonly strict?: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A single path-scoped validation problem. Library-agnostic shape. */
|
|
34
|
+
export interface DecodeIssue {
|
|
35
|
+
readonly path: ReadonlyArray<string | number>
|
|
36
|
+
readonly message: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Errors
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Schema validation failed. `raw` is the original text (or stringified
|
|
45
|
+
* value) that failed; `issues` is a flat list of per-field problems.
|
|
46
|
+
*/
|
|
47
|
+
export class StructuredDecodeError extends Data.TaggedError("StructuredDecodeError")<{
|
|
48
|
+
readonly raw: string
|
|
49
|
+
readonly issues: ReadonlyArray<DecodeIssue>
|
|
50
|
+
}> {}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* `JSON.parse` threw on a string that was supposed to be JSON. Distinct
|
|
54
|
+
* from `StructuredDecodeError`: the bytes weren't even JSON.
|
|
55
|
+
*/
|
|
56
|
+
export class JsonParseError extends Data.TaggedError("StructuredJsonParseError")<{
|
|
57
|
+
readonly raw: string
|
|
58
|
+
readonly cause: unknown
|
|
59
|
+
}> {}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Constructors
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Wrap an Effect `Schema` as a `StructuredFormat`. Effect Schema doesn't
|
|
67
|
+
* natively implement Standard Schema; this helper installs the
|
|
68
|
+
* `~standard` and JSON Schema interfaces.
|
|
69
|
+
*/
|
|
70
|
+
export const fromEffectSchema = <S extends Schema.Codec<any, any, never, any>>(
|
|
71
|
+
schema: S,
|
|
72
|
+
options?: {
|
|
73
|
+
readonly name?: string
|
|
74
|
+
readonly description?: string
|
|
75
|
+
readonly strict?: boolean
|
|
76
|
+
},
|
|
77
|
+
): StructuredFormat<S["Type"]> => ({
|
|
78
|
+
name: options?.name ?? "output",
|
|
79
|
+
schema: Schema.toStandardJSONSchemaV1(Schema.toStandardSchemaV1(schema)),
|
|
80
|
+
...(options?.description !== undefined && {
|
|
81
|
+
description: options.description,
|
|
82
|
+
}),
|
|
83
|
+
...(options?.strict !== undefined && { strict: options.strict }),
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Standard Schema → DecodeIssue
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
const propertyKeyToScalar = Match.type<PropertyKey>().pipe(
|
|
91
|
+
Match.when(Match.string, (s) => s),
|
|
92
|
+
Match.when(Match.number, (n) => n),
|
|
93
|
+
Match.when(Match.symbol, (s) => s.toString()),
|
|
94
|
+
Match.exhaustive,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
const segmentToKey = Match.type<PropertyKey | StandardSchemaV1.PathSegment>().pipe(
|
|
98
|
+
Match.when(Match.string, (s) => s),
|
|
99
|
+
Match.when(Match.number, (n) => n),
|
|
100
|
+
Match.when(Match.symbol, (s) => s.toString()),
|
|
101
|
+
Match.orElse((segment) => propertyKeyToScalar(segment.key)),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
const issueToDecode = (issue: StandardSchemaV1.Issue): DecodeIssue => ({
|
|
105
|
+
path: (issue.path ?? []).map(segmentToKey),
|
|
106
|
+
message: issue.message,
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Decoding
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Validate an `unknown` against the format's schema. Returns the typed
|
|
115
|
+
* value or a `StructuredDecodeError`. Standard Schema's `validate` may
|
|
116
|
+
* be async; this function handles both sync and async results.
|
|
117
|
+
*/
|
|
118
|
+
export const decode =
|
|
119
|
+
<A>(format: StructuredFormat<A>) =>
|
|
120
|
+
(raw: unknown): Effect.Effect<A, StructuredDecodeError> =>
|
|
121
|
+
pipe(
|
|
122
|
+
Effect.promise(async () => format.schema["~standard"].validate(raw)),
|
|
123
|
+
Effect.flatMap((result) =>
|
|
124
|
+
result.issues === undefined
|
|
125
|
+
? Effect.succeed(result.value)
|
|
126
|
+
: Effect.fail(
|
|
127
|
+
new StructuredDecodeError({
|
|
128
|
+
raw: typeof raw === "string" ? raw : JSON.stringify(raw),
|
|
129
|
+
issues: result.issues.map(issueToDecode),
|
|
130
|
+
}),
|
|
131
|
+
),
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Parse a JSON string then validate against the format's schema. Two
|
|
137
|
+
* failure modes: `JsonParseError` (bytes weren't JSON) and
|
|
138
|
+
* `StructuredDecodeError` (JSON didn't match the schema).
|
|
139
|
+
*/
|
|
140
|
+
export const parseJson =
|
|
141
|
+
<A>(format: StructuredFormat<A>) =>
|
|
142
|
+
(raw: string): Effect.Effect<A, JsonParseError | StructuredDecodeError> =>
|
|
143
|
+
pipe(
|
|
144
|
+
Effect.try({
|
|
145
|
+
try: () => JSON.parse(raw),
|
|
146
|
+
catch: (cause) => new JsonParseError({ raw, cause }),
|
|
147
|
+
}),
|
|
148
|
+
Effect.flatMap(decode(format)),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Stream operator: each input string is JSON-parsed and validated.
|
|
153
|
+
* Failures surface in the stream's failure channel, distinguished by tag.
|
|
154
|
+
*/
|
|
155
|
+
export const decodeJsonLines =
|
|
156
|
+
<A>(format: StructuredFormat<A>) =>
|
|
157
|
+
<E, R>(
|
|
158
|
+
self: Stream.Stream<string, E, R>,
|
|
159
|
+
): Stream.Stream<A, E | JsonParseError | StructuredDecodeError, R> =>
|
|
160
|
+
self.pipe(Stream.mapEffect(parseJson(format)))
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { Duration, Effect, Layer, Ref, Schedule, Stream } from "effect"
|
|
2
|
+
import * as AiError from "../domain/AiError.js"
|
|
3
|
+
import type { Item } from "../domain/Items.js"
|
|
4
|
+
import { LanguageModel, type LanguageModelService } from "../language-model/LanguageModel.js"
|
|
5
|
+
import type { Turn, TurnEvent } from "../domain/Turn.js"
|
|
6
|
+
|
|
7
|
+
export interface MockOptions {
|
|
8
|
+
/**
|
|
9
|
+
* If set, deltas of each scripted turn are spaced by this duration via
|
|
10
|
+
* `Schedule.spaced`. Combine with `TestClock.adjust` for deterministic
|
|
11
|
+
* timing in tests.
|
|
12
|
+
*/
|
|
13
|
+
readonly deltaInterval?: Duration.Input
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A scripted mock provider. Pre-canned `Turn` outputs are returned in order,
|
|
18
|
+
* one per call to `streamTurn`. Each scripted turn is split into synthetic
|
|
19
|
+
* deltas (text → tool_call_start → tool_call_args_delta → ... → turn_complete)
|
|
20
|
+
* so streaming consumers can see realistic delta shapes.
|
|
21
|
+
*/
|
|
22
|
+
export interface MockRecorder {
|
|
23
|
+
readonly calls: ReadonlyArray<{
|
|
24
|
+
readonly history: ReadonlyArray<Item>
|
|
25
|
+
readonly turn: Turn
|
|
26
|
+
}>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const turnToDeltas = (turn: Turn): ReadonlyArray<TurnEvent> => {
|
|
30
|
+
const deltas: TurnEvent[] = []
|
|
31
|
+
for (const item of turn.items) {
|
|
32
|
+
if (item.type === "message" && item.role === "assistant") {
|
|
33
|
+
for (const block of item.content) {
|
|
34
|
+
if (block.type === "output_text") {
|
|
35
|
+
deltas.push({ type: "text_delta", text: block.text })
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
} else if (item.type === "function_call") {
|
|
39
|
+
deltas.push({
|
|
40
|
+
type: "tool_call_start",
|
|
41
|
+
call_id: item.call_id,
|
|
42
|
+
name: item.name,
|
|
43
|
+
})
|
|
44
|
+
deltas.push({
|
|
45
|
+
type: "tool_call_args_delta",
|
|
46
|
+
call_id: item.call_id,
|
|
47
|
+
delta: item.arguments,
|
|
48
|
+
})
|
|
49
|
+
} else if (item.type === "reasoning" && item.summary !== undefined) {
|
|
50
|
+
deltas.push({ type: "reasoning_delta", text: item.summary, kind: "summary" })
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
deltas.push({ type: "turn_complete", turn })
|
|
54
|
+
return deltas
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const pacedDeltas = (turn: Turn, options?: MockOptions): Stream.Stream<TurnEvent> => {
|
|
58
|
+
const base = Stream.fromIterable(turnToDeltas(turn))
|
|
59
|
+
return options?.deltaInterval === undefined
|
|
60
|
+
? base
|
|
61
|
+
: base.pipe(Stream.schedule(Schedule.spaced(options.deltaInterval)))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const makeService = (
|
|
65
|
+
scriptedTurns: ReadonlyArray<Turn>,
|
|
66
|
+
options?: MockOptions,
|
|
67
|
+
recordCall?: (history: ReadonlyArray<Item>, turn: Turn) => Effect.Effect<void>,
|
|
68
|
+
) =>
|
|
69
|
+
Effect.gen(function* () {
|
|
70
|
+
const cursor = yield* Ref.make(0)
|
|
71
|
+
return LanguageModel.of({
|
|
72
|
+
streamTurn: (request) =>
|
|
73
|
+
Stream.unwrap(
|
|
74
|
+
Effect.gen(function* () {
|
|
75
|
+
const i = yield* Ref.getAndUpdate(cursor, (n) => n + 1)
|
|
76
|
+
if (i >= scriptedTurns.length) {
|
|
77
|
+
return Stream.fail(
|
|
78
|
+
new AiError.InvalidRequest({
|
|
79
|
+
provider: "mock",
|
|
80
|
+
raw: `MockProvider exhausted: ${scriptedTurns.length} turns scripted, but call ${i + 1} was made`,
|
|
81
|
+
}),
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
const turn = scriptedTurns[i]!
|
|
85
|
+
if (recordCall !== undefined) {
|
|
86
|
+
yield* recordCall(request.history, turn)
|
|
87
|
+
}
|
|
88
|
+
return pacedDeltas(turn, options)
|
|
89
|
+
}),
|
|
90
|
+
),
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
export const layer = (
|
|
95
|
+
scriptedTurns: ReadonlyArray<Turn>,
|
|
96
|
+
options?: MockOptions,
|
|
97
|
+
): Layer.Layer<LanguageModel> => Layer.effect(LanguageModel, makeService(scriptedTurns, options))
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Synchronous constructor that returns the `LanguageModelService` value
|
|
101
|
+
* directly, plus a recorder. Use this when you want to swap models
|
|
102
|
+
* mid-stream via `Effect.provideService` instead of providing one model
|
|
103
|
+
* for the whole program via `Layer`.
|
|
104
|
+
*/
|
|
105
|
+
export const make = (
|
|
106
|
+
scriptedTurns: ReadonlyArray<Turn>,
|
|
107
|
+
options?: MockOptions,
|
|
108
|
+
): {
|
|
109
|
+
readonly service: LanguageModelService
|
|
110
|
+
readonly recorder: Effect.Effect<MockRecorder>
|
|
111
|
+
} => {
|
|
112
|
+
const cursor = Ref.makeUnsafe(0)
|
|
113
|
+
const callsRef = Ref.makeUnsafe<ReadonlyArray<{ history: ReadonlyArray<Item>; turn: Turn }>>([])
|
|
114
|
+
const service: LanguageModelService = {
|
|
115
|
+
streamTurn: (request) =>
|
|
116
|
+
Stream.unwrap(
|
|
117
|
+
Effect.gen(function* () {
|
|
118
|
+
const i = yield* Ref.getAndUpdate(cursor, (n) => n + 1)
|
|
119
|
+
if (i >= scriptedTurns.length) {
|
|
120
|
+
return Stream.fail(
|
|
121
|
+
new AiError.InvalidRequest({
|
|
122
|
+
provider: "mock",
|
|
123
|
+
raw: `MockProvider exhausted: ${scriptedTurns.length} turns scripted, but call ${i + 1} was made`,
|
|
124
|
+
}),
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
const turn = scriptedTurns[i]!
|
|
128
|
+
yield* Ref.update(callsRef, (xs) => [...xs, { history: request.history, turn }])
|
|
129
|
+
return pacedDeltas(turn, options)
|
|
130
|
+
}),
|
|
131
|
+
),
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
service,
|
|
135
|
+
recorder: Ref.get(callsRef).pipe(Effect.map((calls) => ({ calls }))),
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Same as `layer`, but also exposes a recorder that captures every call
|
|
141
|
+
* (history + returned turn).
|
|
142
|
+
*/
|
|
143
|
+
export const layerWithRecorder = (
|
|
144
|
+
scriptedTurns: ReadonlyArray<Turn>,
|
|
145
|
+
options?: MockOptions,
|
|
146
|
+
): {
|
|
147
|
+
readonly layer: Layer.Layer<LanguageModel>
|
|
148
|
+
readonly recorder: Effect.Effect<MockRecorder>
|
|
149
|
+
} => {
|
|
150
|
+
const callsRef = Ref.makeUnsafe<ReadonlyArray<{ history: ReadonlyArray<Item>; turn: Turn }>>([])
|
|
151
|
+
const live = Layer.effect(
|
|
152
|
+
LanguageModel,
|
|
153
|
+
makeService(scriptedTurns, options, (history, turn) =>
|
|
154
|
+
Ref.update(callsRef, (xs) => [...xs, { history, turn }]),
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
return {
|
|
158
|
+
layer: live,
|
|
159
|
+
recorder: Ref.get(callsRef).pipe(Effect.map((calls) => ({ calls }))),
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* History-consistency primitives. Useful even WITHOUT HITL.
|
|
3
|
+
*
|
|
4
|
+
* Every provider rejects a new request if any prior `function_call` lacks
|
|
5
|
+
* a matching `function_call_output`. Multi-turn flows that can be
|
|
6
|
+
* interrupted, restarted, or branched (HITL, mid-stream abort, persisted
|
|
7
|
+
* checkpoints, stateless HTTP servers) need to detect orphans and
|
|
8
|
+
* synthesize closing outputs before submitting.
|
|
9
|
+
*
|
|
10
|
+
* Recipe author calls these at known transition points (right before the
|
|
11
|
+
* next provider request). Not invoked from inside the loop.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
type FunctionCall,
|
|
15
|
+
type Item,
|
|
16
|
+
isFunctionCall,
|
|
17
|
+
isFunctionCallOutput,
|
|
18
|
+
} from "../domain/Items.js"
|
|
19
|
+
import { type ToolResult, cancelled } from "./Outcome.js"
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Return every `function_call` in `history` that does not have a matching
|
|
23
|
+
* `function_call_output` later in `history` (correlated by `call_id`).
|
|
24
|
+
* Empty result = history is provider-submittable from this invariant.
|
|
25
|
+
*/
|
|
26
|
+
export const findUnansweredCalls = (
|
|
27
|
+
history: ReadonlyArray<Item>,
|
|
28
|
+
): ReadonlyArray<FunctionCall> => {
|
|
29
|
+
const answered = new Set(history.filter(isFunctionCallOutput).map((o) => o.call_id))
|
|
30
|
+
return history.filter(isFunctionCall).filter((c) => !answered.has(c.call_id))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Cheap predicate: is this history submittable to a provider? */
|
|
34
|
+
export const isReconciled = (history: ReadonlyArray<Item>): boolean =>
|
|
35
|
+
findUnansweredCalls(history).length === 0
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Synthesize cancellation results for every unanswered call. Caller maps
|
|
39
|
+
* via `toFunctionCallOutput` and appends to history before submitting.
|
|
40
|
+
*
|
|
41
|
+
* Use when: a new user message arrives mid-approval; an approval timer
|
|
42
|
+
* fires; a persisted checkpoint contains orphans (crash recovery); a
|
|
43
|
+
* stateless HTTP server reconstructed history from a stale checkpoint.
|
|
44
|
+
*/
|
|
45
|
+
export const cancelAllPending = (
|
|
46
|
+
history: ReadonlyArray<Item>,
|
|
47
|
+
reason?: string,
|
|
48
|
+
): ReadonlyArray<ToolResult> =>
|
|
49
|
+
findUnansweredCalls(history).map((call) => cancelled(call, reason))
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pre-execution decision (`ToolDecision`) and post-execution result
|
|
3
|
+
* (`ToolResult`) for the resolver-based executor.
|
|
4
|
+
*
|
|
5
|
+
* - Resolver returns ToolDecision (Execute | Reject(result)) per call.
|
|
6
|
+
* - Executor emits ToolResult (Value | Failure) per call.
|
|
7
|
+
*
|
|
8
|
+
* Wire conversion stays at the recipe boundary via `toFunctionCallOutput`
|
|
9
|
+
* so recipes can inspect, redact, or audit values before serialization.
|
|
10
|
+
*
|
|
11
|
+
* `output` and `reason` are `string`, not `unknown`: the wire wants strings,
|
|
12
|
+
* and `unknown` would invite non-serializable values (Date, Map, BigInt,
|
|
13
|
+
* fn). Recipes that want structured detail JSON.stringify themselves.
|
|
14
|
+
*/
|
|
15
|
+
import { Match } from "effect"
|
|
16
|
+
import type { FunctionCall, FunctionCallOutput } from "../domain/Items.js"
|
|
17
|
+
import { functionCallOutput } from "../domain/Items.js"
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// ToolResult
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
export type ToolResult =
|
|
24
|
+
| {
|
|
25
|
+
readonly _tag: "Value"
|
|
26
|
+
readonly call_id: string
|
|
27
|
+
readonly tool: string
|
|
28
|
+
readonly value: unknown
|
|
29
|
+
}
|
|
30
|
+
| {
|
|
31
|
+
readonly _tag: "Failure"
|
|
32
|
+
readonly call_id: string
|
|
33
|
+
readonly tool: string
|
|
34
|
+
readonly kind: string
|
|
35
|
+
readonly reason?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const isValue = (r: ToolResult): r is Extract<ToolResult, { _tag: "Value" }> =>
|
|
39
|
+
r._tag === "Value"
|
|
40
|
+
|
|
41
|
+
export const isFailure = (r: ToolResult): r is Extract<ToolResult, { _tag: "Failure" }> =>
|
|
42
|
+
r._tag === "Failure"
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// ToolDecision
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
export type ToolDecision =
|
|
49
|
+
| { readonly _tag: "Execute" }
|
|
50
|
+
| { readonly _tag: "Reject"; readonly result: ToolResult }
|
|
51
|
+
|
|
52
|
+
export const execute: ToolDecision = { _tag: "Execute" }
|
|
53
|
+
|
|
54
|
+
export const reject = (result: ToolResult): ToolDecision => ({ _tag: "Reject", result })
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Synthesizers. `denied` and `cancelled` are operationally distinct;
|
|
58
|
+
// anything else is just a recipe-chosen `kind` via `rejected`.
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
export const rejected = (
|
|
62
|
+
call: FunctionCall,
|
|
63
|
+
kind: string,
|
|
64
|
+
reason?: string,
|
|
65
|
+
): ToolResult => ({
|
|
66
|
+
_tag: "Failure",
|
|
67
|
+
call_id: call.call_id,
|
|
68
|
+
tool: call.name,
|
|
69
|
+
kind,
|
|
70
|
+
...(reason !== undefined ? { reason } : {}),
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
/** Explicit user/policy rejection. */
|
|
74
|
+
export const denied = (call: FunctionCall, reason?: string): ToolResult =>
|
|
75
|
+
rejected(call, "denied", reason)
|
|
76
|
+
|
|
77
|
+
/** Implicit non-answer (follow-up, inactivity, abort). */
|
|
78
|
+
export const cancelled = (call: FunctionCall, reason?: string): ToolResult =>
|
|
79
|
+
rejected(call, "cancelled", reason)
|
|
80
|
+
|
|
81
|
+
/** Tool's own execution failed (parse error, schema, runtime crash). */
|
|
82
|
+
export const executionError = (call: FunctionCall, reason: string): ToolResult =>
|
|
83
|
+
rejected(call, "execution_error", reason)
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Wire conversion - the one place structured → string happens.
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
export const toFunctionCallOutput = (r: ToolResult): FunctionCallOutput =>
|
|
90
|
+
Match.value(r).pipe(
|
|
91
|
+
Match.tag("Value", (v) => functionCallOutput(v.call_id, JSON.stringify(v.value))),
|
|
92
|
+
Match.tag("Failure", (f) =>
|
|
93
|
+
functionCallOutput(
|
|
94
|
+
f.call_id,
|
|
95
|
+
JSON.stringify(
|
|
96
|
+
f.reason !== undefined ? { kind: f.kind, reason: f.reason } : { kind: f.kind },
|
|
97
|
+
),
|
|
98
|
+
),
|
|
99
|
+
),
|
|
100
|
+
Match.exhaustive,
|
|
101
|
+
)
|