nuabase 3.0.1 → 3.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/CHANGELOG.md +21 -0
- package/README.md +23 -3
- package/dist/chunks/{local-agent-Bj-QKikS.d.cts → local-agent-CPIXKS41.d.cts} +61 -19
- package/dist/chunks/{local-agent-Bj-QKikS.d.mts → local-agent-CPIXKS41.d.mts} +61 -19
- package/dist/cjs/index.cjs +67 -21
- package/dist/cjs/local-agent.cjs +241 -71
- package/dist/esm/index.mjs +67 -21
- package/dist/esm/local-agent.mjs +241 -72
- package/dist/index.d.cts +13 -3
- package/dist/index.d.mts +13 -3
- package/dist/local-agent.d.cts +55 -14
- package/dist/local-agent.d.mts +55 -14
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# nuabase
|
|
2
2
|
|
|
3
|
+
## 3.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 8fdf585: `startAgentSession()` takes `env`, the environment `open` runs in on macOS, and `unsetEnv`, names the launcher unsets before starting the agent. A terminal app that `open` launches keeps `open`'s environment for every later window, so before this a server's own settings reached the user's other terminal sessions. It throws a `TypeError` for an `unsetEnv` entry that isn't a shell variable name.
|
|
8
|
+
- 3700318: `nua.get()` and `nua.list()` take `signal`, an `AbortSignal` that cancels the call. A local agent's process is stopped, SIGINT then SIGKILL 2 seconds later, and the call returns once the process has exited. A call still waiting for a free agent process leaves the queue without starting one. A provider request is aborted. A cancelled call is not retried and fails with `cancelled: true`, a new field of every failed result.
|
|
9
|
+
|
|
10
|
+
A local agent stopped by `timeoutMs` or `idleTimeoutMs` now also says how much it had printed and, for `timeoutMs`, how long before the stop it last printed, which tells a slow agent from a stuck one.
|
|
11
|
+
- a7054ac: Local agents: `onOutput`, an option of `nua.get()` and `nua.list()`, passes on everything the agent prints while that call runs, unparsed, so an app can show its progress as a log. `idleTimeoutMs` stops an agent that prints nothing for 2 minutes (Claude Code and Pi; Codex has no idle limit, since it is quiet while it reasons), and `timeoutMs` now defaults to 10 minutes instead of 3. A timeout says which limit was hit, after how long, how to raise it, and the end of the agent's stderr. Claude Code now runs with `--output-format stream-json`, so it prints while it works; only its final result line is read.
|
|
12
|
+
|
|
13
|
+
Also changed:
|
|
14
|
+
|
|
15
|
+
- `timeoutMs` and `idleTimeoutMs` must be from 1 to 2147483647 ms (or `null` for no idle limit); `localAgent()`, `findLocalAgent()` and `with()` throw a `TypeError` otherwise. Before, a value such as `0`, `NaN` or `Infinity` stopped the agent at once.
|
|
16
|
+
- In `agent.with()`, a `timeoutMs` given as `undefined` now resets to the default, as every other option does, instead of keeping the agent's.
|
|
17
|
+
- `findLocalAgent()` reports an invalid option only once it has found an agent, not before it looks.
|
|
18
|
+
- 36ce67b: Add `localAgentSetupGuide()` to `nuabase/local-agent`: plain text an app shows a user with no coding agent set up, saying the agent must be the CLI, how to install and sign in to each supported one, and a prompt that has a desktop or browser agent install one.
|
|
19
|
+
|
|
20
|
+
### Patch Changes
|
|
21
|
+
|
|
22
|
+
- de8f585: A headless Codex call turns off Codex's shell, its ChatGPT apps connector, plugins, image viewing and web search. In the read-only sandbox the shell could still read any file and the caller's environment, and the apps connector could browse the web and change the account, so a prompt carrying an injected instruction could reach them. Codex 0.154 accepts these feature names; an older Codex that lacks one fails the call with "Unknown feature flag".
|
|
23
|
+
|
|
3
24
|
## 3.0.1
|
|
4
25
|
|
|
5
26
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -110,6 +110,8 @@ if (address.success) console.log(address.data); // z.infer<typeof AddressSchema>
|
|
|
110
110
|
* `input?: unknown` – Context or data to include with the prompt.
|
|
111
111
|
* `output: { name: string; schema: ZodSchema }` – Identifier and Zod schema for the output.
|
|
112
112
|
* `model?: ModelInput` – Override the client's default model for this call.
|
|
113
|
+
* `onOutput?: (chunk, stream) => void` – On a local agent, gets everything the agent prints while this call runs, unparsed, as it arrives (`stream` is `'stdout'` or `'stderr'`), to show as a log. Chunks don't follow line boundaries, and a retry's output follows the earlier attempt's. Provider APIs print nothing, so it is never called.
|
|
114
|
+
* `signal?: AbortSignal` – Cancels the call: a local agent's process is stopped (SIGINT, then SIGKILL 2 seconds later) and a provider request is aborted. A call waiting for a free agent process leaves the queue. The call then fails with `cancelled: true` and is not retried.
|
|
113
115
|
|
|
114
116
|
### `nua.list(prompt, options)`
|
|
115
117
|
|
|
@@ -133,6 +135,8 @@ if (result.success) {
|
|
|
133
135
|
* `primaryKey: string` – Unique key on each input object. Each output row carries it, so you can match rows to inputs.
|
|
134
136
|
* `output: { name: string; schema: ZodSchema }` – Property name and Zod schema attached to each returned row.
|
|
135
137
|
* `model?: ModelInput` – Override the client's default model for this call.
|
|
138
|
+
* `onOutput?: (chunk, stream) => void` – As for `get()`.
|
|
139
|
+
* `signal?: AbortSignal` – As for `get()`.
|
|
136
140
|
|
|
137
141
|
---
|
|
138
142
|
|
|
@@ -210,12 +214,13 @@ const agents = await detectLocalAgents();
|
|
|
210
214
|
| `binaryPath` | `string` | Looked up on `PATH` | Path to executable (`localAgent` only). On Windows, point to a native executable (`.cmd` shims are not supported). |
|
|
211
215
|
| `model` | `string` | Agent's default | The agent's own model name for calls that name no model. Claude Code accepts aliases (`'haiku'`, `'sonnet'`, `'opus'`), and so does a per-call `model: { alias }`. Codex and Pi have no aliases: Codex uses its own model names; Pi uses `'provider/id'` (e.g., `'openrouter/z-ai/glm-5.3-flash'`). |
|
|
212
216
|
| `effort` | `'low' \| 'medium' \| 'high'` | Agent's default | How hard the model thinks, as the CLI's own setting: Claude Code `--effort`, Codex `model_reasoning_effort`, Pi `--thinking`. |
|
|
213
|
-
| `timeoutMs` | `number` | `
|
|
217
|
+
| `timeoutMs` | `number` | `600000` | Maximum execution time per call in milliseconds, from 1 to 2147483647. |
|
|
218
|
+
| `idleTimeoutMs` | `number \| null` | `120000`; none for Codex | Stops the agent when it prints nothing for this many milliseconds (1 to 2147483647), so a stuck agent fails early and a slow one still finishes. `null` for no limit. Codex has none by default: it prints nothing while it reasons. |
|
|
214
219
|
| `concurrency` | `number` | `2` | Maximum concurrent agent processes. |
|
|
215
220
|
| `auth` | `'subscription' \| 'inherit'` | `'subscription'` | `'subscription'` strips env vars like `ANTHROPIC_API_KEY` and `OPENAI_API_KEY` so calls bill your logged-in plan instead of API keys. |
|
|
216
221
|
| `logger` | object | `console` | Object with `debug`, `info`, `warn`, and `error` methods for call and retry logs. |
|
|
217
222
|
|
|
218
|
-
`agent.with({ model, effort, timeoutMs })` returns the same agent with other call options. It shares the agent's executable, environment, logger and `concurrency` limit, so one `localAgent()` per CLI can serve casts on several of its models without running more processes at once than the limit allows. Options left out keep the agent's;
|
|
223
|
+
`agent.with({ model, effort, timeoutMs, idleTimeoutMs })` returns the same agent with other call options. It shares the agent's executable, environment, logger and `concurrency` limit, so one `localAgent()` per CLI can serve casts on several of its models without running more processes at once than the limit allows. Options left out keep the agent's; an option given as `undefined` takes its default.
|
|
219
224
|
|
|
220
225
|
```ts
|
|
221
226
|
const codex = localAgent({ agent: 'codex', concurrency: 2 });
|
|
@@ -224,6 +229,16 @@ const careful = Nua.direct({ localAgent: codex.with({ model: 'gpt-5.6-sol', time
|
|
|
224
229
|
// Both run on the same two processes at most.
|
|
225
230
|
```
|
|
226
231
|
|
|
232
|
+
To show a call's progress, pass `onOutput` with the call. What the agent prints is its own JSON lines, which nuabase passes on as they are:
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
const result = await nua.get(prompt, { output: { name: 'chart', schema }, onOutput: (chunk) => res.write(chunk) });
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
> **Note:** This sends the agent's output to the browser unfiltered: its reasoning, the prompt and input data it echoes, and details of the machine it runs on, such as working directories and how it is logged in. That suits a local-first app, where the server, the agent and the browser are all on the user's own machine and the user is the only one watching. A server that others reach should filter the output, or show only progress such as a count of characters, before passing it on.
|
|
239
|
+
|
|
240
|
+
When a call is stopped by `timeoutMs` or `idleTimeoutMs`, its error says which limit stopped it, how much the agent had printed and when it last printed, so a slow agent can be told from a stuck one. A call stopped by either limit is not retried, so `timeoutMs` bounds how long it runs. A transient failure is retried, and each attempt may take up to `timeoutMs`.
|
|
241
|
+
|
|
227
242
|
> **Note:** Local agent calls count against your personal subscription limits. Check each vendor's terms before exposing local-agent execution to end users.
|
|
228
243
|
|
|
229
244
|
### Interactive Agent Sessions (Node.js only)
|
|
@@ -258,7 +273,9 @@ switch (session.kind) {
|
|
|
258
273
|
|
|
259
274
|
It writes the prompt to its own file (`0600`) and a launcher (`0700`) that passes it to the agent as one argument after `--`. Quotes, backticks and `$NAMES` in the prompt reach the agent as written, and the prompt can't set the agent's flags or run commands. Neither file is ever overwritten or deleted. On macOS the launcher opens in a new window of the user's terminal app (`open`, which needs no Automation permission).
|
|
260
275
|
|
|
261
|
-
Expected failures come back as `kind: 'refused'`, with nothing written: Windows, which can't run the launcher (`unsupported_platform`); a prompt of 128 KiB or more on Linux, which caps a single argument at that size (`prompt_too_long`); and an agent that isn't on `PATH` when no `binaryPath` is given (`agent_not_found`). It throws only for an unknown `agent` or a `name` already used.
|
|
276
|
+
Expected failures come back as `kind: 'refused'`, with nothing written: Windows, which can't run the launcher (`unsupported_platform`); a prompt of 128 KiB or more on Linux, which caps a single argument at that size (`prompt_too_long`); and an agent that isn't on `PATH` when no `binaryPath` is given (`agent_not_found`). It throws only for an unknown `agent`, an `unsetEnv` entry that isn't a shell variable name, or a `name` already used.
|
|
277
|
+
|
|
278
|
+
On macOS, `open` passes its environment to a terminal app it launches, and every later window of that app inherits it. A server whose environment holds its own settings (ports, secrets, a data directory) passes `env` without them, so they don't reach the user's other terminal sessions, and `unsetEnv` with their names, so the launcher unsets them in a terminal that already holds them.
|
|
262
279
|
|
|
263
280
|
`agentSessionMode(platform?)` says beforehand how a session would start, for showing the right button: `'terminal'` on macOS, `'command'` on other POSIX systems, `'none'` on Windows.
|
|
264
281
|
|
|
@@ -273,6 +290,8 @@ Expected failures come back as `kind: 'refused'`, with nothing written: Windows,
|
|
|
273
290
|
| `dir` | `string` | Required | Where both files are written. Created if missing. |
|
|
274
291
|
| `name` | `string` | `<UTC time>-<agent>-<4 hex digits>` | The files' name without extension. Neither file may exist yet. |
|
|
275
292
|
| `platform` | `NodeJS.Platform` | `process.platform` | The platform the launcher is for. |
|
|
293
|
+
| `env` | `NodeJS.ProcessEnv` | `process.env` | The environment `open` runs in on macOS, which a terminal app it launches keeps for every window. |
|
|
294
|
+
| `unsetEnv` | `string[]` | None | Variables the launcher unsets before starting the agent. |
|
|
276
295
|
|
|
277
296
|
---
|
|
278
297
|
|
|
@@ -311,6 +330,7 @@ The call rejects (throws) only for invalid arguments, before anything is sent: a
|
|
|
311
330
|
| --- | --- | --- |
|
|
312
331
|
| `success` | `false` | Request failed (see retries below). |
|
|
313
332
|
| `error` | `string` | Error description. |
|
|
333
|
+
| `cancelled` | `boolean` | `true` when the call's `signal` cancelled it. |
|
|
314
334
|
| `source` | `'direct'` | Indicates the request executed in-process. |
|
|
315
335
|
| `latencyMs` | `number` | Time elapsed until failure in milliseconds. |
|
|
316
336
|
|
|
@@ -76,8 +76,9 @@ type AttemptFailure = {
|
|
|
76
76
|
* - transient: another attempt may succeed.
|
|
77
77
|
* - fatal: every attempt will fail the same way (bad API key, not logged in, out of quota, timed out).
|
|
78
78
|
* - schema-rejected: the engine refused the native schema; the in-prompt prompt may still work.
|
|
79
|
+
* - cancelled: the caller's signal aborted it.
|
|
79
80
|
*/
|
|
80
|
-
kind: "transient" | "fatal" | "schema-rejected";
|
|
81
|
+
kind: "transient" | "fatal" | "schema-rejected" | "cancelled";
|
|
81
82
|
message: string;
|
|
82
83
|
};
|
|
83
84
|
type AttemptOutcome = {
|
|
@@ -89,6 +90,20 @@ type AttemptOutcome = {
|
|
|
89
90
|
kind: "failed";
|
|
90
91
|
failure: AttemptFailure;
|
|
91
92
|
};
|
|
93
|
+
/**
|
|
94
|
+
* Gets what an engine prints while it answers, unparsed, as it arrives: a
|
|
95
|
+
* coding agent's own output, to show as a log while a call runs. Chunks are
|
|
96
|
+
* UTF-8 text that doesn't follow line boundaries; a retry's output follows the
|
|
97
|
+
* earlier attempt's. Engines that print nothing, such as LLM providers' APIs,
|
|
98
|
+
* never call it.
|
|
99
|
+
*/
|
|
100
|
+
type EngineOutputListener = (chunk: string, stream: "stdout" | "stderr") => void;
|
|
101
|
+
/** What one cast's caller hands each of its attempts. */
|
|
102
|
+
type AttemptControls = {
|
|
103
|
+
onOutput?: EngineOutputListener;
|
|
104
|
+
/** Cancels the attempt: an agent process is stopped, an HTTP request aborted. */
|
|
105
|
+
signal?: AbortSignal;
|
|
106
|
+
};
|
|
92
107
|
/**
|
|
93
108
|
* Answers prompts on one model: an LLM provider's HTTP API, or a coding-agent
|
|
94
109
|
* CLI on this machine. Expected failures come back as outcomes, not exceptions.
|
|
@@ -96,7 +111,7 @@ type AttemptOutcome = {
|
|
|
96
111
|
interface LlmEngine {
|
|
97
112
|
/** The envelope schema in the form this engine enforces natively, or null if it cannot. */
|
|
98
113
|
nativeSchema(envelopeSchema: JsonSchema): JsonSchema | null;
|
|
99
|
-
attempt(request: AttemptRequest): Promise<AttemptOutcome>;
|
|
114
|
+
attempt(request: AttemptRequest, controls?: AttemptControls): Promise<AttemptOutcome>;
|
|
100
115
|
}
|
|
101
116
|
|
|
102
117
|
type ProviderModel = {
|
|
@@ -145,10 +160,27 @@ type AgentProcessResult = {
|
|
|
145
160
|
exitCode: number | null;
|
|
146
161
|
stdout: string;
|
|
147
162
|
stderr: string;
|
|
148
|
-
|
|
163
|
+
/** Why the process was stopped before it finished; null when it wasn't. */
|
|
164
|
+
stopped: AgentProcessStop | null;
|
|
149
165
|
/** Contents of the requested output files; undefined when a file was not written. */
|
|
150
166
|
outputFiles: Record<string, string | undefined>;
|
|
151
167
|
};
|
|
168
|
+
/**
|
|
169
|
+
* What stopped a process: the limit on the whole run ("total") or on going
|
|
170
|
+
* quiet ("idle"), which was `limitMs`, or the caller's AbortSignal ("signal").
|
|
171
|
+
*/
|
|
172
|
+
type AgentProcessStopCause = {
|
|
173
|
+
by: "total" | "idle";
|
|
174
|
+
limitMs: number;
|
|
175
|
+
} | {
|
|
176
|
+
by: "signal";
|
|
177
|
+
};
|
|
178
|
+
/** What stopped a process, and when, in milliseconds from its start. */
|
|
179
|
+
type AgentProcessStop = AgentProcessStopCause & {
|
|
180
|
+
afterMs: number;
|
|
181
|
+
/** When it last printed; null when it printed nothing. */
|
|
182
|
+
lastOutputAtMs: number | null;
|
|
183
|
+
};
|
|
152
184
|
/** What an agent's output says about one attempt. */
|
|
153
185
|
type AgentReply = {
|
|
154
186
|
kind: "answered";
|
|
@@ -192,6 +224,11 @@ interface AgentAdapter {
|
|
|
192
224
|
binaryName: string;
|
|
193
225
|
/** Env vars that would make the CLI bill an API key instead of the user's subscription. */
|
|
194
226
|
apiKeyEnvVars: string[];
|
|
227
|
+
/**
|
|
228
|
+
* How long the CLI may print nothing before it counts as stuck, or null when
|
|
229
|
+
* it can be quiet for long stretches while working, as Codex is while it reasons.
|
|
230
|
+
*/
|
|
231
|
+
idleTimeoutMs: number | null;
|
|
195
232
|
/** The CLI's own model name for each model alias it has an equivalent for. */
|
|
196
233
|
modelAliases: Partial<Record<ModelAliasName, string>>;
|
|
197
234
|
/** The envelope schema in the form this CLI accepts, or null if it can't enforce it. */
|
|
@@ -214,7 +251,10 @@ declare class Semaphore {
|
|
|
214
251
|
private active;
|
|
215
252
|
private readonly waiting;
|
|
216
253
|
constructor(limit: number);
|
|
217
|
-
|
|
254
|
+
/** Rejects with the signal's reason, without running `task`, when `signal` aborts while it waits. */
|
|
255
|
+
run<T>(task: () => Promise<T>, signal?: AbortSignal): Promise<T>;
|
|
256
|
+
/** Resolves once a finishing task hands over its place. */
|
|
257
|
+
private waitForTurn;
|
|
218
258
|
}
|
|
219
259
|
|
|
220
260
|
/** How to run one installed agent's processes. Set up once and shared by all its engines. */
|
|
@@ -227,16 +267,21 @@ type AgentRunner = {
|
|
|
227
267
|
limiter: Semaphore;
|
|
228
268
|
logger: Logger;
|
|
229
269
|
};
|
|
230
|
-
/** How one engine's attempts run: the CLI's options and how long each may take. */
|
|
231
|
-
type AgentCall = AgentCallOptions & {
|
|
232
|
-
/** Per-attempt limit. */
|
|
233
|
-
timeoutMs: number;
|
|
234
|
-
};
|
|
235
270
|
|
|
236
|
-
/**
|
|
271
|
+
/**
|
|
272
|
+
* How a local agent's casts run, not which processes run them: what
|
|
273
|
+
* localAgent() sets and `LocalAgent.with()` changes. An option left out, or
|
|
274
|
+
* given as undefined, takes its default.
|
|
275
|
+
*/
|
|
237
276
|
type LocalAgentCallOptions = AgentCallOptions & {
|
|
238
|
-
/** Per-call limit in milliseconds. */
|
|
277
|
+
/** Per-call limit in milliseconds. Defaults to 600000. */
|
|
239
278
|
timeoutMs?: number;
|
|
279
|
+
/**
|
|
280
|
+
* How long, in milliseconds, the agent may print nothing before it is
|
|
281
|
+
* stopped as stuck; null for no limit. Defaults to the agent's own limit,
|
|
282
|
+
* which is none for an agent that is quiet while it works.
|
|
283
|
+
*/
|
|
284
|
+
idleTimeoutMs?: number | null;
|
|
240
285
|
};
|
|
241
286
|
/**
|
|
242
287
|
* A coding-agent CLI on this machine, set up once and ready to run casts.
|
|
@@ -244,18 +289,15 @@ type LocalAgentCallOptions = AgentCallOptions & {
|
|
|
244
289
|
*/
|
|
245
290
|
declare class LocalAgent implements EngineRouter {
|
|
246
291
|
private readonly runner;
|
|
247
|
-
/** How calls that name no model run. */
|
|
248
|
-
private readonly call;
|
|
249
292
|
readonly agent: LocalAgentId;
|
|
250
293
|
readonly logger: Logger;
|
|
251
|
-
constructor(runner: AgentRunner,
|
|
252
294
|
/** How calls that name no model run. */
|
|
253
|
-
call
|
|
295
|
+
private readonly call;
|
|
296
|
+
constructor(runner: AgentRunner, options: LocalAgentCallOptions);
|
|
254
297
|
/**
|
|
255
298
|
* The same agent with other call options, sharing this one's executable,
|
|
256
299
|
* environment, logger and limit on processes running at once. Options left
|
|
257
|
-
* out keep this agent's;
|
|
258
|
-
* default.
|
|
300
|
+
* out keep this agent's; an option given as undefined takes its default.
|
|
259
301
|
*/
|
|
260
302
|
with(options: LocalAgentCallOptions): LocalAgent;
|
|
261
303
|
/**
|
|
@@ -266,5 +308,5 @@ declare class LocalAgent implements EngineRouter {
|
|
|
266
308
|
private routeTo;
|
|
267
309
|
}
|
|
268
310
|
|
|
269
|
-
export { LocalAgent as a, LOCAL_AGENT_IDS as
|
|
270
|
-
export type { AnswerOrigin as A, LlmProviderId as L, ModelInput as M, NormalizedUsage as N, SchemaEnforcementKind as S, LocalAgentId as b,
|
|
311
|
+
export { LocalAgent as a, LOCAL_AGENT_IDS as g, isLocalAgentId as i };
|
|
312
|
+
export type { AnswerOrigin as A, EngineOutputListener as E, LlmProviderId as L, ModelInput as M, NormalizedUsage as N, SchemaEnforcementKind as S, LocalAgentId as b, LocalAgentCallOptions as c, Logger as d, SessionApproval as e, AgentEffort as f };
|
|
@@ -76,8 +76,9 @@ type AttemptFailure = {
|
|
|
76
76
|
* - transient: another attempt may succeed.
|
|
77
77
|
* - fatal: every attempt will fail the same way (bad API key, not logged in, out of quota, timed out).
|
|
78
78
|
* - schema-rejected: the engine refused the native schema; the in-prompt prompt may still work.
|
|
79
|
+
* - cancelled: the caller's signal aborted it.
|
|
79
80
|
*/
|
|
80
|
-
kind: "transient" | "fatal" | "schema-rejected";
|
|
81
|
+
kind: "transient" | "fatal" | "schema-rejected" | "cancelled";
|
|
81
82
|
message: string;
|
|
82
83
|
};
|
|
83
84
|
type AttemptOutcome = {
|
|
@@ -89,6 +90,20 @@ type AttemptOutcome = {
|
|
|
89
90
|
kind: "failed";
|
|
90
91
|
failure: AttemptFailure;
|
|
91
92
|
};
|
|
93
|
+
/**
|
|
94
|
+
* Gets what an engine prints while it answers, unparsed, as it arrives: a
|
|
95
|
+
* coding agent's own output, to show as a log while a call runs. Chunks are
|
|
96
|
+
* UTF-8 text that doesn't follow line boundaries; a retry's output follows the
|
|
97
|
+
* earlier attempt's. Engines that print nothing, such as LLM providers' APIs,
|
|
98
|
+
* never call it.
|
|
99
|
+
*/
|
|
100
|
+
type EngineOutputListener = (chunk: string, stream: "stdout" | "stderr") => void;
|
|
101
|
+
/** What one cast's caller hands each of its attempts. */
|
|
102
|
+
type AttemptControls = {
|
|
103
|
+
onOutput?: EngineOutputListener;
|
|
104
|
+
/** Cancels the attempt: an agent process is stopped, an HTTP request aborted. */
|
|
105
|
+
signal?: AbortSignal;
|
|
106
|
+
};
|
|
92
107
|
/**
|
|
93
108
|
* Answers prompts on one model: an LLM provider's HTTP API, or a coding-agent
|
|
94
109
|
* CLI on this machine. Expected failures come back as outcomes, not exceptions.
|
|
@@ -96,7 +111,7 @@ type AttemptOutcome = {
|
|
|
96
111
|
interface LlmEngine {
|
|
97
112
|
/** The envelope schema in the form this engine enforces natively, or null if it cannot. */
|
|
98
113
|
nativeSchema(envelopeSchema: JsonSchema): JsonSchema | null;
|
|
99
|
-
attempt(request: AttemptRequest): Promise<AttemptOutcome>;
|
|
114
|
+
attempt(request: AttemptRequest, controls?: AttemptControls): Promise<AttemptOutcome>;
|
|
100
115
|
}
|
|
101
116
|
|
|
102
117
|
type ProviderModel = {
|
|
@@ -145,10 +160,27 @@ type AgentProcessResult = {
|
|
|
145
160
|
exitCode: number | null;
|
|
146
161
|
stdout: string;
|
|
147
162
|
stderr: string;
|
|
148
|
-
|
|
163
|
+
/** Why the process was stopped before it finished; null when it wasn't. */
|
|
164
|
+
stopped: AgentProcessStop | null;
|
|
149
165
|
/** Contents of the requested output files; undefined when a file was not written. */
|
|
150
166
|
outputFiles: Record<string, string | undefined>;
|
|
151
167
|
};
|
|
168
|
+
/**
|
|
169
|
+
* What stopped a process: the limit on the whole run ("total") or on going
|
|
170
|
+
* quiet ("idle"), which was `limitMs`, or the caller's AbortSignal ("signal").
|
|
171
|
+
*/
|
|
172
|
+
type AgentProcessStopCause = {
|
|
173
|
+
by: "total" | "idle";
|
|
174
|
+
limitMs: number;
|
|
175
|
+
} | {
|
|
176
|
+
by: "signal";
|
|
177
|
+
};
|
|
178
|
+
/** What stopped a process, and when, in milliseconds from its start. */
|
|
179
|
+
type AgentProcessStop = AgentProcessStopCause & {
|
|
180
|
+
afterMs: number;
|
|
181
|
+
/** When it last printed; null when it printed nothing. */
|
|
182
|
+
lastOutputAtMs: number | null;
|
|
183
|
+
};
|
|
152
184
|
/** What an agent's output says about one attempt. */
|
|
153
185
|
type AgentReply = {
|
|
154
186
|
kind: "answered";
|
|
@@ -192,6 +224,11 @@ interface AgentAdapter {
|
|
|
192
224
|
binaryName: string;
|
|
193
225
|
/** Env vars that would make the CLI bill an API key instead of the user's subscription. */
|
|
194
226
|
apiKeyEnvVars: string[];
|
|
227
|
+
/**
|
|
228
|
+
* How long the CLI may print nothing before it counts as stuck, or null when
|
|
229
|
+
* it can be quiet for long stretches while working, as Codex is while it reasons.
|
|
230
|
+
*/
|
|
231
|
+
idleTimeoutMs: number | null;
|
|
195
232
|
/** The CLI's own model name for each model alias it has an equivalent for. */
|
|
196
233
|
modelAliases: Partial<Record<ModelAliasName, string>>;
|
|
197
234
|
/** The envelope schema in the form this CLI accepts, or null if it can't enforce it. */
|
|
@@ -214,7 +251,10 @@ declare class Semaphore {
|
|
|
214
251
|
private active;
|
|
215
252
|
private readonly waiting;
|
|
216
253
|
constructor(limit: number);
|
|
217
|
-
|
|
254
|
+
/** Rejects with the signal's reason, without running `task`, when `signal` aborts while it waits. */
|
|
255
|
+
run<T>(task: () => Promise<T>, signal?: AbortSignal): Promise<T>;
|
|
256
|
+
/** Resolves once a finishing task hands over its place. */
|
|
257
|
+
private waitForTurn;
|
|
218
258
|
}
|
|
219
259
|
|
|
220
260
|
/** How to run one installed agent's processes. Set up once and shared by all its engines. */
|
|
@@ -227,16 +267,21 @@ type AgentRunner = {
|
|
|
227
267
|
limiter: Semaphore;
|
|
228
268
|
logger: Logger;
|
|
229
269
|
};
|
|
230
|
-
/** How one engine's attempts run: the CLI's options and how long each may take. */
|
|
231
|
-
type AgentCall = AgentCallOptions & {
|
|
232
|
-
/** Per-attempt limit. */
|
|
233
|
-
timeoutMs: number;
|
|
234
|
-
};
|
|
235
270
|
|
|
236
|
-
/**
|
|
271
|
+
/**
|
|
272
|
+
* How a local agent's casts run, not which processes run them: what
|
|
273
|
+
* localAgent() sets and `LocalAgent.with()` changes. An option left out, or
|
|
274
|
+
* given as undefined, takes its default.
|
|
275
|
+
*/
|
|
237
276
|
type LocalAgentCallOptions = AgentCallOptions & {
|
|
238
|
-
/** Per-call limit in milliseconds. */
|
|
277
|
+
/** Per-call limit in milliseconds. Defaults to 600000. */
|
|
239
278
|
timeoutMs?: number;
|
|
279
|
+
/**
|
|
280
|
+
* How long, in milliseconds, the agent may print nothing before it is
|
|
281
|
+
* stopped as stuck; null for no limit. Defaults to the agent's own limit,
|
|
282
|
+
* which is none for an agent that is quiet while it works.
|
|
283
|
+
*/
|
|
284
|
+
idleTimeoutMs?: number | null;
|
|
240
285
|
};
|
|
241
286
|
/**
|
|
242
287
|
* A coding-agent CLI on this machine, set up once and ready to run casts.
|
|
@@ -244,18 +289,15 @@ type LocalAgentCallOptions = AgentCallOptions & {
|
|
|
244
289
|
*/
|
|
245
290
|
declare class LocalAgent implements EngineRouter {
|
|
246
291
|
private readonly runner;
|
|
247
|
-
/** How calls that name no model run. */
|
|
248
|
-
private readonly call;
|
|
249
292
|
readonly agent: LocalAgentId;
|
|
250
293
|
readonly logger: Logger;
|
|
251
|
-
constructor(runner: AgentRunner,
|
|
252
294
|
/** How calls that name no model run. */
|
|
253
|
-
call
|
|
295
|
+
private readonly call;
|
|
296
|
+
constructor(runner: AgentRunner, options: LocalAgentCallOptions);
|
|
254
297
|
/**
|
|
255
298
|
* The same agent with other call options, sharing this one's executable,
|
|
256
299
|
* environment, logger and limit on processes running at once. Options left
|
|
257
|
-
* out keep this agent's;
|
|
258
|
-
* default.
|
|
300
|
+
* out keep this agent's; an option given as undefined takes its default.
|
|
259
301
|
*/
|
|
260
302
|
with(options: LocalAgentCallOptions): LocalAgent;
|
|
261
303
|
/**
|
|
@@ -266,5 +308,5 @@ declare class LocalAgent implements EngineRouter {
|
|
|
266
308
|
private routeTo;
|
|
267
309
|
}
|
|
268
310
|
|
|
269
|
-
export { LocalAgent as a, LOCAL_AGENT_IDS as
|
|
270
|
-
export type { AnswerOrigin as A, LlmProviderId as L, ModelInput as M, NormalizedUsage as N, SchemaEnforcementKind as S, LocalAgentId as b,
|
|
311
|
+
export { LocalAgent as a, LOCAL_AGENT_IDS as g, isLocalAgentId as i };
|
|
312
|
+
export type { AnswerOrigin as A, EngineOutputListener as E, LlmProviderId as L, ModelInput as M, NormalizedUsage as N, SchemaEnforcementKind as S, LocalAgentId as b, LocalAgentCallOptions as c, Logger as d, SessionApproval as e, AgentEffort as f };
|
package/dist/cjs/index.cjs
CHANGED
|
@@ -294,21 +294,27 @@ function renderPrompt(cast, enforcement) {
|
|
|
294
294
|
}
|
|
295
295
|
|
|
296
296
|
function refusedCast(reason) {
|
|
297
|
-
return { success: false, error: reason, reason, attempts: 0 };
|
|
297
|
+
return { success: false, error: reason, reason, attempts: 0, cancelled: false };
|
|
298
298
|
}
|
|
299
299
|
const RETRY_DELAYS_MS = [1e3, 2e3];
|
|
300
300
|
const MAX_ATTEMPTS = RETRY_DELAYS_MS.length + 1;
|
|
301
|
-
async function runCast(engine, request, logger) {
|
|
301
|
+
async function runCast(engine, request, logger, controls = {}) {
|
|
302
302
|
const validate = validatorFor(request.schema);
|
|
303
303
|
let plan = planAttempt(request, chooseEnforcement(engine, request.jsonSchema));
|
|
304
304
|
let lastFailure = { error: "", prompt: plan.prompt };
|
|
305
305
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
306
|
-
|
|
307
|
-
prompt:
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
306
|
+
if (controls.signal?.aborted) {
|
|
307
|
+
return cancelledCast(attempt - 1, attempt > 1 ? lastFailure.prompt : void 0);
|
|
308
|
+
}
|
|
309
|
+
const outcome = await engine.attempt(
|
|
310
|
+
{
|
|
311
|
+
prompt: plan.prompt.full,
|
|
312
|
+
reusablePrefix: plan.prompt.reusablePrefix,
|
|
313
|
+
maxTokens: request.maxTokens,
|
|
314
|
+
enforcement: plan.enforcement
|
|
315
|
+
},
|
|
316
|
+
controls
|
|
317
|
+
);
|
|
312
318
|
const verdict = judgeOutcome(outcome, validate);
|
|
313
319
|
if (verdict.kind === "accept") {
|
|
314
320
|
return {
|
|
@@ -321,7 +327,17 @@ async function runCast(engine, request, logger) {
|
|
|
321
327
|
};
|
|
322
328
|
}
|
|
323
329
|
if (verdict.kind === "give-up") {
|
|
324
|
-
return {
|
|
330
|
+
return {
|
|
331
|
+
success: false,
|
|
332
|
+
error: verdict.error,
|
|
333
|
+
reason: verdict.error,
|
|
334
|
+
attempts: attempt,
|
|
335
|
+
cancelled: false,
|
|
336
|
+
prompt: plan.prompt
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
if (verdict.kind === "cancelled") {
|
|
340
|
+
return cancelledCast(attempt, plan.prompt);
|
|
325
341
|
}
|
|
326
342
|
lastFailure = { error: verdict.error, prompt: plan.prompt };
|
|
327
343
|
if (verdict.kind === "retry-in-prompt") {
|
|
@@ -335,7 +351,7 @@ async function runCast(engine, request, logger) {
|
|
|
335
351
|
if (attempt < MAX_ATTEMPTS) {
|
|
336
352
|
const delayMs = RETRY_DELAYS_MS[attempt - 1];
|
|
337
353
|
logger.warn("LLM call attempt failed; retrying", { attempt, delayMs, error: verdict.error });
|
|
338
|
-
await delay(delayMs);
|
|
354
|
+
await delay(delayMs, controls.signal);
|
|
339
355
|
}
|
|
340
356
|
}
|
|
341
357
|
return {
|
|
@@ -343,6 +359,7 @@ async function runCast(engine, request, logger) {
|
|
|
343
359
|
error: `LLM call failed after ${MAX_ATTEMPTS} attempts. Last error: ${lastFailure.error}`,
|
|
344
360
|
reason: lastFailure.error,
|
|
345
361
|
attempts: MAX_ATTEMPTS,
|
|
362
|
+
cancelled: false,
|
|
346
363
|
prompt: lastFailure.prompt
|
|
347
364
|
};
|
|
348
365
|
}
|
|
@@ -387,6 +404,8 @@ function verdictForFailure(failure) {
|
|
|
387
404
|
return { kind: "retry-in-prompt", error: failure.message };
|
|
388
405
|
case "fatal":
|
|
389
406
|
return { kind: "give-up", error: failure.message };
|
|
407
|
+
case "cancelled":
|
|
408
|
+
return { kind: "cancelled" };
|
|
390
409
|
}
|
|
391
410
|
}
|
|
392
411
|
function readAnswer(answer) {
|
|
@@ -422,8 +441,20 @@ function readStructuredAnswer(envelope) {
|
|
|
422
441
|
function errorMessage(error) {
|
|
423
442
|
return error instanceof Error ? error.message : String(error);
|
|
424
443
|
}
|
|
425
|
-
function
|
|
426
|
-
|
|
444
|
+
function cancelledCast(attempts, prompt) {
|
|
445
|
+
const reason = "The call was cancelled.";
|
|
446
|
+
return { success: false, error: reason, reason, attempts, cancelled: true, prompt };
|
|
447
|
+
}
|
|
448
|
+
function delay(ms, signal) {
|
|
449
|
+
return new Promise((resolve) => {
|
|
450
|
+
const done = () => {
|
|
451
|
+
clearTimeout(timer);
|
|
452
|
+
signal?.removeEventListener("abort", done);
|
|
453
|
+
resolve();
|
|
454
|
+
};
|
|
455
|
+
const timer = setTimeout(done, ms);
|
|
456
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
457
|
+
});
|
|
427
458
|
}
|
|
428
459
|
|
|
429
460
|
const DEFAULT_MAX_TOKENS = 4096;
|
|
@@ -446,7 +477,10 @@ class NuaLlmClient {
|
|
|
446
477
|
jsonSchema,
|
|
447
478
|
maxTokens: params.maxTokens ?? DEFAULT_MAX_TOKENS
|
|
448
479
|
};
|
|
449
|
-
return runCast(route.engine, request, this.engines.logger
|
|
480
|
+
return runCast(route.engine, request, this.engines.logger, {
|
|
481
|
+
onOutput: params.onOutput,
|
|
482
|
+
signal: params.signal
|
|
483
|
+
});
|
|
450
484
|
}
|
|
451
485
|
async castArray(params) {
|
|
452
486
|
const { primaryKey, outputName, schema } = params.output;
|
|
@@ -469,7 +503,10 @@ class NuaLlmClient {
|
|
|
469
503
|
jsonSchema,
|
|
470
504
|
maxTokens: params.maxTokens ?? DEFAULT_MAX_TOKENS
|
|
471
505
|
};
|
|
472
|
-
return runCast(route.engine, request, this.engines.logger
|
|
506
|
+
return runCast(route.engine, request, this.engines.logger, {
|
|
507
|
+
onOutput: params.onOutput,
|
|
508
|
+
signal: params.signal
|
|
509
|
+
});
|
|
473
510
|
}
|
|
474
511
|
}
|
|
475
512
|
|
|
@@ -904,7 +941,8 @@ async function callProvider(call, logger) {
|
|
|
904
941
|
const response = await fetch(request.url, {
|
|
905
942
|
method,
|
|
906
943
|
headers: request.headers ?? {},
|
|
907
|
-
body: toFetchableBody(request.body)
|
|
944
|
+
body: toFetchableBody(request.body),
|
|
945
|
+
signal: call.signal
|
|
908
946
|
});
|
|
909
947
|
status = response.status;
|
|
910
948
|
const reply = await call.parseResponse(response);
|
|
@@ -950,7 +988,7 @@ class HttpEngine {
|
|
|
950
988
|
if (!providerConfigs[provider].strictSchemaModels?.has(model)) return null;
|
|
951
989
|
return strictJsonSchema.toStrictSchema(envelopeSchema);
|
|
952
990
|
}
|
|
953
|
-
async attempt(request) {
|
|
991
|
+
async attempt(request, { signal } = {}) {
|
|
954
992
|
const { provider, model, apiKey } = this.target;
|
|
955
993
|
const config = providerConfigs[provider];
|
|
956
994
|
const responseSchema = request.enforcement.kind === "native" ? request.enforcement.schema : void 0;
|
|
@@ -971,11 +1009,15 @@ class HttpEngine {
|
|
|
971
1009
|
),
|
|
972
1010
|
parseResponse: config.parseResponse,
|
|
973
1011
|
responseLength: (reply) => reply.text.length,
|
|
974
|
-
usage: (reply) => reply.usage
|
|
1012
|
+
usage: (reply) => reply.usage,
|
|
1013
|
+
signal
|
|
975
1014
|
},
|
|
976
1015
|
this.logger
|
|
977
1016
|
);
|
|
978
1017
|
if (call.kind === "failed") {
|
|
1018
|
+
if (signal?.aborted) {
|
|
1019
|
+
return { kind: "failed", failure: { kind: "cancelled", message: "The call was cancelled." } };
|
|
1020
|
+
}
|
|
979
1021
|
return { kind: "failed", failure: failureForStatus(call.status, call.message, responseSchema !== void 0) };
|
|
980
1022
|
}
|
|
981
1023
|
const answer = responseSchema ? readEnvelope(call.reply.text) : { kind: "text", text: call.reply.text };
|
|
@@ -1078,7 +1120,9 @@ class Nua {
|
|
|
1078
1120
|
const result = await this.client.castValue({
|
|
1079
1121
|
model: options?.model,
|
|
1080
1122
|
input: { prompt, data: options?.input ?? null },
|
|
1081
|
-
output: { name: outputName, schema: outputSchema }
|
|
1123
|
+
output: { name: outputName, schema: outputSchema },
|
|
1124
|
+
onOutput: options?.onOutput,
|
|
1125
|
+
signal: options?.signal
|
|
1082
1126
|
});
|
|
1083
1127
|
return toDirectResult(result, Date.now() - startTime);
|
|
1084
1128
|
}
|
|
@@ -1101,7 +1145,9 @@ class Nua {
|
|
|
1101
1145
|
const result = await this.client.castArray({
|
|
1102
1146
|
model: options.model,
|
|
1103
1147
|
input: { prompt, data: options.input },
|
|
1104
|
-
output
|
|
1148
|
+
output,
|
|
1149
|
+
onOutput: options.onOutput,
|
|
1150
|
+
signal: options.signal
|
|
1105
1151
|
});
|
|
1106
1152
|
return toDirectResult(result, Date.now() - startTime);
|
|
1107
1153
|
}
|
|
@@ -1114,8 +1160,8 @@ function enginesFor(config) {
|
|
|
1114
1160
|
}
|
|
1115
1161
|
function toDirectResult(result, latencyMs) {
|
|
1116
1162
|
if (!result.success) {
|
|
1117
|
-
const { error, reason, attempts } = result;
|
|
1118
|
-
return { success: false, error, reason, attempts, source: "direct", latencyMs };
|
|
1163
|
+
const { error, reason, attempts, cancelled } = result;
|
|
1164
|
+
return { success: false, error, reason, attempts, cancelled, source: "direct", latencyMs };
|
|
1119
1165
|
}
|
|
1120
1166
|
return {
|
|
1121
1167
|
success: true,
|