Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,45 @@

All notable changes to bare-agent are documented here. Format: [Keep a Changelog](https://keepachangelog.com/). Versioning: [SemVer](https://semver.org/).

## [0.35.0] - 2026-07-28

### Added

- **BA-19 — a total call-duration deadline (`deadlineMs`) on the http(s) providers, beside the
BA-18 idle timeout.** BA-18's `timeoutMs` bounds socket **inactivity** (`req.setTimeout`), and by
design it resets on *any* socket activity — so a response that trickles a byte forever (a "zombie
stream") never trips it. Bytes keep arriving while the response never completes, and the call
hangs until the OS TCP timeout. An adopter observed one `generate()` run **274 minutes** and end
in `read ECONNRESET` (not a `TimeoutError`) — the reset is the proof of mechanism: bytes *were*
arriving for 4.5h, so the idle timer never fired. `timeoutMs` is the right bound for the quantity
it watches; nothing bounded *total* call duration.
- New `deadlineMs` option on all four providers — an absolute, **non-resetting** wall-clock
ceiling on the whole request. **Disabled by default** (a deliberately long single call — large
`maxTokens`, slow model — is legitimate; a default here would kill it). Overridable per call via
`generate(..., { deadlineMs })`; same disable idiom as `timeoutMs` (`0`/`Infinity`), and a
per-call `null`/`undefined` inherits the instance value. **Disable-edge is fail-loud, not
fail-silent:** an *unset* deadline resolves to disabled, but an *explicitly-set* garbage value
(`NaN`, a non-numeric string like `'30s'`) throws a `ValidationError` at resolve time rather than
silently disabling the deadline and running unbounded for hours — the idle bound can fall back to
its 10-min default on garbage because that default *is* a real bound, but the deadline has no safe
default to fall back to, so a config mistake must surface (review finding 1).
- On trip, `generate()` rejects with a **terminal `TimeoutError`** distinguishable from the idle
trip: `code: 'EDEADLINE'`, `context.bound: 'deadline'`, and `retryable: false` — a deadline is a
hard ceiling the caller set to **stop**, so it is not auto-retried (retrying would re-spend up to
another full `deadlineMs` of tokens/budget). The idle trip now also carries `context.bound:
'idle'`, so a consumer can switch on one uniform field to tell which timer fired. A consumer that
*wants* to retry a deadline can still opt in via `retryOn`.
- When both bounds are armed and `timeoutMs < deadlineMs`, a silent socket trips the idle bound
first; only a still-active-but-never-completing stream reaches the deadline.
- Implemented once in `src/provider-http.js` (`applyRequestDeadline`; `resolveTimeoutMs` gained a
`defaultMs` parameter so the deadline resolves to `0`/disabled by default while the idle bound
keeps its 10-min default). Each provider's `_request` wires all bounds through one
`applyRequestBounds(req, { timeoutMs, deadlineMs }, name)` seam, so the idle (BA-18) + deadline
(BA-19) wiring is not copy-pasted at four call sites and a future third bound is added in one
place (review finding 2). CLIPipe already bounds its child process on wall-clock and is unchanged.
- **Filed with its own limiting evidence:** n=1 (one 274-minute hang). This is a defense-in-depth
knob for consumers who bound total work no other way — not a new default.

## [0.34.0] - 2026-07-26

### Added
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions bareagent.context.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# bareagent — Integration Guide

> For AI assistants and developers wiring bareagent into a project.
> v0.34.0 | Node.js >= 18 | zero required deps (`bareguard >=0.9.0 <0.13.0` optional peer for governance) | Apache 2.0
> v0.35.0 | Node.js >= 18 | zero required deps (`bareguard >=0.9.0 <0.13.0` optional peer for governance) | Apache 2.0
>
> Full human guide with composition examples, design philosophy, and recipes: [Usage Guide](docs/02-features/usage-guide.md)

Expand Down Expand Up @@ -829,7 +829,9 @@ All return `{ text, toolCalls, usage: { inputTokens, outputTokens }, model?, cos

**Error body (v0.11.0):** on an HTTP error the OpenAI/Anthropic/Ollama providers throw a `ProviderError` whose `message` carries the upstream error string. The full parsed response is **not** attached to `err.body` by default (so an unexpected field can't leak through logs that dump the error object). Pass `{ exposeErrorBody: true }` to attach it for debugging.

**Request/idle timeout (BA-18, v0.34.0):** the four http(s) providers (Anthropic, OpenAI, Gemini, Ollama) accept a `timeoutMs` option — constructor default **600000 (10 min)**, overridable per call via `generate(..., { timeoutMs })`, and `0`/`Infinity` disables it. Before this they wired only `req.on('error')`, so a socket the server silently dropped — or a response that never starts — hung `generate()` until the OS TCP timeout (~2h): a hang, not an error, so retry/casualty policy above it never fired. `timeoutMs` bounds on socket **inactivity** (`req.setTimeout`), so a slow-but-streaming response is not killed — only a silent/never-answering socket trips it; the 10-min default clears any single non-streaming completion (TTFB ≈ generation time). On trip, `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`, `retryable: true`). **Retry is caller-side and already wired:** `new Loop({ provider, retry: new Retry() })` wraps `provider.generate`, and `DEFAULT_RETRY_ON` classifies `ETIMEDOUT` (and `ECONNRESET`/`ENOTFOUND`/429/5xx) as transient — so a wired `Retry` retries a timed-out request and rethrows under `retryOn: () => false`, with no extra wiring (`run-plan`'s `stepRetry` is a second consumer of the same seam). CLIPipe already bounded its child process (`timeout`, default 30000 for one-shot) and is unchanged.
**Request/idle timeout (BA-18, v0.34.0):** the four http(s) providers (Anthropic, OpenAI, Gemini, Ollama) accept a `timeoutMs` option — constructor default **600000 (10 min)**, overridable per call via `generate(..., { timeoutMs })`, and `0`/`Infinity` disables it. Before this they wired only `req.on('error')`, so a socket the server silently dropped — or a response that never starts — hung `generate()` until the OS TCP timeout (~2h): a hang, not an error, so retry/casualty policy above it never fired. `timeoutMs` bounds on socket **inactivity** (`req.setTimeout`), so a slow-but-streaming response is not killed — only a silent/never-answering socket trips it; the 10-min default clears any single non-streaming completion (TTFB ≈ generation time). On trip, `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`, `context.bound: 'idle'`, `retryable: true`). **Retry is caller-side and already wired:** `new Loop({ provider, retry: new Retry() })` wraps `provider.generate`, and `DEFAULT_RETRY_ON` classifies `ETIMEDOUT` (and `ECONNRESET`/`ENOTFOUND`/429/5xx) as transient — so a wired `Retry` retries a timed-out request and rethrows under `retryOn: () => false`, with no extra wiring (`run-plan`'s `stepRetry` is a second consumer of the same seam). CLIPipe already bounded its child process (`timeout`, default 30000 for one-shot) and is unchanged.

**Total call-duration deadline (BA-19, v0.35.0):** `timeoutMs` bounds socket *inactivity*, and `req.setTimeout` resets on any activity by design — so a "zombie stream" that trickles a byte forever (bytes arriving, the response never completing) never trips it and hangs the caller for hours (an adopter saw one `generate()` run **274 min** and end in `ECONNRESET`, not a `TimeoutError`: the reset proves bytes *were* flowing, so the idle timer never fired). The four http(s) providers now also accept a `deadlineMs` option — an absolute, **non-resetting** wall-clock ceiling on the whole request. **Disabled by default** (a deliberately long single call — large `maxTokens`, slow model — is legitimate; a default here would kill it), overridable per call via `generate(..., { deadlineMs })`, `0`/`Infinity` disable. An *unset* deadline resolves to disabled, but an *explicitly-set* garbage value (`NaN`, a non-numeric string) throws a `ValidationError` at resolve time rather than silently disabling the bound and running unbounded — unlike `timeoutMs`, the deadline has no safe default to fall back to, so a config mistake must surface loudly. On trip, `generate()` rejects with a **terminal** `TimeoutError` distinguishable from the idle trip: `code: 'EDEADLINE'`, `context.bound: 'deadline'`, `retryable: false` — a deadline is a hard ceiling meant to STOP, so it is *not* auto-retried (retrying would re-spend up to another full `deadlineMs`); a consumer that wants retry opts in via `retryOn`. When both are armed and `timeoutMs < deadlineMs`, a silent socket trips the idle bound first; only a still-active-but-never-completing stream reaches the deadline. The idle bound (BA-18) and the deadline (BA-19) are two independent failure modes — a silent socket vs a zombie stream.

**Plaintext-key warning (Unreleased):** the OpenAI provider's `baseUrl` accepts `http://` (for local/OpenAI-compatible endpoints), but a `Bearer` key sent over plaintext http to a **non-loopback** host is exposed on the wire. The provider now warns once when that happens. Loopback hosts (`localhost`/`127.0.0.0/8`/`::1` — local proxies, Ollama-style endpoints) stay silent, since that's the legitimate keyless-local case. The header is **not** stripped (some local proxies want a key), so use `https` for any remote endpoint, or drop `apiKey` when the local endpoint needs none.

Expand Down
44 changes: 44 additions & 0 deletions docs/01-product/prd.md
Original file line number Diff line number Diff line change
Expand Up @@ -809,6 +809,50 @@ re-litigated unless the user explicitly asks.
> back into this main PRD; granular evidence tables for each live in the
> CHANGELOG and git history.

### v0.35.0 / provider total-duration deadline (bareloop BA-19) (2026-07-28)

> **Numbering note.** This is **bareloop's** BA-19 (`docs/UPSTREAM-ASKS.md`), not
> a bareagent-internal BA-number — cross-repo ask numbers can collide.

- **The gap was real (unlike BA-18 part 2).** BA-18's `timeoutMs` bounds socket
*inactivity* via `req.setTimeout`, which resets on any activity by design — so a
"zombie stream" that trickles a byte forever (bytes arriving, response never
completing) is invisible to it. An adopter saw one `generate()` run 274 min and
end in `ECONNRESET`, not a `TimeoutError`: the reset is the proof of mechanism —
bytes *were* flowing, so the idle timer never fired. Nothing bounded *total*
call duration. Confirmed in source before building.
- **Opt-in `deadlineMs`, DISABLED by default (per the ask, and the deliberate
contrast with BA-18's finite default).** An absolute, non-resetting wall-clock
ceiling (a plain `setTimeout` + `req.destroy`, unref'd + cleared on request
close). A deliberately long single call (large `maxTokens`, slow model) is a
legitimate multi-minute stream a default deadline would kill — so this is a
knob, not a new default. `0`/`Infinity` disable; per-call overridable;
`resolveTimeoutMs` gained a `defaultMs` param so the deadline resolves to
`0`/disabled by default while the idle bound keeps its 10-min default.
- **Disable-edge is fail-LOUD for the deadline (review finding 1).** An unset
deadline resolves to disabled, but an explicitly-set garbage value
(`NaN`/non-numeric) throws a `ValidationError` — the idle bound can fall back to
its 10-min default on garbage because that default is a real bound, but the
deadline has no safe default, so a silent disable would reintroduce the very hang
it prevents. Both bounds now wire through one `applyRequestBounds` seam per
`_request` (review finding 2), so a future third bound is added in one place.
- **Terminal, and distinguishable from the idle trip (user decision:
`retryable:false`).** On trip: `code:'EDEADLINE'`, `context.bound:'deadline'`,
`retryable:false` — a deadline is a hard ceiling meant to STOP, so it is not
auto-retried (retrying would re-spend up to another full `deadlineMs`). The idle
trip now also carries `context.bound:'idle'`, so a consumer switches on one
uniform field to route which timer fired. A consumer that *wants* retry opts in
via `retryOn`. Criterion 3 holds: with `timeoutMs < deadlineMs`, a silent socket
trips the idle bound first; only a still-active-but-never-completing stream
reaches the deadline.
- **Same BA-4/5/6/13/17/18 family** — an under-modeled boundary (a zombie stream
has no slot in the neutral result shape) rounding optimistically toward
"still working." POC + loopback trickling-server tests reproduce it faithfully
(a byte every idle/2 ms, never completing) and were mutation-proven, incl. a
deadline-OFF negative control that hangs. Filed n=1 (one 274-min hang) — a
defense-in-depth knob, not load-bearing for the adopter (their harness-layer
watchdog self-heals this class).

### v0.34.0 / provider request-idle timeout (bareloop BA-18) (2026-07-26)

> **Numbering note.** This is **bareloop's** BA-18 (`docs/UPSTREAM-ASKS.md`), not
Expand Down
5 changes: 4 additions & 1 deletion docs/02-features/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,15 @@ The circuit breaker tracks failures per key. After `threshold` failures, calls a
| Error | When | Fix |
|-------|------|-----|
| `[Retry] Timeout` (`TimeoutError`) | A single attempt exceeded the configured `timeout` (default 60s) | Increase `timeout` in constructor or per-call options. `instanceof TimeoutError`, `code: 'ETIMEDOUT'`, `retryable: true` |
| `[<Provider>] request timed out after Nms of socket inactivity` (`TimeoutError`, BA-18) | An http(s) provider's socket went idle past its `timeoutMs` (default 600000; a silently-dropped or never-answering socket). Applies to Anthropic/OpenAI/Gemini/Ollama. | Expected on a dead/hung connection — it's now an error instead of a ~2h hang. `code: 'ETIMEDOUT'`, `retryable: true`. Tune per provider via `timeoutMs` (`0`/`Infinity` disables); wrap the Loop in `Retry` to auto-retry it. |
| `[<Provider>] request timed out after Nms of socket inactivity` (`TimeoutError`, BA-18) | An http(s) provider's socket went idle past its `timeoutMs` (default 600000; a silently-dropped or never-answering socket). Applies to Anthropic/OpenAI/Gemini/Ollama. | Expected on a dead/hung connection — it's now an error instead of a ~2h hang. `code: 'ETIMEDOUT'`, `context.bound: 'idle'`, `retryable: true`. Tune per provider via `timeoutMs` (`0`/`Infinity` disables); wrap the Loop in `Retry` to auto-retry it. |
| `[<Provider>] request exceeded its total deadline of Nms` (`TimeoutError`, BA-19) | An http(s) provider's whole request ran past its opt-in `deadlineMs` (default disabled). Catches a "zombie stream" that trickles bytes forever — which the idle `timeoutMs` cannot, since activity keeps resetting it. Applies to Anthropic/OpenAI/Gemini/Ollama. | A hard wall-clock ceiling; on trip the request is destroyed. `code: 'EDEADLINE'`, `context.bound: 'deadline'`, **`retryable: false`** (a deadline is meant to stop, not re-spend — it is *not* auto-retried by `DEFAULT_RETRY_ON`; opt in via `retryOn` if you want it). Set `deadlineMs` per provider or per call; `0`/`Infinity` disable. |

**Note:** After exhausting `maxAttempts`, Retry rethrows the last error from the wrapped function — it does not add its own prefix. Errors with `err.retryable === true` are automatically retried; `err.retryable === false` bail immediately without consuming remaining attempts.

**Provider request timeout + Retry (BA-18).** The Loop wires `Retry` around `provider.generate` — `new Loop({ provider, retry: new Retry() })` — and the default retry predicate (`DEFAULT_RETRY_ON`) classifies `ETIMEDOUT`/`ECONNRESET`/`ENOTFOUND`/429/5xx as transient. So a provider request that times out (or drops its socket) is retried automatically under a wired `Retry`, and rethrows under `retryOn: () => false`. `run-plan`'s `stepRetry` is the same seam at the step level. Using a provider **without** a Loop? Its own `timeoutMs` still bounds the hang — you just handle the rejection yourself.

**Idle vs total-duration bound (BA-18 vs BA-19).** `timeoutMs` (BA-18, default 10 min) bounds socket *inactivity* — it catches a silent or never-answering socket, but by design resets on any activity, so it cannot catch a "zombie stream" that keeps trickling bytes without ever completing. `deadlineMs` (BA-19, opt-in, disabled by default) is an absolute wall-clock ceiling on the *whole* request that catches exactly that. They compose: with both set and `timeoutMs < deadlineMs`, a silent socket trips the idle bound (`ETIMEDOUT`, retryable) first, and only a still-active-but-never-completing stream reaches the deadline (`EDEADLINE`, terminal). Switch on `err.context.bound` (`'idle'` vs `'deadline'`) to tell which fired. Leave `deadlineMs` unset unless you need to bound total call duration and have no other watchdog — a legitimate long completion (large `maxTokens`, slow model) is a multi-minute stream a deadline would kill. An *unset* `deadlineMs` is disabled, but an *explicitly-set* garbage value (`NaN`, a non-numeric string) throws a `ValidationError` at resolve time rather than silently disabling the bound — the deadline has no safe default to fall back to, so a config mistake surfaces loudly instead of running unbounded.

## CLIPipeProvider

| Error | When | Fix |
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "bare-agent",
"version": "0.34.0",
"version": "0.35.0",
"files": [
"index.js",
"index.d.ts",
Expand Down
Loading