@llm4ts/core 2.40.0 → 2.41.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.
@@ -2,10 +2,11 @@ import * as Effect from "effect/Effect"
2
2
  import * as Stream from "effect/Stream"
3
3
  import { makeCliConnector, type CliConnectorShape } from "../Connector.ts"
4
4
  import type { CliConnectorConfig } from "../ConnectorConfig.ts"
5
- import { ProviderError } from "../Errors.ts"
5
+ import { InvalidRequestError, ProviderError, type LlmError } from "../Errors.ts"
6
6
  import { ConnectorCapabilities, ConnectorIds, LlmChunk, TokenUsage } from "../Models.ts"
7
7
  import type { ProcessExecutorShape } from "../ProcessExecutor.ts"
8
8
  import {
9
+ atLeastVersion,
9
10
  effortWord,
10
11
  cumulativeUsage,
11
12
  failClassifiedCliError,
@@ -19,9 +20,41 @@ import {
19
20
  toolEventChunk,
20
21
  toolResultChunk,
21
22
  toolResultText,
22
- usageEventChunk
23
+ usageEventChunk,
24
+ versionTriple,
25
+ statusChunk
23
26
  } from "./CliSupport.ts"
24
27
 
28
+ /**
29
+ * The first pi whose `--tools` allowlist can be kept free of MCP tools: from
30
+ * 1.0.4 `--tools read` keeps every MCP tool the user configured in
31
+ * `~/.pi/agent/mcp.json` unless `--no-mcp` (new in that release) drops them,
32
+ * and MCP tools are built in since 0.99.0. Below this a read-only seat can
33
+ * reach any MCP write tool, so llm4ts refuses it (ADR 0010, ADR 0035).
34
+ */
35
+ export const piReadOnlyFloor = "1.0.4"
36
+
37
+ /** Why this pi cannot hold a read-only seat, or nothing when it can. */
38
+ export const piReadOnlyProblem = (versionText: string): string | undefined => {
39
+ const version = versionTriple(versionText)
40
+ const floor = versionTriple(piReadOnlyFloor) ?? []
41
+ if (version === undefined) {
42
+ return (
43
+ `could not read the pi version from '${versionText.trim()}'; a read-only seat needs ` +
44
+ `pi >= ${piReadOnlyFloor}, whose --no-mcp keeps MCP tools out of the read allowlist`
45
+ )
46
+ }
47
+ return atLeastVersion(version, floor)
48
+ ? undefined
49
+ : `pi ${version.join(".")} cannot enforce a read-only seat: --tools read keeps MCP tools ` +
50
+ `until --no-mcp arrived in ${piReadOnlyFloor}; upgrade pi, or give the judge and ` +
51
+ "reviewer seats to another harness"
52
+ }
53
+
54
+ /** The provider half of pi's `provider/model[:thinking]` model id. */
55
+ const providerOf = (model: string | undefined): string | undefined =>
56
+ model === undefined ? undefined : model.split("/", 1)[0]
57
+
25
58
  export const piExtraArgs = (config: CliConnectorConfig): ReadonlyArray<string> => [
26
59
  ...optionalModelArgs(config.model),
27
60
  // `noTools` takes precedence over `readOnly`: `--no-tools` disables tool
@@ -29,12 +62,22 @@ export const piExtraArgs = (config: CliConnectorConfig): ReadonlyArray<string> =
29
62
  // offers a read tool the model can invoke — fine for an interactive
30
63
  // session, unsafe for a one-shot complete() call with no tool-loop
31
64
  // continuation (see the field's doc comment on CliConnectorConfig).
32
- ...(config.noTools ? ["--no-tools"] : config.readOnly ? ["--tools", "read"] : []),
65
+ // `--no-tools` also drops MCP tools; `--tools read` keeps them (pi >= 1.0.4),
66
+ // so a read-only seat adds `--no-mcp` (ADR 0035).
67
+ ...(config.noTools ? ["--no-tools"] : config.readOnly ? ["--tools", "read", "--no-mcp"] : []),
33
68
  // `--thinking` is pi's effort flag (off, minimal, low, medium, high, xhigh).
34
69
  ...(config.effort === undefined ? [] : ["--thinking", effortWord(config.effort, "xhigh")]),
35
70
  // Isolation is partial (ADR 0029): project extensions and skills stay
36
71
  // out; pi still reads AGENTS.md from the working directory and its parents.
37
- ...(config.isolated ? ["--no-extensions", "--no-skills"] : []),
72
+ // `--no-extensions` also drops the built-in llama.cpp provider (pi >=
73
+ // 0.99.0), so a local model on it is re-enabled by name.
74
+ ...(config.isolated
75
+ ? [
76
+ "--no-extensions",
77
+ "--no-skills",
78
+ ...(providerOf(config.model) === "llama.cpp" ? ["-e", "builtin:llama.cpp"] : [])
79
+ ]
80
+ : []),
38
81
  ...sortedFlagArgs(config.flags)
39
82
  ]
40
83
 
@@ -53,27 +96,40 @@ export const parsePiStreamLine = (line: string): ReadonlyArray<LlmChunk> => {
53
96
  : undefined
54
97
  return delta === undefined || delta.length === 0 ? [] : [LlmChunk.make({ delta })]
55
98
  }
56
- case "tool_execution_start":
99
+ // A call nested in another (codemode) names its parent (ADR 0033).
100
+ case "tool_execution_start": {
101
+ const parent = jsonStringField(json, "parentToolCallId")
57
102
  return [
58
103
  toolEventChunk(
59
104
  jsonStringField(json, "toolName") ?? "",
60
105
  jsonField(json, "args"),
61
- jsonStringField(json, "toolCallId")
106
+ jsonStringField(json, "toolCallId"),
107
+ parent === undefined ? {} : { parent }
62
108
  )
63
109
  ]
64
- case "tool_execution_end":
110
+ }
111
+ case "tool_execution_end": {
112
+ const parent = jsonStringField(json, "parentToolCallId")
113
+ const durationMs = jsonIntField(json, "durationMs")
65
114
  return [
66
115
  toolResultChunk(jsonStringField(json, "toolCallId"), {
67
116
  failed: jsonField(json, "isError") === true,
117
+ ...(parent === undefined ? {} : { parent }),
118
+ ...(durationMs === undefined ? {} : { durationMs }),
68
119
  ...(() => {
69
120
  const output = toolResultText(jsonField(jsonField(json, "result"), "content"))
70
121
  return output === undefined ? {} : { output }
71
122
  })()
72
123
  })
73
124
  ]
125
+ }
74
126
  case "message_end":
75
127
  case "agent_end": {
76
128
  const message = jsonField(json, "message")
129
+ // An aborted turn (a signal, a steer) is not a reply either.
130
+ if (jsonStringField(message, "stopReason") === "aborted") {
131
+ return [LlmChunk.make({ delta: "", metadata: { piError: "pi aborted the turn" } })]
132
+ }
77
133
  // pi reports a provider refusal (a usage limit, an auth failure) as an
78
134
  // assistant message that stopped with an error and exits 0; without
79
135
  // this it reads as an empty, successful reply.
@@ -106,6 +162,10 @@ export const parsePiStreamLine = (line: string): ReadonlyArray<LlmChunk> => {
106
162
  )
107
163
  ]
108
164
  }
