@llm4ts/flow 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 +41 -0
- package/dist/AzureDevOpsTool.d.ts +60 -0
- package/dist/AzureDevOpsTool.d.ts.map +1 -0
- package/dist/AzureDevOpsTool.js +163 -0
- package/dist/AzureDevOpsTool.js.map +1 -0
- package/dist/Bench.d.ts +133 -0
- package/dist/Bench.d.ts.map +1 -0
- package/dist/Bench.js +272 -0
- package/dist/Bench.js.map +1 -0
- package/dist/BenchReport.d.ts +9 -0
- package/dist/BenchReport.d.ts.map +1 -0
- package/dist/BenchReport.js +360 -0
- package/dist/BenchReport.js.map +1 -0
- package/dist/CapabilityGuard.d.ts +6 -0
- package/dist/CapabilityGuard.d.ts.map +1 -0
- package/dist/CapabilityGuard.js +25 -0
- package/dist/CapabilityGuard.js.map +1 -0
- package/dist/Chat.d.ts +15 -0
- package/dist/Chat.d.ts.map +1 -0
- package/dist/Chat.js +36 -0
- package/dist/Chat.js.map +1 -0
- package/dist/Classified.d.ts +14 -0
- package/dist/Classified.d.ts.map +1 -0
- package/dist/Classified.js +48 -0
- package/dist/Classified.js.map +1 -0
- package/dist/CostLedger.d.ts +55 -0
- package/dist/CostLedger.d.ts.map +1 -0
- package/dist/CostLedger.js +126 -0
- package/dist/CostLedger.js.map +1 -0
- package/dist/CostTracker.d.ts +13 -0
- package/dist/CostTracker.d.ts.map +1 -0
- package/dist/CostTracker.js +116 -0
- package/dist/CostTracker.js.map +1 -0
- package/dist/Equiv.d.ts +163 -0
- package/dist/Equiv.d.ts.map +1 -0
- package/dist/Equiv.js +256 -0
- package/dist/Equiv.js.map +1 -0
- package/dist/EquivReport.d.ts +16 -0
- package/dist/EquivReport.d.ts.map +1 -0
- package/dist/EquivReport.js +77 -0
- package/dist/EquivReport.js.map +1 -0
- package/dist/Flow.d.ts +25 -0
- package/dist/Flow.d.ts.map +1 -0
- package/dist/Flow.js +39 -0
- package/dist/Flow.js.map +1 -0
- package/dist/FlowContext.d.ts +23 -0
- package/dist/FlowContext.d.ts.map +1 -0
- package/dist/FlowContext.js +4 -0
- package/dist/FlowContext.js.map +1 -0
- package/dist/FlowError.d.ts +80 -0
- package/dist/FlowError.d.ts.map +1 -0
- package/dist/FlowError.js +92 -0
- package/dist/FlowError.js.map +1 -0
- package/dist/FlowEvents.d.ts +101 -0
- package/dist/FlowEvents.d.ts.map +1 -0
- package/dist/FlowEvents.js +103 -0
- package/dist/FlowEvents.js.map +1 -0
- package/dist/FlowRecorder.d.ts +26 -0
- package/dist/FlowRecorder.d.ts.map +1 -0
- package/dist/FlowRecorder.js +74 -0
- package/dist/FlowRecorder.js.map +1 -0
- package/dist/GitHubTool.d.ts +53 -0
- package/dist/GitHubTool.d.ts.map +1 -0
- package/dist/GitHubTool.js +191 -0
- package/dist/GitHubTool.js.map +1 -0
- package/dist/GitTool.d.ts +46 -0
- package/dist/GitTool.d.ts.map +1 -0
- package/dist/GitTool.js +97 -0
- package/dist/GitTool.js.map +1 -0
- package/dist/Interaction.d.ts +21 -0
- package/dist/Interaction.d.ts.map +1 -0
- package/dist/Interaction.js +9 -0
- package/dist/Interaction.js.map +1 -0
- package/dist/McpServer.d.ts +18 -0
- package/dist/McpServer.d.ts.map +1 -0
- package/dist/McpServer.js +122 -0
- package/dist/McpServer.js.map +1 -0
- package/dist/Mermaid.d.ts +7 -0
- package/dist/Mermaid.d.ts.map +1 -0
- package/dist/Mermaid.js +98 -0
- package/dist/Mermaid.js.map +1 -0
- package/dist/Pack.d.ts +40 -0
- package/dist/Pack.d.ts.map +1 -0
- package/dist/Pack.js +141 -0
- package/dist/Pack.js.map +1 -0
- package/dist/Package.d.ts +2 -0
- package/dist/Package.d.ts.map +1 -0
- package/dist/Package.js +2 -0
- package/dist/Package.js.map +1 -0
- package/dist/Persistence.d.ts +32 -0
- package/dist/Persistence.d.ts.map +1 -0
- package/dist/Persistence.js +85 -0
- package/dist/Persistence.js.map +1 -0
- package/dist/Plan.d.ts +26 -0
- package/dist/Plan.d.ts.map +1 -0
- package/dist/Plan.js +101 -0
- package/dist/Plan.js.map +1 -0
- package/dist/PlanExecution.d.ts +7 -0
- package/dist/PlanExecution.d.ts.map +1 -0
- package/dist/PlanExecution.js +19 -0
- package/dist/PlanExecution.js.map +1 -0
- package/dist/Planner.d.ts +32 -0
- package/dist/Planner.d.ts.map +1 -0
- package/dist/Planner.js +97 -0
- package/dist/Planner.js.map +1 -0
- package/dist/PrSummary.d.ts +15 -0
- package/dist/PrSummary.d.ts.map +1 -0
- package/dist/PrSummary.js +29 -0
- package/dist/PrSummary.js.map +1 -0
- package/dist/PriceList.d.ts +11 -0
- package/dist/PriceList.d.ts.map +1 -0
- package/dist/PriceList.js +79 -0
- package/dist/PriceList.js.map +1 -0
- package/dist/Provenance.d.ts +27 -0
- package/dist/Provenance.d.ts.map +1 -0
- package/dist/Provenance.js +38 -0
- package/dist/Provenance.js.map +1 -0
- package/dist/Replay.d.ts +29 -0
- package/dist/Replay.d.ts.map +1 -0
- package/dist/Replay.js +181 -0
- package/dist/Replay.js.map +1 -0
- package/dist/Review.d.ts +71 -0
- package/dist/Review.d.ts.map +1 -0
- package/dist/Review.js +204 -0
- package/dist/Review.js.map +1 -0
- package/dist/ReviewCache.d.ts +6 -0
- package/dist/ReviewCache.d.ts.map +1 -0
- package/dist/ReviewCache.js +28 -0
- package/dist/ReviewCache.js.map +1 -0
- package/dist/Reviewer.d.ts +12 -0
- package/dist/Reviewer.d.ts.map +1 -0
- package/dist/Reviewer.js +30 -0
- package/dist/Reviewer.js.map +1 -0
- package/dist/SpecChecks.d.ts +17 -0
- package/dist/SpecChecks.d.ts.map +1 -0
- package/dist/SpecChecks.js +107 -0
- package/dist/SpecChecks.js.map +1 -0
- package/dist/Survey.d.ts +32 -0
- package/dist/Survey.d.ts.map +1 -0
- package/dist/Survey.js +119 -0
- package/dist/Survey.js.map +1 -0
- package/dist/TransientRetry.d.ts +16 -0
- package/dist/TransientRetry.d.ts.map +1 -0
- package/dist/TransientRetry.js +102 -0
- package/dist/TransientRetry.js.map +1 -0
- package/dist/Workspace.d.ts +39 -0
- package/dist/Workspace.d.ts.map +1 -0
- package/dist/Workspace.js +192 -0
- package/dist/Workspace.js.map +1 -0
- package/dist/WorkspaceLayout.d.ts +3 -0
- package/dist/WorkspaceLayout.d.ts.map +1 -0
- package/dist/WorkspaceLayout.js +19 -0
- package/dist/WorkspaceLayout.js.map +1 -0
- package/dist/WorkspaceTools.d.ts +4 -0
- package/dist/WorkspaceTools.d.ts.map +1 -0
- package/dist/WorkspaceTools.js +130 -0
- package/dist/WorkspaceTools.js.map +1 -0
- package/package.json +91 -0
- package/src/AzureDevOpsTool.ts +291 -0
- package/src/Bench.ts +340 -0
- package/src/BenchReport.ts +447 -0
- package/src/CapabilityGuard.ts +43 -0
- package/src/Chat.ts +60 -0
- package/src/Classified.ts +67 -0
- package/src/CostLedger.ts +171 -0
- package/src/CostTracker.ts +170 -0
- package/src/Equiv.ts +378 -0
- package/src/EquivReport.ts +92 -0
- package/src/Flow.ts +72 -0
- package/src/FlowContext.ts +23 -0
- package/src/FlowError.ts +112 -0
- package/src/FlowEvents.ts +147 -0
- package/src/FlowRecorder.ts +116 -0
- package/src/GitHubTool.ts +315 -0
- package/src/GitTool.ts +214 -0
- package/src/Interaction.ts +23 -0
- package/src/McpServer.ts +168 -0
- package/src/Mermaid.ts +112 -0
- package/src/Pack.ts +213 -0
- package/src/Package.ts +1 -0
- package/src/Persistence.ts +198 -0
- package/src/Plan.ts +119 -0
- package/src/PlanExecution.ts +46 -0
- package/src/Planner.ts +134 -0
- package/src/PrSummary.ts +37 -0
- package/src/PriceList.ts +91 -0
- package/src/Provenance.ts +85 -0
- package/src/Replay.ts +265 -0
- package/src/Review.ts +337 -0
- package/src/ReviewCache.ts +45 -0
- package/src/Reviewer.ts +31 -0
- package/src/SpecChecks.ts +143 -0
- package/src/Survey.ts +144 -0
- package/src/TransientRetry.ts +238 -0
- package/src/Workspace.ts +255 -0
- package/src/WorkspaceLayout.ts +22 -0
- package/src/WorkspaceTools.ts +188 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import * as Duration from "effect/Duration"
|
|
2
|
+
import * as Effect from "effect/Effect"
|
|
3
|
+
import * as Stream from "effect/Stream"
|
|
4
|
+
import type { LlmError } from "@llm4ts/core/Errors"
|
|
5
|
+
import { LlmService, type LlmServiceShape } from "@llm4ts/core/LlmService"
|
|
6
|
+
import type { LlmChunk } from "@llm4ts/core/Models"
|
|
7
|
+
import { FlowEvents, Info } from "./FlowEvents.ts"
|
|
8
|
+
|
|
9
|
+
const transientSignals: ReadonlyArray<string> = [
|
|
10
|
+
"connection reset",
|
|
11
|
+
"server_error",
|
|
12
|
+
"internal error",
|
|
13
|
+
"service unavailable",
|
|
14
|
+
"unavailable",
|
|
15
|
+
"overloaded",
|
|
16
|
+
"temporarily",
|
|
17
|
+
"timed out",
|
|
18
|
+
"api error",
|
|
19
|
+
"unknown error",
|
|
20
|
+
"code=500",
|
|
21
|
+
"code=502",
|
|
22
|
+
"code=503",
|
|
23
|
+
"code=504",
|
|
24
|
+
" 500",
|
|
25
|
+
" 502",
|
|
26
|
+
" 503",
|
|
27
|
+
" 504"
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
const flakyStreamSignals: ReadonlyArray<string> = [
|
|
31
|
+
"empty response",
|
|
32
|
+
"malformed tool call",
|
|
33
|
+
"invalid stream"
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
const includesSignal = (message: string, signals: ReadonlyArray<string>): boolean => {
|
|
37
|
+
const normalized = message.toLowerCase()
|
|
38
|
+
return signals.some((signal) => normalized.includes(signal))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const isFlakyStream = (error: LlmError): boolean =>
|
|
42
|
+
error._tag === "ProviderError" && includesSignal(error.message, flakyStreamSignals)
|
|
43
|
+
|
|
44
|
+
export const isTransient = (error: LlmError): boolean => {
|
|
45
|
+
switch (error._tag) {
|
|
46
|
+
case "TimeoutError":
|
|
47
|
+
case "RateLimitError":
|
|
48
|
+
return true
|
|
49
|
+
case "ProviderError":
|
|
50
|
+
return !isFlakyStream(error) && includesSignal(error.message, transientSignals)
|
|
51
|
+
default:
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const maxHonoredRetryAfter = Duration.minutes(2)
|
|
57
|
+
|
|
58
|
+
export const transientDelay = (error: LlmError, backoff: Duration.Duration): Duration.Duration => {
|
|
59
|
+
if (error._tag !== "RateLimitError" || error.retryAfter === undefined) {
|
|
60
|
+
return backoff
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return Duration.max(backoff, Duration.min(error.retryAfter, maxHonoredRetryAfter))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface TransientRetryOptions {
|
|
67
|
+
readonly maxRetries?: number
|
|
68
|
+
readonly baseDelay?: Duration.Input
|
|
69
|
+
readonly flakyRetries?: number
|
|
70
|
+
readonly flakyDelay?: Duration.Input
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface ResolvedTransientRetryOptions {
|
|
74
|
+
readonly maxRetries: number
|
|
75
|
+
readonly baseDelay: Duration.Duration
|
|
76
|
+
readonly flakyRetries: number
|
|
77
|
+
readonly flakyDelay: Duration.Duration
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const resolveOptions = (options: TransientRetryOptions): ResolvedTransientRetryOptions => ({
|
|
81
|
+
maxRetries: options.maxRetries ?? 3,
|
|
82
|
+
baseDelay: Duration.fromInputUnsafe(options.baseDelay ?? "1 second"),
|
|
83
|
+
flakyRetries: options.flakyRetries ?? 6,
|
|
84
|
+
flakyDelay: Duration.fromInputUnsafe(options.flakyDelay ?? "1 second")
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const backoff = (baseDelay: Duration.Duration, attempt: number): Duration.Duration =>
|
|
88
|
+
Duration.times(baseDelay, 2 ** attempt)
|
|
89
|
+
|
|
90
|
+
const retryNotice = (
|
|
91
|
+
events: FlowEvents["Service"],
|
|
92
|
+
what: string,
|
|
93
|
+
attempt: number,
|
|
94
|
+
maximum: number,
|
|
95
|
+
error: LlmError
|
|
96
|
+
): Effect.Effect<void> =>
|
|
97
|
+
events.publish(
|
|
98
|
+
Info.make({
|
|
99
|
+
message: `⟳ ${what} — retry ${attempt + 1}/${maximum}: ${error.message}`
|
|
100
|
+
})
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
const waitForRetry = (
|
|
104
|
+
events: FlowEvents["Service"],
|
|
105
|
+
what: string,
|
|
106
|
+
attempt: number,
|
|
107
|
+
maximum: number,
|
|
108
|
+
error: LlmError,
|
|
109
|
+
delay: Duration.Duration
|
|
110
|
+
): Effect.Effect<void> =>
|
|
111
|
+
retryNotice(events, what, attempt, maximum, error).pipe(Effect.andThen(Effect.sleep(delay)))
|
|
112
|
+
|
|
113
|
+
const retryEffect = <A, R>(
|
|
114
|
+
effect: Effect.Effect<A, LlmError, R>,
|
|
115
|
+
what: string,
|
|
116
|
+
options: ResolvedTransientRetryOptions,
|
|
117
|
+
events: FlowEvents["Service"]
|
|
118
|
+
): Effect.Effect<A, LlmError, R> => {
|
|
119
|
+
const loop = (transientAttempt: number, flakyAttempt: number): Effect.Effect<A, LlmError, R> =>
|
|
120
|
+
Effect.catchIf(
|
|
121
|
+
effect,
|
|
122
|
+
() => true,
|
|
123
|
+
(error) => {
|
|
124
|
+
if (isFlakyStream(error) && flakyAttempt < options.flakyRetries) {
|
|
125
|
+
return waitForRetry(
|
|
126
|
+
events,
|
|
127
|
+
`flaky ${what} (fresh retry)`,
|
|
128
|
+
flakyAttempt,
|
|
129
|
+
options.flakyRetries,
|
|
130
|
+
error,
|
|
131
|
+
options.flakyDelay
|
|
132
|
+
).pipe(Effect.andThen(Effect.suspend(() => loop(transientAttempt, flakyAttempt + 1))))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (isTransient(error) && transientAttempt < options.maxRetries) {
|
|
136
|
+
return waitForRetry(
|
|
137
|
+
events,
|
|
138
|
+
`transient error (${what})`,
|
|
139
|
+
transientAttempt,
|
|
140
|
+
options.maxRetries,
|
|
141
|
+
error,
|
|
142
|
+
transientDelay(error, backoff(options.baseDelay, transientAttempt))
|
|
143
|
+
).pipe(Effect.andThen(Effect.suspend(() => loop(transientAttempt + 1, flakyAttempt))))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return Effect.fail(error)
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return loop(0, 0)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const retryStream = <R>(
|
|
154
|
+
stream: Stream.Stream<LlmChunk, LlmError, R>,
|
|
155
|
+
what: string,
|
|
156
|
+
options: ResolvedTransientRetryOptions,
|
|
157
|
+
events: FlowEvents["Service"]
|
|
158
|
+
): Stream.Stream<LlmChunk, LlmError, R> => {
|
|
159
|
+
const waitThenRetry = (
|
|
160
|
+
notice: Effect.Effect<void>,
|
|
161
|
+
next: () => Stream.Stream<LlmChunk, LlmError, R>
|
|
162
|
+
): Stream.Stream<LlmChunk, LlmError, R> =>
|
|
163
|
+
Stream.concat(Stream.drain(Stream.fromEffect(notice)), Stream.suspend(next))
|
|
164
|
+
|
|
165
|
+
const loop = (
|
|
166
|
+
transientAttempt: number,
|
|
167
|
+
flakyAttempt: number
|
|
168
|
+
): Stream.Stream<LlmChunk, LlmError, R> =>
|
|
169
|
+
Stream.catchIf(
|
|
170
|
+
stream,
|
|
171
|
+
() => true,
|
|
172
|
+
(error) => {
|
|
173
|
+
if (isFlakyStream(error) && flakyAttempt < options.flakyRetries) {
|
|
174
|
+
return waitThenRetry(
|
|
175
|
+
waitForRetry(
|
|
176
|
+
events,
|
|
177
|
+
`flaky ${what} (fresh retry)`,
|
|
178
|
+
flakyAttempt,
|
|
179
|
+
options.flakyRetries,
|
|
180
|
+
error,
|
|
181
|
+
options.flakyDelay
|
|
182
|
+
),
|
|
183
|
+
() => loop(transientAttempt, flakyAttempt + 1)
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (isTransient(error) && transientAttempt < options.maxRetries) {
|
|
188
|
+
return waitThenRetry(
|
|
189
|
+
waitForRetry(
|
|
190
|
+
events,
|
|
191
|
+
`transient error (${what})`,
|
|
192
|
+
transientAttempt,
|
|
193
|
+
options.maxRetries,
|
|
194
|
+
error,
|
|
195
|
+
transientDelay(error, backoff(options.baseDelay, transientAttempt))
|
|
196
|
+
),
|
|
197
|
+
() => loop(transientAttempt + 1, flakyAttempt)
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return Stream.fail(error)
|
|
202
|
+
}
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
return loop(0, 0)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export const makeTransientRetry = Effect.fn("@llm4ts/flow/TransientRetry.make")(function* (
|
|
209
|
+
underlying: LlmServiceShape,
|
|
210
|
+
retryOptions: TransientRetryOptions = {}
|
|
211
|
+
): Effect.fn.Return<LlmServiceShape, never, FlowEvents> {
|
|
212
|
+
const events = yield* FlowEvents
|
|
213
|
+
const options = resolveOptions(retryOptions)
|
|
214
|
+
|
|
215
|
+
return LlmService.of({
|
|
216
|
+
executeStream: (prompt) =>
|
|
217
|
+
retryStream(underlying.executeStream(prompt), "stream", options, events),
|
|
218
|
+
executeStreamWithHistory: (messages) =>
|
|
219
|
+
retryStream(underlying.executeStreamWithHistory(messages), "stream", options, events),
|
|
220
|
+
executeWithTools: (prompt, tools) =>
|
|
221
|
+
retryEffect(underlying.executeWithTools(prompt, tools), "tools", options, events),
|
|
222
|
+
executeStructured: (prompt, schema, jsonSchema) =>
|
|
223
|
+
retryEffect(
|
|
224
|
+
underlying.executeStructured(prompt, schema, jsonSchema),
|
|
225
|
+
"structured",
|
|
226
|
+
options,
|
|
227
|
+
events
|
|
228
|
+
),
|
|
229
|
+
executeStructuredWithUsage: (prompt, schema, jsonSchema) =>
|
|
230
|
+
retryEffect(
|
|
231
|
+
underlying.executeStructuredWithUsage(prompt, schema, jsonSchema),
|
|
232
|
+
"structured",
|
|
233
|
+
options,
|
|
234
|
+
events
|
|
235
|
+
),
|
|
236
|
+
isAvailable: underlying.isAvailable
|
|
237
|
+
})
|
|
238
|
+
})
|
package/src/Workspace.ts
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import * as Context from "effect/Context"
|
|
2
|
+
import * as Effect from "effect/Effect"
|
|
3
|
+
import * as Ref from "effect/Ref"
|
|
4
|
+
import * as Schema from "effect/Schema"
|
|
5
|
+
import { WorkspaceIoError, WorkspaceLimitError, WorkspacePathError } from "./FlowError.ts"
|
|
6
|
+
|
|
7
|
+
export type WorkspaceError = WorkspacePathError | WorkspaceLimitError | WorkspaceIoError
|
|
8
|
+
|
|
9
|
+
export class SearchMatch extends Schema.Class<SearchMatch>("SearchMatch")({
|
|
10
|
+
path: Schema.String,
|
|
11
|
+
line: Schema.Int,
|
|
12
|
+
text: Schema.String
|
|
13
|
+
}) {}
|
|
14
|
+
|
|
15
|
+
export interface WorkspaceLimits {
|
|
16
|
+
readonly maxReadBytes: number
|
|
17
|
+
readonly maxWriteBytes: number
|
|
18
|
+
readonly maxResults: number
|
|
19
|
+
readonly maxDepth: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const defaultWorkspaceLimits: WorkspaceLimits = Object.freeze({
|
|
23
|
+
maxReadBytes: 1_048_576,
|
|
24
|
+
maxWriteBytes: 1_048_576,
|
|
25
|
+
maxResults: 1_000,
|
|
26
|
+
maxDepth: 32
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
export interface WorkspaceShape {
|
|
30
|
+
readonly root: string
|
|
31
|
+
readonly resolve: (path: string) => Effect.Effect<string, WorkspaceError>
|
|
32
|
+
readonly read: (path: string) => Effect.Effect<string, WorkspaceError>
|
|
33
|
+
readonly write: (path: string, contents: string) => Effect.Effect<void, WorkspaceError>
|
|
34
|
+
readonly append: (path: string, contents: string) => Effect.Effect<void, WorkspaceError>
|
|
35
|
+
readonly discover: (pattern?: string) => Effect.Effect<ReadonlyArray<string>, WorkspaceError>
|
|
36
|
+
readonly search: (
|
|
37
|
+
query: string,
|
|
38
|
+
pattern?: string
|
|
39
|
+
) => Effect.Effect<ReadonlyArray<SearchMatch>, WorkspaceError>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class Workspace extends Context.Service<Workspace, WorkspaceShape>()(
|
|
43
|
+
"@llm4ts/flow/Workspace"
|
|
44
|
+
) {}
|
|
45
|
+
|
|
46
|
+
const globRegex = (glob: string): RegExp => {
|
|
47
|
+
let source = ""
|
|
48
|
+
for (let index = 0; index < glob.length; index += 1) {
|
|
49
|
+
const character = glob.charAt(index)
|
|
50
|
+
if (character === "*" && glob.charAt(index + 1) === "*") {
|
|
51
|
+
source += ".*"
|
|
52
|
+
index += 1
|
|
53
|
+
} else if (character === "*") {
|
|
54
|
+
source += "[^/]*"
|
|
55
|
+
} else if (character === "?") {
|
|
56
|
+
source += "[^/]"
|
|
57
|
+
} else {
|
|
58
|
+
source += character.replace(/[\\^$.[\]{}()+|]/g, "\\$&")
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return new RegExp(`^${source}$`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const comparePaths = (left: string, right: string): number => {
|
|
65
|
+
const leftSegments = left.split("/")
|
|
66
|
+
const rightSegments = right.split("/")
|
|
67
|
+
const shared = Math.min(leftSegments.length, rightSegments.length)
|
|
68
|
+
for (let index = 0; index < shared; index += 1) {
|
|
69
|
+
const order = (leftSegments[index] ?? "").localeCompare(rightSegments[index] ?? "")
|
|
70
|
+
if (order !== 0) {
|
|
71
|
+
return order
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return leftSegments.length - rightSegments.length
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const byteLength = (contents: string): number => new TextEncoder().encode(contents).length
|
|
78
|
+
|
|
79
|
+
export interface MemoryWorkspaceOptions {
|
|
80
|
+
readonly root?: string
|
|
81
|
+
readonly limits?: WorkspaceLimits
|
|
82
|
+
readonly initial?: Readonly<Record<string, string>>
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const makeMemoryWorkspace = (
|
|
86
|
+
options: MemoryWorkspaceOptions = {}
|
|
87
|
+
): Effect.Effect<WorkspaceShape> => {
|
|
88
|
+
const root = (options.root ?? "/workspace").replace(/\/+$/, "")
|
|
89
|
+
const limits = options.limits ?? defaultWorkspaceLimits
|
|
90
|
+
|
|
91
|
+
const relativeKey = (input: string): string | undefined => {
|
|
92
|
+
const normalized = input.replaceAll("\\", "/")
|
|
93
|
+
const withoutRoot =
|
|
94
|
+
normalized === root
|
|
95
|
+
? ""
|
|
96
|
+
: normalized.startsWith(`${root}/`)
|
|
97
|
+
? normalized.slice(root.length + 1)
|
|
98
|
+
: normalized
|
|
99
|
+
if (withoutRoot.startsWith("/")) {
|
|
100
|
+
return undefined
|
|
101
|
+
}
|
|
102
|
+
const segments: Array<string> = []
|
|
103
|
+
for (const part of withoutRoot.split("/")) {
|
|
104
|
+
if (part === "" || part === ".") {
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
if (part === "..") {
|
|
108
|
+
if (segments.length === 0) {
|
|
109
|
+
return undefined
|
|
110
|
+
}
|
|
111
|
+
segments.pop()
|
|
112
|
+
} else {
|
|
113
|
+
segments.push(part)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return segments.join("/")
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const resolveKey = (input: string): Effect.Effect<string, WorkspaceError> => {
|
|
120
|
+
const key = relativeKey(input)
|
|
121
|
+
return key === undefined
|
|
122
|
+
? Effect.fail(
|
|
123
|
+
WorkspacePathError.make({
|
|
124
|
+
path: input,
|
|
125
|
+
message: "path escapes the configured workspace"
|
|
126
|
+
})
|
|
127
|
+
)
|
|
128
|
+
: Effect.succeed(key)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return Ref.make<Readonly<Record<string, string>>>(options.initial ?? {}).pipe(
|
|
132
|
+
Effect.map((state) => {
|
|
133
|
+
const read = (input: string): Effect.Effect<string, WorkspaceError> =>
|
|
134
|
+
Effect.gen(function* () {
|
|
135
|
+
const key = yield* resolveKey(input)
|
|
136
|
+
const files = yield* Ref.get(state)
|
|
137
|
+
const contents = files[key]
|
|
138
|
+
if (contents === undefined) {
|
|
139
|
+
return yield* WorkspaceIoError.make({
|
|
140
|
+
operation: "read",
|
|
141
|
+
path: input,
|
|
142
|
+
message: "file does not exist"
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
const bytes = byteLength(contents)
|
|
146
|
+
if (bytes > limits.maxReadBytes) {
|
|
147
|
+
return yield* WorkspaceLimitError.make({
|
|
148
|
+
operation: "read bytes",
|
|
149
|
+
limit: limits.maxReadBytes,
|
|
150
|
+
actual: bytes
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
return contents
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
const writeBounded = (
|
|
157
|
+
operation: "write" | "append",
|
|
158
|
+
input: string,
|
|
159
|
+
contents: string
|
|
160
|
+
): Effect.Effect<void, WorkspaceError> => {
|
|
161
|
+
const bytes = byteLength(contents)
|
|
162
|
+
if (bytes > limits.maxWriteBytes) {
|
|
163
|
+
return Effect.fail(
|
|
164
|
+
WorkspaceLimitError.make({
|
|
165
|
+
operation: `${operation} bytes`,
|
|
166
|
+
limit: limits.maxWriteBytes,
|
|
167
|
+
actual: bytes
|
|
168
|
+
})
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
return resolveKey(input).pipe(
|
|
172
|
+
Effect.flatMap((key) =>
|
|
173
|
+
Ref.update(state, (files) => ({
|
|
174
|
+
...files,
|
|
175
|
+
[key]: operation === "write" ? contents : `${files[key] ?? ""}${contents}`
|
|
176
|
+
}))
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const discover = (pattern = "**/*"): Effect.Effect<ReadonlyArray<string>, WorkspaceError> =>
|
|
182
|
+
Effect.gen(function* () {
|
|
183
|
+
const normalizedPattern = pattern.replaceAll("\\", "/")
|
|
184
|
+
const matcher = normalizedPattern === "**/*" ? /.*/ : globRegex(normalizedPattern)
|
|
185
|
+
const files = yield* Ref.get(state)
|
|
186
|
+
const results: Array<string> = []
|
|
187
|
+
for (const path of Object.keys(files).sort(comparePaths)) {
|
|
188
|
+
const depth = path.split("/").length - 1
|
|
189
|
+
if (depth > limits.maxDepth) {
|
|
190
|
+
return yield* WorkspaceLimitError.make({
|
|
191
|
+
operation: "discovery depth",
|
|
192
|
+
limit: limits.maxDepth,
|
|
193
|
+
actual: depth
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
if (matcher.test(path)) {
|
|
197
|
+
results.push(path)
|
|
198
|
+
if (results.length > limits.maxResults) {
|
|
199
|
+
return yield* WorkspaceLimitError.make({
|
|
200
|
+
operation: "discovery results",
|
|
201
|
+
limit: limits.maxResults,
|
|
202
|
+
actual: results.length
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return results
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
const search = (
|
|
211
|
+
query: string,
|
|
212
|
+
pattern = "**/*"
|
|
213
|
+
): Effect.Effect<ReadonlyArray<SearchMatch>, WorkspaceError> =>
|
|
214
|
+
Effect.gen(function* () {
|
|
215
|
+
const paths = yield* discover(pattern)
|
|
216
|
+
const matches: Array<SearchMatch> = []
|
|
217
|
+
for (const path of paths) {
|
|
218
|
+
const text = yield* read(path)
|
|
219
|
+
const lines = text.split("\n")
|
|
220
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
221
|
+
const line = lines[index] ?? ""
|
|
222
|
+
if (line.includes(query)) {
|
|
223
|
+
matches.push(
|
|
224
|
+
SearchMatch.make({
|
|
225
|
+
path,
|
|
226
|
+
line: index + 1,
|
|
227
|
+
text: line
|
|
228
|
+
})
|
|
229
|
+
)
|
|
230
|
+
if (matches.length > limits.maxResults) {
|
|
231
|
+
return yield* WorkspaceLimitError.make({
|
|
232
|
+
operation: "search results",
|
|
233
|
+
limit: limits.maxResults,
|
|
234
|
+
actual: matches.length
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return matches
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
root,
|
|
245
|
+
resolve: (input: string) =>
|
|
246
|
+
resolveKey(input).pipe(Effect.map((key) => (key === "" ? root : `${root}/${key}`))),
|
|
247
|
+
read,
|
|
248
|
+
write: (path: string, contents: string) => writeBounded("write", path, contents),
|
|
249
|
+
append: (path: string, contents: string) => writeBounded("append", path, contents),
|
|
250
|
+
discover,
|
|
251
|
+
search
|
|
252
|
+
}
|
|
253
|
+
})
|
|
254
|
+
)
|
|
255
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { stableHash } from "./Plan.ts"
|
|
2
|
+
|
|
3
|
+
const normalize = (path: string): string => path.replaceAll("\\", "/").replace(/\/+$/, "")
|
|
4
|
+
|
|
5
|
+
const basename = (path: string): string => {
|
|
6
|
+
const parts = normalize(path).split("/")
|
|
7
|
+
return parts.at(-1) || "repo"
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const repoId = (workspace: string, repository: string): string | undefined => {
|
|
11
|
+
const normalizedWorkspace = normalize(workspace)
|
|
12
|
+
const normalizedRepository = normalize(repository)
|
|
13
|
+
return normalizedWorkspace === normalizedRepository
|
|
14
|
+
? undefined
|
|
15
|
+
: `${basename(normalizedRepository)}-${stableHash(normalizedRepository)}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const llm4tsDirectory = (workspace: string, repository: string): string => {
|
|
19
|
+
const root = `${normalize(workspace)}/.llm4ts`
|
|
20
|
+
const id = repoId(workspace, repository)
|
|
21
|
+
return id === undefined ? root : `${root}/${id}`
|
|
22
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect"
|
|
2
|
+
import { ToolExecutionFailed, makeTool, type Tool } from "@llm4ts/core/tools/Tool"
|
|
3
|
+
import { isJsonRecord } from "@llm4ts/core/providers/CliSupport"
|
|
4
|
+
import type { JsonValue } from "@llm4ts/core/providers/CliSupport"
|
|
5
|
+
import type { WorkspaceShape } from "./Workspace.ts"
|
|
6
|
+
|
|
7
|
+
const parameter = (
|
|
8
|
+
parameters: JsonValue,
|
|
9
|
+
name: string
|
|
10
|
+
): Effect.Effect<string, ToolExecutionFailed> => {
|
|
11
|
+
if (!isJsonRecord(parameters)) {
|
|
12
|
+
return Effect.fail(
|
|
13
|
+
ToolExecutionFailed.make({
|
|
14
|
+
message: "tool parameters must be an object"
|
|
15
|
+
})
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
const value = parameters[name]
|
|
19
|
+
return typeof value === "string"
|
|
20
|
+
? Effect.succeed(value)
|
|
21
|
+
: Effect.fail(
|
|
22
|
+
ToolExecutionFailed.make({
|
|
23
|
+
message: `parameter '${name}' must be a string`
|
|
24
|
+
})
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const optionalParameter = (
|
|
29
|
+
parameters: JsonValue,
|
|
30
|
+
name: string
|
|
31
|
+
): Effect.Effect<string | undefined, ToolExecutionFailed> => {
|
|
32
|
+
if (!isJsonRecord(parameters)) {
|
|
33
|
+
return Effect.fail(
|
|
34
|
+
ToolExecutionFailed.make({
|
|
35
|
+
message: "tool parameters must be an object"
|
|
36
|
+
})
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
const value = parameters[name]
|
|
40
|
+
return value === undefined || typeof value === "string"
|
|
41
|
+
? Effect.succeed(value)
|
|
42
|
+
: Effect.fail(
|
|
43
|
+
ToolExecutionFailed.make({
|
|
44
|
+
message: `parameter '${name}' must be a string when supplied`
|
|
45
|
+
})
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const mapWorkspaceError = Effect.mapError((error: { readonly message: string }) =>
|
|
50
|
+
ToolExecutionFailed.make({ message: error.message })
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
const pathSchema = {
|
|
54
|
+
type: "object",
|
|
55
|
+
properties: {
|
|
56
|
+
path: { type: "string" }
|
|
57
|
+
},
|
|
58
|
+
required: ["path"],
|
|
59
|
+
additionalProperties: false
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const contentSchema = {
|
|
63
|
+
type: "object",
|
|
64
|
+
properties: {
|
|
65
|
+
path: { type: "string" },
|
|
66
|
+
contents: { type: "string" }
|
|
67
|
+
},
|
|
68
|
+
required: ["path", "contents"],
|
|
69
|
+
additionalProperties: false
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const workspaceTools = (workspace: WorkspaceShape): ReadonlyArray<Tool> => [
|
|
73
|
+
makeTool({
|
|
74
|
+
name: "workspace_read",
|
|
75
|
+
description: "Read a bounded UTF-8 file under the workspace root",
|
|
76
|
+
parameters: pathSchema,
|
|
77
|
+
tags: ["file", "read", "workspace"],
|
|
78
|
+
sandbox: "WorkspaceReadOnly",
|
|
79
|
+
execute: (parameters) =>
|
|
80
|
+
Effect.all({
|
|
81
|
+
path: parameter(parameters, "path")
|
|
82
|
+
}).pipe(
|
|
83
|
+
Effect.flatMap(({ path }) => workspace.read(path)),
|
|
84
|
+
mapWorkspaceError
|
|
85
|
+
)
|
|
86
|
+
}),
|
|
87
|
+
makeTool({
|
|
88
|
+
name: "workspace_write",
|
|
89
|
+
description: "Write bounded UTF-8 content under the workspace root",
|
|
90
|
+
parameters: contentSchema,
|
|
91
|
+
tags: ["file", "write", "workspace"],
|
|
92
|
+
execute: (parameters) =>
|
|
93
|
+
Effect.all({
|
|
94
|
+
path: parameter(parameters, "path"),
|
|
95
|
+
contents: parameter(parameters, "contents")
|
|
96
|
+
}).pipe(
|
|
97
|
+
Effect.flatMap(({ path, contents }) => workspace.write(path, contents)),
|
|
98
|
+
mapWorkspaceError,
|
|
99
|
+
Effect.as({ written: true })
|
|
100
|
+
)
|
|
101
|
+
}),
|
|
102
|
+
makeTool({
|
|
103
|
+
name: "workspace_append",
|
|
104
|
+
description: "Append bounded UTF-8 content under the workspace root",
|
|
105
|
+
parameters: contentSchema,
|
|
106
|
+
tags: ["file", "append", "workspace"],
|
|
107
|
+
execute: (parameters) =>
|
|
108
|
+
Effect.all({
|
|
109
|
+
path: parameter(parameters, "path"),
|
|
110
|
+
contents: parameter(parameters, "contents")
|
|
111
|
+
}).pipe(
|
|
112
|
+
Effect.flatMap(({ path, contents }) => workspace.append(path, contents)),
|
|
113
|
+
mapWorkspaceError,
|
|
114
|
+
Effect.as({ appended: true })
|
|
115
|
+
)
|
|
116
|
+
}),
|
|
117
|
+
makeTool({
|
|
118
|
+
name: "workspace_discover",
|
|
119
|
+
description: "Discover files under the workspace root using a bounded glob",
|
|
120
|
+
parameters: {
|
|
121
|
+
type: "object",
|
|
122
|
+
properties: {
|
|
123
|
+
pattern: { type: "string" }
|
|
124
|
+
},
|
|
125
|
+
additionalProperties: false
|
|
126
|
+
},
|
|
127
|
+
tags: ["file", "glob", "discover", "workspace"],
|
|
128
|
+
sandbox: "WorkspaceReadOnly",
|
|
129
|
+
execute: (parameters) =>
|
|
130
|
+
optionalParameter(parameters, "pattern").pipe(
|
|
131
|
+
Effect.flatMap((pattern) => workspace.discover(pattern)),
|
|
132
|
+
mapWorkspaceError,
|
|
133
|
+
Effect.map((paths) => [...paths])
|
|
134
|
+
)
|
|
135
|
+
}),
|
|
136
|
+
makeTool({
|
|
137
|
+
name: "workspace_search",
|
|
138
|
+
description: "Search bounded workspace files for literal text",
|
|
139
|
+
parameters: {
|
|
140
|
+
type: "object",
|
|
141
|
+
properties: {
|
|
142
|
+
query: { type: "string" },
|
|
143
|
+
pattern: { type: "string" }
|
|
144
|
+
},
|
|
145
|
+
required: ["query"],
|
|
146
|
+
additionalProperties: false
|
|
147
|
+
},
|
|
148
|
+
tags: ["file", "search", "workspace"],
|
|
149
|
+
sandbox: "WorkspaceReadOnly",
|
|
150
|
+
execute: (parameters) =>
|
|
151
|
+
Effect.all({
|
|
152
|
+
query: parameter(parameters, "query"),
|
|
153
|
+
pattern: optionalParameter(parameters, "pattern")
|
|
154
|
+
}).pipe(
|
|
155
|
+
Effect.flatMap(({ pattern, query }) => workspace.search(query, pattern)),
|
|
156
|
+
mapWorkspaceError,
|
|
157
|
+
Effect.map((matches) =>
|
|
158
|
+
matches.map((match) => ({
|
|
159
|
+
path: match.path,
|
|
160
|
+
line: match.line,
|
|
161
|
+
text: match.text
|
|
162
|
+
}))
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
}),
|
|
166
|
+
makeTool({
|
|
167
|
+
name: "workspace_validate_json",
|
|
168
|
+
description: "Validate that a bounded workspace file contains JSON",
|
|
169
|
+
parameters: pathSchema,
|
|
170
|
+
tags: ["file", "validate", "json", "workspace"],
|
|
171
|
+
sandbox: "WorkspaceReadOnly",
|
|
172
|
+
execute: (parameters) =>
|
|
173
|
+
parameter(parameters, "path").pipe(
|
|
174
|
+
Effect.flatMap((path) => workspace.read(path)),
|
|
175
|
+
mapWorkspaceError,
|
|
176
|
+
Effect.flatMap((contents) =>
|
|
177
|
+
Effect.try({
|
|
178
|
+
try: () => JSON.parse(contents),
|
|
179
|
+
catch: (error) =>
|
|
180
|
+
ToolExecutionFailed.make({
|
|
181
|
+
message: `invalid JSON: ${String(error)}`
|
|
182
|
+
})
|
|
183
|
+
})
|
|
184
|
+
),
|
|
185
|
+
Effect.as({ valid: true })
|
|
186
|
+
)
|
|
187
|
+
})
|
|
188
|
+
]
|