@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.
Files changed (110) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +43 -0
  3. package/dist/AiError-CqmYjXyx.d.mts +110 -0
  4. package/dist/AiError-CqmYjXyx.d.mts.map +1 -0
  5. package/dist/Items-D1C2686t.d.mts +372 -0
  6. package/dist/Items-D1C2686t.d.mts.map +1 -0
  7. package/dist/Loop-CzSJo1h8.d.mts +87 -0
  8. package/dist/Loop-CzSJo1h8.d.mts.map +1 -0
  9. package/dist/Outcome-C2JYknCu.d.mts +40 -0
  10. package/dist/Outcome-C2JYknCu.d.mts.map +1 -0
  11. package/dist/StructuredFormat-B5ueioNr.d.mts +88 -0
  12. package/dist/StructuredFormat-B5ueioNr.d.mts.map +1 -0
  13. package/dist/Tool-5wxOCuOh.d.mts +86 -0
  14. package/dist/Tool-5wxOCuOh.d.mts.map +1 -0
  15. package/dist/ToolEvent-B2N10hr3.d.mts +29 -0
  16. package/dist/ToolEvent-B2N10hr3.d.mts.map +1 -0
  17. package/dist/Turn-rlTfuHaQ.d.mts +211 -0
  18. package/dist/Turn-rlTfuHaQ.d.mts.map +1 -0
  19. package/dist/chunk-CfYAbeIz.mjs +13 -0
  20. package/dist/domain/AiError.d.mts +2 -0
  21. package/dist/domain/AiError.mjs +40 -0
  22. package/dist/domain/AiError.mjs.map +1 -0
  23. package/dist/domain/Items.d.mts +2 -0
  24. package/dist/domain/Items.mjs +238 -0
  25. package/dist/domain/Items.mjs.map +1 -0
  26. package/dist/domain/Turn.d.mts +2 -0
  27. package/dist/domain/Turn.mjs +82 -0
  28. package/dist/domain/Turn.mjs.map +1 -0
  29. package/dist/index.d.mts +14 -0
  30. package/dist/index.mjs +14 -0
  31. package/dist/language-model/LanguageModel.d.mts +60 -0
  32. package/dist/language-model/LanguageModel.d.mts.map +1 -0
  33. package/dist/language-model/LanguageModel.mjs +33 -0
  34. package/dist/language-model/LanguageModel.mjs.map +1 -0
  35. package/dist/loop/Loop.d.mts +2 -0
  36. package/dist/loop/Loop.mjs +172 -0
  37. package/dist/loop/Loop.mjs.map +1 -0
  38. package/dist/match/Match.d.mts +16 -0
  39. package/dist/match/Match.d.mts.map +1 -0
  40. package/dist/match/Match.mjs +15 -0
  41. package/dist/match/Match.mjs.map +1 -0
  42. package/dist/observability/Metrics.d.mts +45 -0
  43. package/dist/observability/Metrics.d.mts.map +1 -0
  44. package/dist/observability/Metrics.mjs +52 -0
  45. package/dist/observability/Metrics.mjs.map +1 -0
  46. package/dist/streaming/JSONL.d.mts +34 -0
  47. package/dist/streaming/JSONL.d.mts.map +1 -0
  48. package/dist/streaming/JSONL.mjs +51 -0
  49. package/dist/streaming/JSONL.mjs.map +1 -0
  50. package/dist/streaming/Lines.d.mts +27 -0
  51. package/dist/streaming/Lines.d.mts.map +1 -0
  52. package/dist/streaming/Lines.mjs +32 -0
  53. package/dist/streaming/Lines.mjs.map +1 -0
  54. package/dist/streaming/SSE.d.mts +31 -0
  55. package/dist/streaming/SSE.d.mts.map +1 -0
  56. package/dist/streaming/SSE.mjs +58 -0
  57. package/dist/streaming/SSE.mjs.map +1 -0
  58. package/dist/structured-format/StructuredFormat.d.mts +2 -0
  59. package/dist/structured-format/StructuredFormat.mjs +68 -0
  60. package/dist/structured-format/StructuredFormat.mjs.map +1 -0
  61. package/dist/testing/MockProvider.d.mts +48 -0
  62. package/dist/testing/MockProvider.d.mts.map +1 -0
  63. package/dist/testing/MockProvider.mjs +95 -0
  64. package/dist/testing/MockProvider.mjs.map +1 -0
  65. package/dist/tool/HistoryCheck.d.mts +24 -0
  66. package/dist/tool/HistoryCheck.d.mts.map +1 -0
  67. package/dist/tool/HistoryCheck.mjs +39 -0
  68. package/dist/tool/HistoryCheck.mjs.map +1 -0
  69. package/dist/tool/Outcome.d.mts +2 -0
  70. package/dist/tool/Outcome.mjs +45 -0
  71. package/dist/tool/Outcome.mjs.map +1 -0
  72. package/dist/tool/Resolvers.d.mts +44 -0
  73. package/dist/tool/Resolvers.d.mts.map +1 -0
  74. package/dist/tool/Resolvers.mjs +67 -0
  75. package/dist/tool/Resolvers.mjs.map +1 -0
  76. package/dist/tool/Tool.d.mts +2 -0
  77. package/dist/tool/Tool.mjs +79 -0
  78. package/dist/tool/Tool.mjs.map +1 -0
  79. package/dist/tool/ToolEvent.d.mts +2 -0
  80. package/dist/tool/ToolEvent.mjs +8 -0
  81. package/dist/tool/ToolEvent.mjs.map +1 -0
  82. package/dist/tool/Toolkit.d.mts +34 -0
  83. package/dist/tool/Toolkit.d.mts.map +1 -0
  84. package/dist/tool/Toolkit.mjs +105 -0
  85. package/dist/tool/Toolkit.mjs.map +1 -0
  86. package/package.json +127 -0
  87. package/src/domain/AiError.ts +93 -0
  88. package/src/domain/Items.ts +260 -0
  89. package/src/domain/Turn.ts +174 -0
  90. package/src/index.ts +13 -0
  91. package/src/language-model/LanguageModel.ts +73 -0
  92. package/src/loop/Loop.test.ts +412 -0
  93. package/src/loop/Loop.ts +295 -0
  94. package/src/match/Match.ts +9 -0
  95. package/src/observability/Metrics.ts +87 -0
  96. package/src/streaming/JSONL.test.ts +85 -0
  97. package/src/streaming/JSONL.ts +96 -0
  98. package/src/streaming/Lines.ts +34 -0
  99. package/src/streaming/SSE.test.ts +72 -0
  100. package/src/streaming/SSE.ts +114 -0
  101. package/src/structured-format/StructuredFormat.ts +160 -0
  102. package/src/testing/MockProvider.ts +161 -0
  103. package/src/tool/HistoryCheck.ts +49 -0
  104. package/src/tool/Outcome.ts +101 -0
  105. package/src/tool/Resolvers.test.ts +426 -0
  106. package/src/tool/Resolvers.ts +166 -0
  107. package/src/tool/Tool.ts +150 -0
  108. package/src/tool/ToolEvent.ts +37 -0
  109. package/src/tool/Toolkit.test.ts +45 -0
  110. 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
+ )