165
+ // A provider retry and a context compaction pause the turn: the lane
166
+ // says so instead of looking stuck (ADR 0033).
167
+ case "auto_retry_start":
168
+ return [statusChunk("retrying", "start", jsonStringField(json, "errorMessage"))]
109
169
  case "auto_retry_end":
110
170
  return jsonBooleanField(json, "success") === false
111
171
  ? [
@@ -116,7 +176,11 @@ export const parsePiStreamLine = (line: string): ReadonlyArray<LlmChunk> => {
116
176
  }
117
177
  })
118
178
  ]
119
- : []
179
+ : [statusChunk("retrying", "end")]
180
+ case "compaction_start":
181
+ return [statusChunk("compacting", "start")]
182
+ case "compaction_end":
183
+ return [statusChunk("compacting", "end")]
120
184
  case "extension_error":
121
185
  return [
122
186
  LlmChunk.make({
@@ -138,9 +202,27 @@ export const makePiConnector = (
138
202
  const cwd = config.workingDir ?? "."
139
203
  const extraArgs = piExtraArgs(config)
140
204
 
205
+ // A read-only seat is only taken on a pi whose read allowlist can exclude
206
+ // MCP tools (`piReadOnlyFloor`): the version answer decides before the
207
+ // first turn, once per connector; a failed probe is retried on the next.
208
+ let floorChecked = !config.readOnly
209
+ const checkFloorOnce: Effect.Effect<void, LlmError> = Effect.suspend(() =>
210
+ floorChecked
211
+ ? Effect.void
212
+ : Effect.flatMap(executor.run(["pi", "--version"], cwd, config.envVars), (result) => {
213
+ const problem = piReadOnlyProblem([...result.stdout, ...result.stderr].join("\n"))
214
+ return problem === undefined
215
+ ? Effect.sync(() => {
216
+ floorChecked = true
217
+ })
218
+ : Effect.fail(InvalidRequestError.make({ message: problem }))
219
+ })
220
+ )
221
+
141
222
  const complete = Effect.fn("@llm4ts/core/providers/PiConnector.complete")(function* (
142
223
  prompt: string
143
224
  ) {
225
+ yield* checkFloorOnce
144
226
  const result = yield* executor.runWithStdin(
145
227
  ["pi", "-p", ...extraArgs],
146
228
  cwd,
@@ -161,24 +243,26 @@ export const makePiConnector = (
161
243
  })
162
244
 
163
245
  const completeStream = (prompt: string) =>
164
- executor
165
- .runStreamingWithStdin(
166
- ["pi", "-p", "--mode", "json", ...extraArgs],
167
- cwd,
168
- config.envVars,
169
- prompt
170
- )
171
- .pipe(
172
- Stream.flatMap((line) => Stream.fromIterable(parsePiStreamLine(line))),
173
- // pi reports usage per assistant message; a turn is all of them.
174
- cumulativeUsage,
175
- Stream.mapEffect((chunk) => {
176
- const message = chunk.metadata.piError
177
- return message === undefined
178
- ? Effect.succeed(chunk)
179
- : failClassifiedCliError("pi", "pi error", message)
180
- })
181
- )
246
+ Stream.fromEffect(checkFloorOnce).pipe(
247
+ Stream.drain,
248
+ Stream.concat(
249
+ executor.runStreamingWithStdin(
250
+ ["pi", "-p", "--mode", "json", ...extraArgs],
251
+ cwd,
252
+ config.envVars,
253
+ prompt
254
+ )
255
+ ),
256
+ Stream.flatMap((line) => Stream.fromIterable(parsePiStreamLine(line))),
257
+ // pi reports usage per assistant message; a turn is all of them.
258
+ cumulativeUsage,
259
+ Stream.mapEffect((chunk) => {
260
+ const message = chunk.metadata.piError
261
+ return message === undefined
262
+ ? Effect.succeed(chunk)
263
+ : failClassifiedCliError("pi", "pi error", message)
264
+ })
265
+ )
182
266
 
183
267
  return makeCliConnector({
184
268
  id: ConnectorIds.Pi,
@@ -186,13 +270,17 @@ export const makePiConnector = (
186
270
  // `--tools read` is pi's documented comma-separated ALLOWLIST of tool
187
271
  // names: only `read` is enabled, so bash/edit/write are absent — a real
188
272
  // capability removal, the same mechanism class as claude's `--tools`.
273
+ // `--no-mcp` keeps MCP tools out of it, and `checkFloorOnce` refuses a
274
+ // read-only seat on a pi without that flag, so the grade holds.
189
275
  capabilities: ConnectorCapabilities.make({
190
276
  interactiveSessions: true,
191
277
  readOnlyEnforcement: "enforced",
192
278
  effort: "mapped",
193
279
  isolatedHeadless: "partial"
194
280
  }),
195
- buildArgv: (prompt, _context) => ["pi", "-p", ...extraArgs, prompt],
281
+ // `--` ends the options (pi >= 0.84.3): a prompt starting with a dash
282
+ // is a prompt, not a flag. Stdin paths need no separator.
283
+ buildArgv: (prompt, _context) => ["pi", "-p", ...extraArgs, "--", prompt],
196
284
  buildInteractiveArgv: (_context) => ["pi", ...extraArgs],
197
285
  complete,
198
286
  completeStream,