@llm4ts/runner 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 +61 -0
- package/dist/Cli.d.ts +5 -0
- package/dist/Cli.d.ts.map +1 -0
- package/dist/Cli.js +36 -0
- package/dist/Cli.js.map +1 -0
- package/dist/CoderPolicy.d.ts +12 -0
- package/dist/CoderPolicy.d.ts.map +1 -0
- package/dist/CoderPolicy.js +59 -0
- package/dist/CoderPolicy.js.map +1 -0
- package/dist/Connectors.d.ts +28 -0
- package/dist/Connectors.d.ts.map +1 -0
- package/dist/Connectors.js +165 -0
- package/dist/Connectors.js.map +1 -0
- package/dist/Doctor.d.ts +4 -0
- package/dist/Doctor.d.ts.map +1 -0
- package/dist/Doctor.js +33 -0
- package/dist/Doctor.js.map +1 -0
- package/dist/FlowArgs.d.ts +34 -0
- package/dist/FlowArgs.d.ts.map +1 -0
- package/dist/FlowArgs.js +136 -0
- package/dist/FlowArgs.js.map +1 -0
- package/dist/FlowRunner.d.ts +48 -0
- package/dist/FlowRunner.d.ts.map +1 -0
- package/dist/FlowRunner.js +97 -0
- package/dist/FlowRunner.js.map +1 -0
- package/dist/McpStdio.d.ts +7 -0
- package/dist/McpStdio.d.ts.map +1 -0
- package/dist/McpStdio.js +19 -0
- package/dist/McpStdio.js.map +1 -0
- package/dist/NodeGeminiCliExecutor.d.ts +7 -0
- package/dist/NodeGeminiCliExecutor.d.ts.map +1 -0
- package/dist/NodeGeminiCliExecutor.js +26 -0
- package/dist/NodeGeminiCliExecutor.js.map +1 -0
- package/dist/NodeHttpClient.d.ts +5 -0
- package/dist/NodeHttpClient.d.ts.map +1 -0
- package/dist/NodeHttpClient.js +109 -0
- package/dist/NodeHttpClient.js.map +1 -0
- package/dist/NodePlainFileStore.d.ts +5 -0
- package/dist/NodePlainFileStore.d.ts.map +1 -0
- package/dist/NodePlainFileStore.js +64 -0
- package/dist/NodePlainFileStore.js.map +1 -0
- package/dist/NodeProcessExecutor.d.ts +5 -0
- package/dist/NodeProcessExecutor.d.ts.map +1 -0
- package/dist/NodeProcessExecutor.js +212 -0
- package/dist/NodeProcessExecutor.js.map +1 -0
- package/dist/NodeRuntime.d.ts +4 -0
- package/dist/NodeRuntime.d.ts.map +1 -0
- package/dist/NodeRuntime.js +11 -0
- package/dist/NodeRuntime.js.map +1 -0
- package/dist/NodeTemporaryFiles.d.ts +5 -0
- package/dist/NodeTemporaryFiles.d.ts.map +1 -0
- package/dist/NodeTemporaryFiles.js +30 -0
- package/dist/NodeTemporaryFiles.js.map +1 -0
- package/dist/NodeWorkspace.d.ts +6 -0
- package/dist/NodeWorkspace.d.ts.map +1 -0
- package/dist/NodeWorkspace.js +231 -0
- package/dist/NodeWorkspace.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/ReviewFingerprint.d.ts +2 -0
- package/dist/ReviewFingerprint.d.ts.map +1 -0
- package/dist/ReviewFingerprint.js +13 -0
- package/dist/ReviewFingerprint.js.map +1 -0
- package/dist/Terminal.d.ts +46 -0
- package/dist/Terminal.d.ts.map +1 -0
- package/dist/Terminal.js +226 -0
- package/dist/Terminal.js.map +1 -0
- package/dist/TerminalInteraction.d.ts +4 -0
- package/dist/TerminalInteraction.d.ts.map +1 -0
- package/dist/TerminalInteraction.js +25 -0
- package/dist/TerminalInteraction.js.map +1 -0
- package/dist/cli-main.d.ts +3 -0
- package/dist/cli-main.d.ts.map +1 -0
- package/dist/cli-main.js +39 -0
- package/dist/cli-main.js.map +1 -0
- package/package.json +74 -0
- package/src/Cli.ts +51 -0
- package/src/CoderPolicy.ts +82 -0
- package/src/Connectors.ts +225 -0
- package/src/Doctor.ts +44 -0
- package/src/FlowArgs.ts +167 -0
- package/src/FlowRunner.ts +220 -0
- package/src/McpStdio.ts +41 -0
- package/src/NodeGeminiCliExecutor.ts +56 -0
- package/src/NodeHttpClient.ts +224 -0
- package/src/NodePlainFileStore.ts +75 -0
- package/src/NodeProcessExecutor.ts +331 -0
- package/src/NodeRuntime.ts +19 -0
- package/src/NodeTemporaryFiles.ts +46 -0
- package/src/NodeWorkspace.ts +282 -0
- package/src/Package.ts +1 -0
- package/src/ReviewFingerprint.ts +13 -0
- package/src/Terminal.ts +329 -0
- package/src/TerminalInteraction.ts +43 -0
- package/src/cli-main.ts +53 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect"
|
|
2
|
+
import * as Layer from "effect/Layer"
|
|
3
|
+
import * as Stream from "effect/Stream"
|
|
4
|
+
import { ProviderError } from "@llm4ts/core/Errors"
|
|
5
|
+
import type { ProcessExecutorShape } from "@llm4ts/core/ProcessExecutor"
|
|
6
|
+
import {
|
|
7
|
+
GeminiCliExecutor,
|
|
8
|
+
buildGeminiArgs,
|
|
9
|
+
extractGeminiCliResponse,
|
|
10
|
+
geminiProcessEnv,
|
|
11
|
+
parseGeminiCliStreamEvent,
|
|
12
|
+
validateGeminiExitCode,
|
|
13
|
+
type GeminiCliExecutorShape
|
|
14
|
+
} from "@llm4ts/core/providers/GeminiCliProvider"
|
|
15
|
+
import { nodeProcessExecutor } from "./NodeProcessExecutor.ts"
|
|
16
|
+
|
|
17
|
+
const failure = (message: string): ProviderError => ProviderError.make({ message })
|
|
18
|
+
|
|
19
|
+
export const makeNodeGeminiCliExecutor = (
|
|
20
|
+
executor: ProcessExecutorShape
|
|
21
|
+
): GeminiCliExecutorShape => ({
|
|
22
|
+
checkGeminiInstalled: executor
|
|
23
|
+
.run(["gemini", "--version"], process.cwd(), {})
|
|
24
|
+
.pipe(
|
|
25
|
+
Effect.flatMap((result) =>
|
|
26
|
+
result.exitCode === 0
|
|
27
|
+
? Effect.void
|
|
28
|
+
: Effect.fail(failure(`Gemini CLI is unavailable: ${result.stderr.join("\n")}`))
|
|
29
|
+
)
|
|
30
|
+
),
|
|
31
|
+
runGeminiProcess: (prompt, config, context) =>
|
|
32
|
+
Effect.gen(function* () {
|
|
33
|
+
const result = yield* executor.runWithStdin(
|
|
34
|
+
["gemini", ...buildGeminiArgs(config, context, "json")],
|
|
35
|
+
context.cwd ?? process.cwd(),
|
|
36
|
+
geminiProcessEnv(context),
|
|
37
|
+
prompt
|
|
38
|
+
)
|
|
39
|
+
yield* validateGeminiExitCode(result.exitCode, result.stderr.join("\n"), context.turnLimit)
|
|
40
|
+
const extracted = extractGeminiCliResponse(result.stdout.join("\n"))
|
|
41
|
+
return extracted.ok ? extracted.value : yield* failure(extracted.error)
|
|
42
|
+
}),
|
|
43
|
+
runGeminiProcessStream: (prompt, config, context) =>
|
|
44
|
+
executor
|
|
45
|
+
.runStreamingWithStdin(
|
|
46
|
+
["gemini", ...buildGeminiArgs(config, context, "stream-json")],
|
|
47
|
+
context.cwd ?? process.cwd(),
|
|
48
|
+
geminiProcessEnv(context),
|
|
49
|
+
prompt
|
|
50
|
+
)
|
|
51
|
+
.pipe(Stream.map(parseGeminiCliStreamEvent))
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
export const nodeGeminiCliExecutor = makeNodeGeminiCliExecutor(nodeProcessExecutor)
|
|
55
|
+
|
|
56
|
+
export const NodeGeminiCliExecutorLive = Layer.succeed(GeminiCliExecutor, nodeGeminiCliExecutor)
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import * as Duration from "effect/Duration"
|
|
2
|
+
import * as Effect from "effect/Effect"
|
|
3
|
+
import * as Layer from "effect/Layer"
|
|
4
|
+
import * as Stream from "effect/Stream"
|
|
5
|
+
import { HttpClient, makeHttpClient, type HttpClientShape } from "@llm4ts/core/HttpClient"
|
|
6
|
+
import {
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
InvalidRequestError,
|
|
9
|
+
ParseError,
|
|
10
|
+
ProviderError,
|
|
11
|
+
RateLimitError,
|
|
12
|
+
TimeoutError,
|
|
13
|
+
type LlmError
|
|
14
|
+
} from "@llm4ts/core/Errors"
|
|
15
|
+
|
|
16
|
+
const errorMessage = (error: unknown): string =>
|
|
17
|
+
error instanceof Error ? error.message : String(error)
|
|
18
|
+
|
|
19
|
+
const checkedUrl = (raw: string): Effect.Effect<string, InvalidRequestError> =>
|
|
20
|
+
Effect.try({
|
|
21
|
+
try: () => new URL(raw).toString(),
|
|
22
|
+
catch: (error) =>
|
|
23
|
+
InvalidRequestError.make({
|
|
24
|
+
message: `Invalid URL '${raw}': ${errorMessage(error)}`
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const fetchResponse = (
|
|
29
|
+
rawUrl: string,
|
|
30
|
+
init: RequestInit,
|
|
31
|
+
timeout: Duration.Duration
|
|
32
|
+
): Effect.Effect<Response, LlmError> =>
|
|
33
|
+
Effect.flatMap(checkedUrl(rawUrl), (url) =>
|
|
34
|
+
Effect.tryPromise({
|
|
35
|
+
try: (signal) =>
|
|
36
|
+
fetch(url, {
|
|
37
|
+
...init,
|
|
38
|
+
signal
|
|
39
|
+
}),
|
|
40
|
+
catch: (error) =>
|
|
41
|
+
ProviderError.make({
|
|
42
|
+
message: `Provider unavailable: ${rawUrl}`,
|
|
43
|
+
cause: error
|
|
44
|
+
})
|
|
45
|
+
}).pipe(
|
|
46
|
+
Effect.timeoutOrElse({
|
|
47
|
+
duration: timeout,
|
|
48
|
+
orElse: () =>
|
|
49
|
+
Effect.fail(
|
|
50
|
+
TimeoutError.make({
|
|
51
|
+
duration: timeout
|
|
52
|
+
})
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
const responseText = (response: Response): Effect.Effect<string, ParseError> =>
|
|
59
|
+
Effect.tryPromise({
|
|
60
|
+
try: () => response.text(),
|
|
61
|
+
catch: (error) =>
|
|
62
|
+
ParseError.make({
|
|
63
|
+
message: errorMessage(error),
|
|
64
|
+
raw: ""
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const retryAfter = (response: Response, fallback: Duration.Duration): Duration.Duration => {
|
|
69
|
+
const value = response.headers.get("Retry-After")?.trim()
|
|
70
|
+
return value !== undefined && /^\d+$/.test(value) ? Duration.seconds(Number(value)) : fallback
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const classifyBodyResponse = (
|
|
74
|
+
response: Response,
|
|
75
|
+
url: string,
|
|
76
|
+
timeout: Duration.Duration,
|
|
77
|
+
accepts: (status: number) => boolean
|
|
78
|
+
): Effect.Effect<string, LlmError> =>
|
|
79
|
+
Effect.flatMap(responseText(response), (body): Effect.Effect<string, LlmError> => {
|
|
80
|
+
const status = response.status
|
|
81
|
+
if (accepts(status)) {
|
|
82
|
+
return Effect.succeed(body)
|
|
83
|
+
}
|
|
84
|
+
if (status === 401 || status === 403) {
|
|
85
|
+
return Effect.fail(AuthenticationError.make({ message: url }))
|
|
86
|
+
}
|
|
87
|
+
if (status === 429) {
|
|
88
|
+
return Effect.fail(
|
|
89
|
+
RateLimitError.make({
|
|
90
|
+
retryAfter: retryAfter(response, timeout)
|
|
91
|
+
})
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
if (status >= 400 && status < 500) {
|
|
95
|
+
return Effect.fail(
|
|
96
|
+
InvalidRequestError.make({
|
|
97
|
+
message: `HTTP ${status}: ${body}`
|
|
98
|
+
})
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
return Effect.fail(
|
|
102
|
+
ProviderError.make({
|
|
103
|
+
message: `HTTP ${status}: ${body}`
|
|
104
|
+
})
|
|
105
|
+
)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const requestHeaders = (
|
|
109
|
+
headers: Readonly<Record<string, string>>,
|
|
110
|
+
contentType: string | undefined
|
|
111
|
+
): Headers => {
|
|
112
|
+
const result = new Headers(headers)
|
|
113
|
+
if (contentType !== undefined && !result.has("Content-Type")) {
|
|
114
|
+
result.set("Content-Type", contentType)
|
|
115
|
+
}
|
|
116
|
+
return result
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const requestBody = (
|
|
120
|
+
method: string,
|
|
121
|
+
url: string,
|
|
122
|
+
body: string | undefined,
|
|
123
|
+
headers: Readonly<Record<string, string>>,
|
|
124
|
+
contentType: string | undefined,
|
|
125
|
+
timeout: Duration.Duration,
|
|
126
|
+
accepts: (status: number) => boolean
|
|
127
|
+
): Effect.Effect<string, LlmError> =>
|
|
128
|
+
Effect.flatMap(
|
|
129
|
+
fetchResponse(
|
|
130
|
+
url,
|
|
131
|
+
{
|
|
132
|
+
method,
|
|
133
|
+
headers: requestHeaders(headers, contentType),
|
|
134
|
+
...(body === undefined ? {} : { body })
|
|
135
|
+
},
|
|
136
|
+
timeout
|
|
137
|
+
),
|
|
138
|
+
(response) => classifyBodyResponse(response, url, timeout, accepts)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
const streamBody = (
|
|
142
|
+
response: Response,
|
|
143
|
+
url: string,
|
|
144
|
+
timeout: Duration.Duration
|
|
145
|
+
): Stream.Stream<string, LlmError> => {
|
|
146
|
+
if (response.status !== 200) {
|
|
147
|
+
return Stream.fromEffect(
|
|
148
|
+
classifyBodyResponse(response, url, timeout, (status) => status === 200)
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
const body = response.body
|
|
152
|
+
if (body === null) {
|
|
153
|
+
return Stream.fail(
|
|
154
|
+
ProviderError.make({
|
|
155
|
+
message: `Provider returned no streaming body: ${url}`
|
|
156
|
+
})
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return Stream.fromReadableStream({
|
|
161
|
+
evaluate: () => body,
|
|
162
|
+
onError: (error) =>
|
|
163
|
+
ProviderError.make({
|
|
164
|
+
message: `Failed to read streaming response from ${url}`,
|
|
165
|
+
cause: error
|
|
166
|
+
})
|
|
167
|
+
}).pipe(
|
|
168
|
+
Stream.decodeText,
|
|
169
|
+
Stream.splitLines,
|
|
170
|
+
Stream.timeoutOrElse({
|
|
171
|
+
duration: timeout,
|
|
172
|
+
orElse: () =>
|
|
173
|
+
Stream.fail(
|
|
174
|
+
TimeoutError.make({
|
|
175
|
+
duration: timeout
|
|
176
|
+
})
|
|
177
|
+
)
|
|
178
|
+
})
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const postJsonStream: HttpClientShape["postJsonStream"] = (url, body, headers, timeout) =>
|
|
183
|
+
Stream.unwrap(
|
|
184
|
+
Effect.map(
|
|
185
|
+
fetchResponse(
|
|
186
|
+
url,
|
|
187
|
+
{
|
|
188
|
+
method: "POST",
|
|
189
|
+
headers: requestHeaders(headers, "application/json"),
|
|
190
|
+
body
|
|
191
|
+
},
|
|
192
|
+
timeout
|
|
193
|
+
),
|
|
194
|
+
(response) => streamBody(response, url, timeout)
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
export const nodeHttpClient: HttpClientShape = makeHttpClient({
|
|
199
|
+
get: (url, headers, timeout) =>
|
|
200
|
+
requestBody("GET", url, undefined, headers, undefined, timeout, (status) => status === 200),
|
|
201
|
+
postJson: (url, body, headers, timeout) =>
|
|
202
|
+
requestBody(
|
|
203
|
+
"POST",
|
|
204
|
+
url,
|
|
205
|
+
body,
|
|
206
|
+
headers,
|
|
207
|
+
"application/json",
|
|
208
|
+
timeout,
|
|
209
|
+
(status) => status === 200
|
|
210
|
+
),
|
|
211
|
+
send: (method, url, body, headers, contentType, timeout) =>
|
|
212
|
+
requestBody(
|
|
213
|
+
method,
|
|
214
|
+
url,
|
|
215
|
+
body,
|
|
216
|
+
headers,
|
|
217
|
+
contentType,
|
|
218
|
+
timeout,
|
|
219
|
+
(status) => status >= 200 && status < 300
|
|
220
|
+
),
|
|
221
|
+
postJsonStream
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
export const NodeHttpClientLive = Layer.succeed(HttpClient)(nodeHttpClient)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto"
|
|
2
|
+
import { appendFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"
|
|
3
|
+
import { dirname, join } from "node:path"
|
|
4
|
+
import * as Effect from "effect/Effect"
|
|
5
|
+
import * as Layer from "effect/Layer"
|
|
6
|
+
import { PlainFileStore, type PlainFileStoreShape } from "@llm4ts/flow/Persistence"
|
|
7
|
+
import { PersistenceError } from "@llm4ts/flow/FlowError"
|
|
8
|
+
|
|
9
|
+
const message = (error: unknown): string => (error instanceof Error ? error.message : String(error))
|
|
10
|
+
|
|
11
|
+
const persistenceFailure =
|
|
12
|
+
(operation: string, path: string) =>
|
|
13
|
+
(error: unknown): PersistenceError =>
|
|
14
|
+
PersistenceError.make({
|
|
15
|
+
message: `failed to ${operation} ${path}: ${message(error)}`,
|
|
16
|
+
cause: error
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const ensureParent = async (path: string): Promise<void> => {
|
|
20
|
+
await mkdir(dirname(path), { recursive: true })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const nodePlainFileStore: PlainFileStoreShape = {
|
|
24
|
+
read: (path) =>
|
|
25
|
+
Effect.tryPromise({
|
|
26
|
+
try: async () => {
|
|
27
|
+
try {
|
|
28
|
+
return await readFile(path, "utf8")
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
31
|
+
return undefined
|
|
32
|
+
}
|
|
33
|
+
throw error
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
catch: persistenceFailure("read", path)
|
|
37
|
+
}),
|
|
38
|
+
writeAtomic: (path, contents) =>
|
|
39
|
+
Effect.tryPromise({
|
|
40
|
+
try: async () => {
|
|
41
|
+
await ensureParent(path)
|
|
42
|
+
const temporary = join(dirname(path), `.${randomUUID()}.llm4ts-tmp`)
|
|
43
|
+
try {
|
|
44
|
+
await writeFile(temporary, contents, "utf8")
|
|
45
|
+
await rename(temporary, path)
|
|
46
|
+
} finally {
|
|
47
|
+
await rm(temporary, { force: true })
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
catch: persistenceFailure("write", path)
|
|
51
|
+
}),
|
|
52
|
+
append: (path, contents) =>
|
|
53
|
+
Effect.tryPromise({
|
|
54
|
+
try: async () => {
|
|
55
|
+
await ensureParent(path)
|
|
56
|
+
await appendFile(path, contents, "utf8")
|
|
57
|
+
},
|
|
58
|
+
catch: persistenceFailure("append", path)
|
|
59
|
+
}),
|
|
60
|
+
remove: (path) =>
|
|
61
|
+
Effect.tryPromise({
|
|
62
|
+
try: () => rm(path, { force: true }),
|
|
63
|
+
catch: persistenceFailure("remove", path)
|
|
64
|
+
}),
|
|
65
|
+
hashSha256: (path) =>
|
|
66
|
+
Effect.tryPromise({
|
|
67
|
+
try: async () =>
|
|
68
|
+
createHash("sha256")
|
|
69
|
+
.update(await readFile(path))
|
|
70
|
+
.digest("hex"),
|
|
71
|
+
catch: persistenceFailure("hash", path)
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const NodePlainFileStoreLive = Layer.succeed(PlainFileStore)(nodePlainFileStore)
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"
|
|
2
|
+
import * as readline from "node:readline"
|
|
3
|
+
import * as Cause from "effect/Cause"
|
|
4
|
+
import * as Effect from "effect/Effect"
|
|
5
|
+
import * as Layer from "effect/Layer"
|
|
6
|
+
import * as Queue from "effect/Queue"
|
|
7
|
+
import * as Stream from "effect/Stream"
|
|
8
|
+
import {
|
|
9
|
+
ProcessExecutor,
|
|
10
|
+
ProcessResult,
|
|
11
|
+
makeProcessExecutor,
|
|
12
|
+
type BidirectionalProcess,
|
|
13
|
+
type ProcessExecutorShape
|
|
14
|
+
} from "@llm4ts/core/ProcessExecutor"
|
|
15
|
+
import { InvalidRequestError, ProviderError, type LlmError } from "@llm4ts/core/Errors"
|
|
16
|
+
|
|
17
|
+
const maxStderrLines = 256
|
|
18
|
+
|
|
19
|
+
const errorMessage = (error: unknown): string =>
|
|
20
|
+
error instanceof Error ? error.message : String(error)
|
|
21
|
+
|
|
22
|
+
const commandName = (argv: ReadonlyArray<string>): string => argv[0] ?? "process"
|
|
23
|
+
|
|
24
|
+
const invalidArgv = (): InvalidRequestError => InvalidRequestError.make({ message: "empty argv" })
|
|
25
|
+
|
|
26
|
+
const processFailure = (argv: ReadonlyArray<string>, detail: string): ProviderError =>
|
|
27
|
+
ProviderError.make({
|
|
28
|
+
message: detail.includes("ENOENT")
|
|
29
|
+
? `${commandName(argv)} failed: command not found. Install '${commandName(argv)}' and make sure it is on your PATH` +
|
|
30
|
+
` (coding-agent CLIs can be switched with LLM4TS_CODER)`
|
|
31
|
+
: `${commandName(argv)} failed: ${detail}`
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const spawnChild = (
|
|
35
|
+
argv: ReadonlyArray<string>,
|
|
36
|
+
cwd: string,
|
|
37
|
+
envVars: Readonly<Record<string, string>>
|
|
38
|
+
): Effect.Effect<ChildProcessWithoutNullStreams, ProviderError> => {
|
|
39
|
+
const [command, ...args] = argv
|
|
40
|
+
if (command === undefined) {
|
|
41
|
+
return Effect.fail(
|
|
42
|
+
ProviderError.make({
|
|
43
|
+
message: "process failed: empty argv"
|
|
44
|
+
})
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return Effect.try({
|
|
49
|
+
try: () =>
|
|
50
|
+
spawn(command, args, {
|
|
51
|
+
cwd,
|
|
52
|
+
env: {
|
|
53
|
+
...process.env,
|
|
54
|
+
...envVars
|
|
55
|
+
},
|
|
56
|
+
stdio: "pipe"
|
|
57
|
+
}),
|
|
58
|
+
catch: (error) => processFailure(argv, errorMessage(error))
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const killChild = (child: ChildProcessWithoutNullStreams): Effect.Effect<void> =>
|
|
63
|
+
Effect.sync(() => {
|
|
64
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
65
|
+
child.kill("SIGKILL")
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const writeAndCloseStdin = (
|
|
70
|
+
child: ChildProcessWithoutNullStreams,
|
|
71
|
+
stdin: string | undefined
|
|
72
|
+
): void => {
|
|
73
|
+
if (stdin === undefined) {
|
|
74
|
+
child.stdin.end()
|
|
75
|
+
} else {
|
|
76
|
+
child.stdin.end(stdin)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const awaitCapturedProcess = (
|
|
81
|
+
child: ChildProcessWithoutNullStreams,
|
|
82
|
+
argv: ReadonlyArray<string>,
|
|
83
|
+
stdin: string | undefined
|
|
84
|
+
): Effect.Effect<ProcessResult, ProviderError> =>
|
|
85
|
+
Effect.callback((resume) => {
|
|
86
|
+
const stdout: Array<string> = []
|
|
87
|
+
const stderr: Array<string> = []
|
|
88
|
+
const outLines = readline.createInterface({ input: child.stdout })
|
|
89
|
+
const errLines = readline.createInterface({ input: child.stderr })
|
|
90
|
+
|
|
91
|
+
const onStdout = (line: string): void => {
|
|
92
|
+
stdout.push(line)
|
|
93
|
+
}
|
|
94
|
+
const onStderr = (line: string): void => {
|
|
95
|
+
stderr.push(line)
|
|
96
|
+
}
|
|
97
|
+
const onError = (error: Error): void => {
|
|
98
|
+
resume(Effect.fail(processFailure(argv, error.message)))
|
|
99
|
+
}
|
|
100
|
+
const onClose = (exitCode: number | null): void => {
|
|
101
|
+
resume(
|
|
102
|
+
Effect.succeed(
|
|
103
|
+
ProcessResult.make({
|
|
104
|
+
stdout,
|
|
105
|
+
stderr,
|
|
106
|
+
exitCode: exitCode ?? -1
|
|
107
|
+
})
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
outLines.on("line", onStdout)
|
|
113
|
+
errLines.on("line", onStderr)
|
|
114
|
+
child.once("error", onError)
|
|
115
|
+
child.once("close", onClose)
|
|
116
|
+
writeAndCloseStdin(child, stdin)
|
|
117
|
+
|
|
118
|
+
return Effect.sync(() => {
|
|
119
|
+
child.off("error", onError)
|
|
120
|
+
child.off("close", onClose)
|
|
121
|
+
outLines.close()
|
|
122
|
+
errLines.close()
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
const runCaptured = (
|
|
127
|
+
argv: ReadonlyArray<string>,
|
|
128
|
+
cwd: string,
|
|
129
|
+
envVars: Readonly<Record<string, string>>,
|
|
130
|
+
stdin: string | undefined
|
|
131
|
+
): Effect.Effect<ProcessResult, LlmError> => {
|
|
132
|
+
if (argv.length === 0) {
|
|
133
|
+
return Effect.fail(invalidArgv())
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return Effect.acquireUseRelease(
|
|
137
|
+
spawnChild(argv, cwd, envVars),
|
|
138
|
+
(child) => awaitCapturedProcess(child, argv, stdin),
|
|
139
|
+
(child) => killChild(child)
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
interface StreamOptions {
|
|
144
|
+
readonly stdin: string | undefined
|
|
145
|
+
readonly killOnClose: boolean
|
|
146
|
+
readonly failOnNonZero: boolean
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const childLines = (
|
|
150
|
+
child: ChildProcessWithoutNullStreams,
|
|
151
|
+
argv: ReadonlyArray<string>,
|
|
152
|
+
options: StreamOptions
|
|
153
|
+
): Stream.Stream<string, LlmError> =>
|
|
154
|
+
Stream.callback<string, LlmError>((queue) =>
|
|
155
|
+
Effect.gen(function* () {
|
|
156
|
+
const stderr: Array<string> = []
|
|
157
|
+
const outLines = readline.createInterface({ input: child.stdout })
|
|
158
|
+
const errLines = readline.createInterface({ input: child.stderr })
|
|
159
|
+
|
|
160
|
+
const onStdout = (line: string): void => {
|
|
161
|
+
Queue.offerUnsafe(queue, line)
|
|
162
|
+
}
|
|
163
|
+
const onStderr = (line: string): void => {
|
|
164
|
+
if (stderr.length < maxStderrLines) {
|
|
165
|
+
stderr.push(line)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const onError = (error: Error): void => {
|
|
169
|
+
Queue.failCauseUnsafe(queue, Cause.fail(processFailure(argv, error.message)))
|
|
170
|
+
}
|
|
171
|
+
const onClose = (exitCode: number | null): void => {
|
|
172
|
+
if (options.failOnNonZero && exitCode !== 0) {
|
|
173
|
+
const detail = `${commandName(argv)} exited with code ${exitCode ?? -1}${
|
|
174
|
+
stderr.length === 0 ? "" : `: ${stderr.join("\n")}`
|
|
175
|
+
}`
|
|
176
|
+
Queue.failCauseUnsafe(queue, Cause.fail(processFailure(argv, detail)))
|
|
177
|
+
} else {
|
|
178
|
+
Queue.endUnsafe(queue)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
outLines.on("line", onStdout)
|
|
183
|
+
errLines.on("line", onStderr)
|
|
184
|
+
child.once("error", onError)
|
|
185
|
+
child.once("close", onClose)
|
|
186
|
+
|
|
187
|
+
yield* Effect.addFinalizer(() =>
|
|
188
|
+
Effect.sync(() => {
|
|
189
|
+
child.off("error", onError)
|
|
190
|
+
child.off("close", onClose)
|
|
191
|
+
outLines.close()
|
|
192
|
+
errLines.close()
|
|
193
|
+
}).pipe(Effect.andThen(options.killOnClose ? killChild(child) : Effect.void))
|
|
194
|
+
)
|
|
195
|
+
})
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
const runStreaming = (
|
|
199
|
+
argv: ReadonlyArray<string>,
|
|
200
|
+
cwd: string,
|
|
201
|
+
envVars: Readonly<Record<string, string>>,
|
|
202
|
+
stdin: string | undefined
|
|
203
|
+
): Stream.Stream<string, LlmError> => {
|
|
204
|
+
if (argv.length === 0) {
|
|
205
|
+
return Stream.fail(invalidArgv())
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return Stream.callback<string, LlmError>((queue) =>
|
|
209
|
+
Effect.gen(function* () {
|
|
210
|
+
const child = yield* Effect.acquireRelease(spawnChild(argv, cwd, envVars), (process) =>
|
|
211
|
+
killChild(process)
|
|
212
|
+
)
|
|
213
|
+
const stderr: Array<string> = []
|
|
214
|
+
const outLines = readline.createInterface({ input: child.stdout })
|
|
215
|
+
const errLines = readline.createInterface({ input: child.stderr })
|
|
216
|
+
|
|
217
|
+
const onStdout = (line: string): void => {
|
|
218
|
+
Queue.offerUnsafe(queue, line)
|
|
219
|
+
}
|
|
220
|
+
const onStderr = (line: string): void => {
|
|
221
|
+
if (stderr.length < maxStderrLines) {
|
|
222
|
+
stderr.push(line)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const onError = (error: Error): void => {
|
|
226
|
+
Queue.failCauseUnsafe(queue, Cause.fail(processFailure(argv, error.message)))
|
|
227
|
+
}
|
|
228
|
+
const onClose = (exitCode: number | null): void => {
|
|
229
|
+
if (exitCode === 0) {
|
|
230
|
+
Queue.endUnsafe(queue)
|
|
231
|
+
} else {
|
|
232
|
+
const detail = `${commandName(argv)} exited with code ${exitCode ?? -1}${
|
|
233
|
+
stderr.length === 0 ? "" : `: ${stderr.join("\n")}`
|
|
234
|
+
}`
|
|
235
|
+
Queue.failCauseUnsafe(queue, Cause.fail(processFailure(argv, detail)))
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
outLines.on("line", onStdout)
|
|
240
|
+
errLines.on("line", onStderr)
|
|
241
|
+
child.once("error", onError)
|
|
242
|
+
child.once("close", onClose)
|
|
243
|
+
writeAndCloseStdin(child, stdin)
|
|
244
|
+
|
|
245
|
+
yield* Effect.addFinalizer(() =>
|
|
246
|
+
Effect.sync(() => {
|
|
247
|
+
child.off("error", onError)
|
|
248
|
+
child.off("close", onClose)
|
|
249
|
+
outLines.close()
|
|
250
|
+
errLines.close()
|
|
251
|
+
})
|
|
252
|
+
)
|
|
253
|
+
})
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const writeLine = (
|
|
258
|
+
child: ChildProcessWithoutNullStreams,
|
|
259
|
+
argv: ReadonlyArray<string>,
|
|
260
|
+
line: string
|
|
261
|
+
): Effect.Effect<void, ProviderError> =>
|
|
262
|
+
Effect.callback((resume) => {
|
|
263
|
+
const onError = (error: Error): void => {
|
|
264
|
+
resume(Effect.fail(processFailure(argv, error.message)))
|
|
265
|
+
}
|
|
266
|
+
child.stdin.once("error", onError)
|
|
267
|
+
child.stdin.write(`${line}\n`, (error) => {
|
|
268
|
+
child.stdin.off("error", onError)
|
|
269
|
+
resume(
|
|
270
|
+
error === null || error === undefined
|
|
271
|
+
? Effect.void
|
|
272
|
+
: Effect.fail(processFailure(argv, error.message))
|
|
273
|
+
)
|
|
274
|
+
})
|
|
275
|
+
return Effect.sync(() => {
|
|
276
|
+
child.stdin.off("error", onError)
|
|
277
|
+
})
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
const bidirectionalBundle = (
|
|
281
|
+
stdin: Queue.Queue<string>,
|
|
282
|
+
stdout: Stream.Stream<string, LlmError>
|
|
283
|
+
): BidirectionalProcess => [stdin, stdout]
|
|
284
|
+
|
|
285
|
+
const runBidirectional: ProcessExecutorShape["runBidirectional"] = (argv, cwd, envVars) => {
|
|
286
|
+
if (argv.length === 0) {
|
|
287
|
+
return Effect.fail(invalidArgv())
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return Effect.gen(function* () {
|
|
291
|
+
const input = yield* Effect.acquireRelease(Queue.unbounded<string>(), (queue) =>
|
|
292
|
+
Queue.shutdown(queue).pipe(Effect.asVoid)
|
|
293
|
+
)
|
|
294
|
+
const child = yield* Effect.acquireRelease(spawnChild(argv, cwd, envVars), (process) =>
|
|
295
|
+
killChild(process)
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
const pump = Effect.suspend(function loop(): Effect.Effect<void, ProviderError> {
|
|
299
|
+
return Effect.flatMap(Queue.take(input), (line) =>
|
|
300
|
+
writeLine(child, argv, line).pipe(Effect.andThen(Effect.suspend(loop)))
|
|
301
|
+
)
|
|
302
|
+
}).pipe(
|
|
303
|
+
Effect.ensuring(
|
|
304
|
+
Effect.sync(() => {
|
|
305
|
+
child.stdin.end()
|
|
306
|
+
})
|
|
307
|
+
),
|
|
308
|
+
Effect.catchCause(() => Effect.void)
|
|
309
|
+
)
|
|
310
|
+
yield* Effect.forkScoped(pump)
|
|
311
|
+
|
|
312
|
+
return bidirectionalBundle(
|
|
313
|
+
input,
|
|
314
|
+
childLines(child, argv, {
|
|
315
|
+
stdin: undefined,
|
|
316
|
+
killOnClose: false,
|
|
317
|
+
failOnNonZero: false
|
|
318
|
+
})
|
|
319
|
+
)
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export const nodeProcessExecutor: ProcessExecutorShape = makeProcessExecutor({
|
|
324
|
+
run: (argv, cwd, envVars) => runCaptured(argv, cwd, envVars, undefined),
|
|
325
|
+
runStreaming: (argv, cwd, envVars) => runStreaming(argv, cwd, envVars, undefined),
|
|
326
|
+
runWithStdin: (argv, cwd, envVars, stdin) => runCaptured(argv, cwd, envVars, stdin),
|
|
327
|
+
runStreamingWithStdin: (argv, cwd, envVars, stdin) => runStreaming(argv, cwd, envVars, stdin),
|
|
328
|
+
runBidirectional
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
export const NodeProcessExecutorLive = Layer.succeed(ProcessExecutor)(nodeProcessExecutor)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import * as Layer from "effect/Layer"
|
|
2
|
+
import { ConnectorRegistryLive } from "@llm4ts/core/providers/ConnectorFactories"
|
|
3
|
+
import { NodeHttpClientLive } from "./NodeHttpClient.ts"
|
|
4
|
+
import { NodeGeminiCliExecutorLive } from "./NodeGeminiCliExecutor.ts"
|
|
5
|
+
import { NodePlainFileStoreLive } from "./NodePlainFileStore.ts"
|
|
6
|
+
import { NodeProcessExecutorLive } from "./NodeProcessExecutor.ts"
|
|
7
|
+
import { NodeTemporaryFilesLive } from "./NodeTemporaryFiles.ts"
|
|
8
|
+
|
|
9
|
+
export const NodeBoundaryLayers = Layer.mergeAll(
|
|
10
|
+
NodeHttpClientLive,
|
|
11
|
+
NodeProcessExecutorLive,
|
|
12
|
+
NodeTemporaryFilesLive,
|
|
13
|
+
NodeGeminiCliExecutorLive,
|
|
14
|
+
NodePlainFileStoreLive
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
const NodeConnectorRegistryLive = ConnectorRegistryLive.pipe(Layer.provide(NodeBoundaryLayers))
|
|
18
|
+
|
|
19
|
+
export const NodeRuntimeLive = Layer.merge(NodeBoundaryLayers, NodeConnectorRegistryLive)
|