@yolk-sdk/harness 0.1.0-canary.77

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/src/outcome.ts ADDED
@@ -0,0 +1,316 @@
1
+ import { Effect, Stream } from 'effect'
2
+ import {
3
+ addAgentUsage,
4
+ zeroAgentUsage,
5
+ type AgentEvent,
6
+ type AgentMessage,
7
+ type AgentUsage,
8
+ type HitlRequest,
9
+ type HitlResponse
10
+ } from '@yolk-sdk/agent/protocol'
11
+ import {
12
+ collectModelTurnAttempt,
13
+ LLMError,
14
+ runModelTurn,
15
+ runToolBatch,
16
+ type AgentLoopError,
17
+ type ContextTransformer,
18
+ type LLMProvider,
19
+ type LoopConfig,
20
+ type ModelTurnConfig,
21
+ type ModelTurnResult,
22
+ type ToolBatchConfig,
23
+ type ToolExecutor
24
+ } from '@yolk-sdk/agent/loop'
25
+ import {
26
+ applyOverflowCompaction,
27
+ isOverflowCompactionAttemptCount
28
+ } from '@yolk-sdk/agent/compaction'
29
+
30
+ export type OverflowCompactionResult =
31
+ | {
32
+ readonly _tag: 'Compacted'
33
+ readonly messages: ReadonlyArray<AgentMessage>
34
+ }
35
+ | {
36
+ readonly _tag: 'Skipped'
37
+ }
38
+
39
+ export type CompletedTurn = {
40
+ readonly _tag: 'Completed'
41
+ readonly needsContinuation: boolean
42
+ } & ModelTurnResult
43
+
44
+ export type ModelTurnOutcome =
45
+ | CompletedTurn
46
+ | { readonly _tag: 'Retry'; readonly error: AgentLoopError }
47
+ | ({ readonly _tag: 'Continue'; readonly error: AgentLoopError } & ModelTurnResult)
48
+ | { readonly _tag: 'RecoverFull'; readonly error: AgentLoopError }
49
+ | {
50
+ readonly _tag: 'Compacted'
51
+ readonly messages: ReadonlyArray<AgentMessage>
52
+ readonly overflowCompactionAttempt: number
53
+ }
54
+
55
+ export type ToolBatchOutcome =
56
+ | CompletedTurn
57
+ | {
58
+ readonly _tag: 'AwaitingInput'
59
+ readonly requests: ReadonlyArray<HitlRequest>
60
+ readonly usage: AgentUsage
61
+ }
62
+
63
+ export type StepOutcome = ModelTurnOutcome | ToolBatchOutcome
64
+
65
+ const completedOutcome = (result: ModelTurnResult): CompletedTurn => ({
66
+ _tag: 'Completed',
67
+ needsContinuation: result.stopReason === 'tool_use',
68
+ assistantMessage: result.assistantMessage,
69
+ toolCalls: result.toolCalls,
70
+ usage: result.usage,
71
+ stopReason: result.stopReason
72
+ })
73
+
74
+ const modelTurnResult = (result: ModelTurnResult): ModelTurnResult => ({
75
+ assistantMessage: result.assistantMessage,
76
+ toolCalls: result.toolCalls,
77
+ usage: result.usage,
78
+ stopReason: result.stopReason
79
+ })
80
+
81
+ const invalidOverflowCompactionAttemptError = () =>
82
+ new LLMError({
83
+ cause: 'validation_error',
84
+ message: 'overflowCompactionAttempt must be a finite integer >= 0',
85
+ retryable: false
86
+ })
87
+
88
+ const isAgentLoopError = (error: unknown): error is AgentLoopError => {
89
+ if (typeof error !== 'object' || error === null || !('_tag' in error)) {
90
+ return false
91
+ }
92
+ const tag = error._tag
93
+ return (
94
+ tag === 'LLMError' ||
95
+ tag === 'FauxExhaustedError' ||
96
+ tag === 'ToolError' ||
97
+ tag === 'ContextTransformError' ||
98
+ tag === 'AbortError'
99
+ )
100
+ }
101
+
102
+ const isOverflow = (error: AgentLoopError) =>
103
+ (error._tag === 'LLMError' || error._tag === 'ContextTransformError') &&
104
+ error.cause === 'context_overflow'
105
+
106
+ const isMissingDone = (error: AgentLoopError) =>
107
+ error._tag === 'LLMError' && error.responseIssue === 'missing_done'
108
+
109
+ const isRetryableLlm = (
110
+ error: AgentLoopError
111
+ ): error is Extract<AgentLoopError, { _tag: 'LLMError' }> =>
112
+ error._tag === 'LLMError' && error.retryable
113
+
114
+ const classifyModelTurnFailure = <E2, R2>(input: {
115
+ readonly error: AgentLoopError
116
+ readonly collected: ModelTurnResult
117
+ readonly outputStarted: boolean
118
+ readonly messages: ReadonlyArray<AgentMessage>
119
+ readonly overflowCompactionAttempt: number
120
+ readonly compact?: (
121
+ messages: ReadonlyArray<AgentMessage>
122
+ ) => Effect.Effect<OverflowCompactionResult, E2, R2>
123
+ }): Effect.Effect<ModelTurnOutcome, AgentLoopError | E2, R2> => {
124
+ if (isOverflow(input.error)) {
125
+ if (input.compact === undefined) {
126
+ return Effect.fail(input.error)
127
+ }
128
+
129
+ return applyOverflowCompaction({
130
+ compact: input.compact,
131
+ messages: input.messages,
132
+ attempt: input.overflowCompactionAttempt,
133
+ outputStarted: input.outputStarted
134
+ }).pipe(
135
+ Effect.flatMap(result =>
136
+ result._tag === 'Compacted'
137
+ ? Effect.succeed({
138
+ _tag: 'Compacted' as const,
139
+ messages: result.messages,
140
+ overflowCompactionAttempt: input.overflowCompactionAttempt + 1
141
+ })
142
+ : Effect.fail(input.error)
143
+ )
144
+ )
145
+ }
146
+
147
+ if (isMissingDone(input.error) && !input.outputStarted) {
148
+ return Effect.succeed({ _tag: 'RecoverFull', error: input.error })
149
+ }
150
+
151
+ if (isMissingDone(input.error) && input.outputStarted) {
152
+ return Effect.succeed({
153
+ _tag: 'Continue',
154
+ error: input.error,
155
+ ...modelTurnResult(input.collected)
156
+ })
157
+ }
158
+
159
+ if (isRetryableLlm(input.error) && !input.outputStarted) {
160
+ if (input.error.cause === 'invalid_response') {
161
+ return Effect.succeed({ _tag: 'RecoverFull', error: input.error })
162
+ }
163
+ return Effect.succeed({ _tag: 'Retry', error: input.error })
164
+ }
165
+
166
+ if (isRetryableLlm(input.error) && input.outputStarted) {
167
+ return Effect.succeed({
168
+ _tag: 'Continue',
169
+ error: input.error,
170
+ ...modelTurnResult(input.collected)
171
+ })
172
+ }
173
+
174
+ return Effect.fail(input.error)
175
+ }
176
+
177
+ export const attemptModelTurn = <E2 = never, R2 = never>(
178
+ config: ModelTurnConfig,
179
+ options?: {
180
+ readonly onEvent?: (event: AgentEvent) => Effect.Effect<void, E2, R2>
181
+ readonly initialUsage?: AgentUsage
182
+ readonly overflowCompactionAttempt?: number
183
+ readonly compact?: (
184
+ messages: ReadonlyArray<AgentMessage>
185
+ ) => Effect.Effect<OverflowCompactionResult, E2, R2>
186
+ }
187
+ ): Effect.Effect<
188
+ ModelTurnOutcome,
189
+ AgentLoopError | E2,
190
+ ContextTransformer | LLMProvider | LoopConfig | R2
191
+ > =>
192
+ Effect.gen(function* () {
193
+ const overflowCompactionAttempt = options?.overflowCompactionAttempt ?? 0
194
+ if (!isOverflowCompactionAttemptCount(overflowCompactionAttempt)) {
195
+ return yield* Effect.fail(invalidOverflowCompactionAttemptError())
196
+ }
197
+
198
+ const outcome = yield* collectModelTurnAttempt(runModelTurn(config), {
199
+ onEvent: options?.onEvent,
200
+ initialUsage: options?.initialUsage
201
+ })
202
+
203
+ switch (outcome._tag) {
204
+ case 'Collected':
205
+ return completedOutcome(outcome.collection)
206
+ case 'SinkFailed':
207
+ return yield* Effect.fail(outcome.error)
208
+ case 'StreamFailed':
209
+ if (!isAgentLoopError(outcome.error)) {
210
+ return yield* Effect.fail(outcome.error)
211
+ }
212
+ return yield* classifyModelTurnFailure({
213
+ error: outcome.error,
214
+ collected: {
215
+ assistantMessage:
216
+ outcome.collection.assistantMessage ?? outcome.collection.partialAssistantMessage,
217
+ toolCalls: outcome.collection.toolCalls,
218
+ usage: outcome.collection.usage,
219
+ stopReason: outcome.collection.stopReason
220
+ },
221
+ outputStarted: outcome.collection.outputStarted,
222
+ messages: config.messages,
223
+ overflowCompactionAttempt,
224
+ compact: options?.compact
225
+ })
226
+ }
227
+ })
228
+
229
+ export const attemptToolBatch = <E2 = never, R2 = never>(
230
+ config: ToolBatchConfig,
231
+ options?: {
232
+ readonly onEvent?: (event: AgentEvent) => Effect.Effect<void, E2, R2>
233
+ }
234
+ ): Effect.Effect<ToolBatchOutcome, AgentLoopError | E2, LoopConfig | ToolExecutor | R2> => {
235
+ const onEvent = options?.onEvent
236
+
237
+ return runToolBatch(config).pipe(
238
+ Stream.runFoldEffect(
239
+ (): {
240
+ requests: ReadonlyArray<HitlRequest>
241
+ usage: AgentUsage
242
+ toolCalls: ModelTurnResult['toolCalls']
243
+ } => ({
244
+ requests: [],
245
+ usage: config.usage ?? zeroAgentUsage,
246
+ toolCalls: []
247
+ }),
248
+ (acc, event) => {
249
+ const next =
250
+ event._tag === 'AgentAwaitingInput'
251
+ ? { ...acc, requests: event.requests, usage: event.usage }
252
+ : event._tag === 'UsageUpdate'
253
+ ? { ...acc, usage: addAgentUsage(acc.usage, event.usage) }
254
+ : event._tag === 'ToolExecutionCompleted' || event._tag === 'ToolExecutionAccepted'
255
+ ? { ...acc, toolCalls: [...acc.toolCalls, event.call] }
256
+ : acc
257
+ return onEvent === undefined ? Effect.succeed(next) : onEvent(event).pipe(Effect.as(next))
258
+ }
259
+ ),
260
+ Effect.map((result): ToolBatchOutcome => {
261
+ if (result.requests.length === 0) {
262
+ const needsContinuation = result.toolCalls.length > 0
263
+ return {
264
+ _tag: 'Completed',
265
+ needsContinuation,
266
+ assistantMessage: undefined,
267
+ toolCalls: result.toolCalls,
268
+ usage: result.usage,
269
+ stopReason: needsContinuation ? 'tool_use' : 'stop'
270
+ }
271
+ }
272
+ return { _tag: 'AwaitingInput', requests: result.requests, usage: result.usage }
273
+ })
274
+ )
275
+ }
276
+
277
+ export type HitlMatch =
278
+ | { readonly _tag: 'Match'; readonly requestId: string }
279
+ | { readonly _tag: 'Mismatch' }
280
+
281
+ const hitlResponseMatchesRequest = (response: HitlResponse, request: HitlRequest) => {
282
+ switch (response._tag) {
283
+ case 'ToolApprovalResponse':
284
+ return (
285
+ request._tag === 'ToolApprovalRequest' &&
286
+ response.requestId === request.requestId &&
287
+ response.toolCallId === request.toolCallId
288
+ )
289
+ case 'QuestionResponse':
290
+ return (
291
+ request._tag === 'QuestionRequest' &&
292
+ response.requestId === request.requestId &&
293
+ response.toolCallId === request.toolCallId
294
+ )
295
+ }
296
+ }
297
+
298
+ export const matchHitlResponse = (
299
+ pending: ReadonlyArray<HitlRequest>,
300
+ response: HitlResponse
301
+ ): HitlMatch => {
302
+ const matched = pending.find(request => hitlResponseMatchesRequest(response, request))
303
+ return matched === undefined
304
+ ? { _tag: 'Mismatch' }
305
+ : { _tag: 'Match', requestId: matched.requestId }
306
+ }
307
+
308
+ export const resumeHitlIfMatched = <A, E, R>(input: {
309
+ readonly pending: ReadonlyArray<HitlRequest>
310
+ readonly response: HitlResponse
311
+ readonly resume: (requestId: string) => Effect.Effect<A, E, R>
312
+ }): Effect.Effect<A | { readonly _tag: 'Mismatch' }, E, R> => {
313
+ const matched = matchHitlResponse(input.pending, input.response)
314
+ if (matched._tag === 'Mismatch') return Effect.succeed(matched)
315
+ return input.resume(matched.requestId)
316
+ }
package/src/store.ts ADDED
@@ -0,0 +1,137 @@
1
+ import { Context, Effect, Layer, Ref, Semaphore } from 'effect'
2
+
3
+ export type RunStoreShape = {
4
+ readonly claim: (runId: string) => Effect.Effect<void>
5
+ readonly release: (runId: string) => Effect.Effect<void>
6
+ readonly isClaimed: (runId: string) => Effect.Effect<boolean>
7
+ readonly claimed: Effect.Effect<ReadonlySet<string>>
8
+ readonly incrementResumeCount: (runId: string) => Effect.Effect<number>
9
+ readonly resumeCount: (runId: string) => Effect.Effect<number>
10
+ }
11
+
12
+ export class RunStore extends Context.Service<RunStore, RunStoreShape>()(
13
+ '@yolk-sdk/harness/RunStore'
14
+ ) {}
15
+
16
+ export type DurableRunStoreSnapshot = {
17
+ readonly claimed: ReadonlyArray<string>
18
+ readonly resumes: ReadonlyArray<readonly [string, number]>
19
+ }
20
+
21
+ type MemorySnapshot = {
22
+ readonly claimed: ReadonlySet<string>
23
+ readonly resumes: ReadonlyMap<string, number>
24
+ }
25
+
26
+ const emptySnapshot: DurableRunStoreSnapshot = {
27
+ claimed: [],
28
+ resumes: []
29
+ }
30
+
31
+ const snapshotFromMemory = (memory: MemorySnapshot): DurableRunStoreSnapshot => ({
32
+ claimed: [...memory.claimed],
33
+ resumes: [...memory.resumes.entries()]
34
+ })
35
+
36
+ const memoryFromSnapshot = (snapshot: DurableRunStoreSnapshot): MemorySnapshot => ({
37
+ claimed: new Set(snapshot.claimed),
38
+ resumes: new Map(snapshot.resumes)
39
+ })
40
+
41
+ const makeSnapshotRunStore = (options: {
42
+ readonly load: Effect.Effect<DurableRunStoreSnapshot | undefined>
43
+ readonly save: (snapshot: DurableRunStoreSnapshot) => Effect.Effect<void>
44
+ }): Effect.Effect<RunStore['Service']> =>
45
+ Effect.gen(function* () {
46
+ const loaded = yield* options.load
47
+ const memory = yield* Ref.make(memoryFromSnapshot(loaded ?? emptySnapshot))
48
+ const lock = yield* Semaphore.make(1)
49
+ const mutate = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
50
+ Effect.uninterruptibleMask(restore =>
51
+ restore(lock.take(1)).pipe(
52
+ Effect.flatMap(() => effect.pipe(Effect.ensuring(lock.release(1))))
53
+ )
54
+ )
55
+ const commit = (next: MemorySnapshot) =>
56
+ options.save(snapshotFromMemory(next)).pipe(Effect.flatMap(() => Ref.set(memory, next)))
57
+
58
+ return RunStore.of({
59
+ claim: runId =>
60
+ mutate(
61
+ Effect.gen(function* () {
62
+ const current = yield* Ref.get(memory)
63
+ yield* commit({
64
+ claimed: new Set(current.claimed).add(runId),
65
+ resumes: current.resumes
66
+ })
67
+ })
68
+ ),
69
+ release: runId =>
70
+ mutate(
71
+ Effect.gen(function* () {
72
+ const current = yield* Ref.get(memory)
73
+ const claimed = new Set(current.claimed)
74
+ claimed.delete(runId)
75
+ const resumes = new Map(current.resumes)
76
+ resumes.delete(runId)
77
+ yield* commit({ claimed, resumes })
78
+ })
79
+ ),
80
+ isClaimed: runId => Ref.get(memory).pipe(Effect.map(current => current.claimed.has(runId))),
81
+ claimed: Ref.get(memory).pipe(Effect.map(current => new Set(current.claimed))),
82
+ incrementResumeCount: runId =>
83
+ mutate(
84
+ Effect.gen(function* () {
85
+ const current = yield* Ref.get(memory)
86
+ const nextCount = (current.resumes.get(runId) ?? 0) + 1
87
+ const resumes = new Map(current.resumes)
88
+ resumes.set(runId, nextCount)
89
+ yield* commit({ claimed: current.claimed, resumes })
90
+ return nextCount
91
+ })
92
+ ),
93
+ resumeCount: runId =>
94
+ Ref.get(memory).pipe(Effect.map(current => current.resumes.get(runId) ?? 0))
95
+ })
96
+ })
97
+
98
+ export const makeSnapshotRunStoreLayer = (options: {
99
+ readonly load: Effect.Effect<DurableRunStoreSnapshot | undefined>
100
+ readonly save: (snapshot: DurableRunStoreSnapshot) => Effect.Effect<void>
101
+ }): Layer.Layer<RunStore> => Layer.effect(RunStore, makeSnapshotRunStore(options))
102
+
103
+ export const makeInMemoryRunStoreLayer = (): Layer.Layer<RunStore> =>
104
+ Layer.effect(
105
+ RunStore,
106
+ Effect.gen(function* () {
107
+ const claimed = yield* Ref.make(new Set<string>())
108
+ const resumes = yield* Ref.make(new Map<string, number>())
109
+
110
+ return RunStore.of({
111
+ claim: runId => Ref.update(claimed, current => new Set(current).add(runId)),
112
+ release: runId =>
113
+ Effect.zip(
114
+ Ref.update(claimed, current => {
115
+ const next = new Set(current)
116
+ next.delete(runId)
117
+ return next
118
+ }),
119
+ Ref.update(resumes, current => {
120
+ const next = new Map(current)
121
+ next.delete(runId)
122
+ return next
123
+ })
124
+ ).pipe(Effect.asVoid),
125
+ isClaimed: runId => Ref.get(claimed).pipe(Effect.map(current => current.has(runId))),
126
+ claimed: Ref.get(claimed).pipe(Effect.map(current => new Set(current))),
127
+ incrementResumeCount: runId =>
128
+ Ref.modify(resumes, current => {
129
+ const nextCount = (current.get(runId) ?? 0) + 1
130
+ const next = new Map(current)
131
+ next.set(runId, nextCount)
132
+ return [nextCount, next] as const
133
+ }),
134
+ resumeCount: runId => Ref.get(resumes).pipe(Effect.map(current => current.get(runId) ?? 0))
135
+ })
136
+ })
137
+ )