Skip to content

Commit ff4b312

Browse files
authored
feat(providers): always-on client-side context compaction for pydantic-ai providers (claude, openai) (#503)
* build(deps): add pydantic-ai-harness and bump pydantic-ai-slim floor * feat(providers): add context-window and output-limit resolution cascades for compaction * feat(providers): assemble tiered compaction capability for pydantic-ai agents * feat(providers): enable always-on tiered compaction for claude and openai providers * feat(providers): emit compaction lifecycle events for pydantic-ai agents * feat(cli): render compaction lifecycle events in console output - Add ConsoleEventSubscriber branches for agent_compaction_config, start, and complete. - Use styled()/join() for markup-safe interpolation per AGENTS.md rules. - Extend test_logging.py with config/start/success/failure render tests. - Add JSONL verbatim round-trip test and replay skip-list guard. * feat(dashboard): show compaction lifecycle events in the activity stream - Add agent_compaction_config/start/complete to the EventType union and payload interfaces. - Append activity-log entries for compaction lifecycle events on the active node. - Add Vitest coverage for config/start/complete/success/error and replay-state neutrality. - Regenerate static/ assets via make build-frontend. * docs: document automatic context compaction for pydantic-ai providers * feat(providers): unify default max_tokens at 16384 for claude and openai * feat(providers): resolve effective anthropic max_tokens and read model output cap for compaction * fix(providers): additive compaction reserve from effective max_tokens and tool-output config * docs: align compaction reserve docs with effective-max-tokens formula * feat(providers): add vendor model-listing token-limit parser for compaction * refactor(providers): drop the global compaction context-window env override * fix(providers): paginate the full Anthropic model listing for token-limit metadata * feat(providers): read vendor-advertised model token limits in the OpenAI provider * fix(providers): handle OpenAI SDK AsyncPaginator in model metadata fetch * test(providers): pin compaction cascade behavior with provider-advertised limits * docs: document provider-advertised token limits and drop the window env override * fix(providers): harden compaction metadata parsing and close lifecycle Defensive model-listing parser for hostile Mapping/property access. Broad exception handling in Claude metadata cache population. Reset Claude unavailable-listing latches in close(). Restore OpenAI get_model_capabilities token fields to None and plain in membership. Add regression tests for parser hostility, proxy-prefix negative pin, and close reset. * fix(providers): emit compaction start event before the inner strategy runs The fail-open wrapper emitted agent_compaction_start only after the inner tiered strategy (including the summarizer's network call) had already returned, and hardcoded elapsed to 0.0, so console/JSONL/dashboard showed an instant start/complete pair instead of a live lifecycle. Emit start immediately before delegating when the estimate crosses the trigger, and measure the real elapsed time. * fix(providers): warn only for explicit models and single-flight model listing The available-models warning fired for the hardcoded provider default (e.g. gpt-5-mini) even when every agent overrides it with its own model:, producing a misleading warning on proxies that don't list the default. Warn only when the model was explicitly requested via the constructor. Also move the models.list() fetch under the cache lock with a double-checked pattern so concurrent first-callers issue exactly one round-trip instead of a stampede, and honor a per-agent max_tokens attribute (issue #471 groundwork) when resolving the compaction output limit in both providers. * test(providers): pin explicit-model warning, listing single-flight, per-agent compaction limit Cover the companion provider fixes: no warning for the hardcoded default model when agents override it (warning retained for an explicitly requested model), exactly one models.list() call under concurrent first-callers, and the compaction output limit honoring a per-agent max_tokens attribute with source "settings". * fix(providers): address PR #503 review findings on context compaction Blocking: - measure post-compaction tokens via heuristic reclaim (mirroring TieredCompaction._escalate) so tokens_saved is non-zero on real compactions instead of always reporting before == after - replace the degenerate trigger=1 floor with resolve_compaction_plan: the tool buffer is clamped to 25% of the window, the target keeps a window-scaled 5% hysteresis margin below the trigger, and a plan with no viable headroom is disabled (reported on agent_compaction_config via enabled/disabled_reason) instead of compacting on every request - keep max_tokens off the OpenAI wire when unset; the compaction reserve still falls back to the 16384 default internally, so reasoning models keep the server's full output allowance Recommended: - fold ANTHROPIC_BASE_URL into ClaudeProvider._base_url so has_custom_base_url gates registry lookups for env-configured proxies - bound Anthropic/OpenAI model-listing drains at 2000 entries, handle partial listings explicitly, never cache an empty listing, and narrow the catch to transport errors so parser bugs surface - pin pydantic-ai-harness (<0.25) and pydantic-ai-slim (<3), and declare genai-prices as a direct dependency - delete the dead _ThresholdGatedCompaction gate (its branches were identical and duplicated the wrapper's own estimate) - split the wrapper's failure handling into three zones so a telemetry failure no longer latches compaction off or reports false failure, and name degraded tiers / still-over-trigger on agent_compaction_complete - implement AgentProvider.get_max_output_tokens on the Copilot provider - style the four compaction activity types in the dashboard, render tokens_saved, and surface disabled/degraded states - rebuild examples/compaction.yaml around a multi-turn MCP tool loop (loop-back iterations never accumulated provider history) and fix the inverted trigger-direction comment - correct docs claiming ModelInfo.max_tokens caps the wire value, the dashboard-bar refresh timing, and split the CHANGELOG entry into Added/Changed (dropping the nonexistent 64k-fallback removal note)
1 parent ead6c76 commit ff4b312

48 files changed

Lines changed: 6370 additions & 221 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,8 @@ step-by-step checklist.
228228
- **Hardened dashboard request surface** (issue #397): three independent layers, none individually load-bearing. (1) `web/auth.py::OriginHostGuard`, a pure-ASGI middleware (`app.add_middleware(...)`, not `BaseHTTPMiddleware`/`@app.middleware("http")` — neither sees WebSocket scopes) validating `Host` (required, must name the bound machine) and `Origin` (only checked when present — httpx/curl/`conductor gate respond` send none, and that path must keep working with no extra setup) on every HTTP and WebSocket request. `CONDUCTOR_WEB_ALLOW_ORIGINS` (comma-separated full origins) is the dev-server escape hatch, additive only. (2) A per-run token, minted automatically (`mint_token()`) so the protected configuration is the default; `CONDUCTOR_GATE_TOKEN` overrides it (`resolve_expected_token`). Required on every mutating route (`/api/stop`, `/api/kill`, `/api/resume`, `/api/gate-respond`, `/api/guidance`) and on the `/ws` handshake — the single auth point for the socket, since an unauthenticated connection is closed (`websocket.close`, code 1008) in reply to `websocket.connect`, before `accept()`, so it can never send *any* message type. This is strictly stronger than the pre-#397 per-message check, which browsers could never satisfy at all. Read-only routes (`/api/state`, `/api/info`, `/api/logs`, `/api/gate-status`, `/api/files/*`, the whole replay app) stay unauthenticated, protected by origin/host only. (3) `Content-Type: application/json` required on every mutating route (415 otherwise), including the bodyless control POSTs. **Token discovery:** `WebDashboard.start()` writes a `0600` file (POSIX; on Windows the mode is not honoured and the file relies on the user-profile NTFS ACL instead) at `~/.conductor/runs/dashboard-<port>.token` once the port resolves (works for both `--web` and `--web-bg`, since `cli/run.py` calls `start()`/`stop()` on both the run and resume paths); `stop()` removes it. `conductor gate respond`, `conductor guide`, and `conductor stop`'s graceful-kill rung all resolve a token via the shared `resolve_cli_token(port, token)`: `--token` > `CONDUCTOR_GATE_TOKEN` > the token file. See `docs/cli-reference.md` (Environment Variables, and the Authentication sections under `conductor gate respond` / `conductor guide`) and the `web/` bullets above for the full mechanism.
229229
- **MCP server exposure** (`mcp:` workflow block, `conductor mcp serve`, issue #432): `workflow.mcp` (`config/schema.py::McpConfig`) is a typed, `extra="forbid"` block — `expose` (default `true`; every workflow is a candidate for MCP tool exposure with no editing required, DD4), `mode` (`async`/`sync`/`auto`; the default a generated tool's omitted `_wait_seconds` resolves to), `read_only` / `destructive` (surfaced as the generated tool's `readOnlyHint`/`destructiveHint` annotations), and `estimated_minutes` (a client-side hint, must be positive). `conductor validate` reports an unknown key inside it as a schema error, not silence (FR11) — it cannot ride on the existing untyped `metadata: dict`. See `examples/mcp-serve.yaml` and `docs/mcp-server.md` (the user-facing guide: host configuration, the exposure ladder, toolsets, the run lifecycle, and a dedicated *Limits* section for DD5/DD9/DD11/DD12/R4) and `src/conductor/mcp/serve/` above for the server that reads it. **R1 — this feature's terminal run record is a scope change to `conductor status` and `conductor fleet list`, not an MCP-only addition**: every run — MCP-launched or not — now writes a completion tombstone, so both commands (and the Fleet TUI's History screen) gained a completed-runs section as a side effect, with `--live` restoring the exact pre-change scope. See the `fleet/records.py` (`TerminalRunRecord`) bullet above and the `CHANGELOG.md` entry for the full description of what changed.
230230

231+
- **Context compaction**: Always-on client-side context window compaction for the `claude` and `openai` providers. Compaction is triggered proactively using the reserve-based formula `trigger = window - (output_limit + buffer)` and targets a clamped 55% hysteresis ceiling. In this formula, the `output_limit` resolves to the minimum of the effective `max_tokens` sent to the API (from settings or defaults) and the model output cap (from provider-cap). The tool-output-derived `buffer` is calculated as `2 * ceil(max_chars / 4) + 15,000` tokens as a heuristic for worst-case tool results. Resolution prioritizes cascades: provider-advertised metadata (with full pagination for Anthropic and a vendor-field parser for OpenAI-compatible endpoints), public registry, and conservative fallback. The wrapper operates in a fail-open manner, disabling itself for the rest of the agent execution upon outer failure. Compaction events are emitted to the dashboard and console. A summarizing compaction step runs a nested model call that consumes a request slot from the agent's `max_agent_iterations` budget, which doesn't get refunded. The dashboard context bar relies on provider-only limits and may disagree with the compaction window.
232+
231233
### Debugging `--web-bg` failures
232234

233235
When a `conductor run --web-bg` (or `resume --web-bg`) child dies before
@@ -389,6 +391,7 @@ All providers must maintain feature parity where applicable. Any change to one p
389391
- **Session management**: Same lifecycle (`validate_connection()`, `execute()`, `close()`)
390392
- **Reasoning effort**: All providers must accept the unified `reasoning.effort` field (`low` | `medium` | `high` | `xhigh` | `max`), translate it to the native API (Copilot `reasoning_effort` on the session; Claude extended `thinking` budget), validate that the selected model supports the requested effort, and raise `ValidationError` with a clear message when it does not. The one declared exception is Hermes, whose `CAPABILITIES.reasoning_effort` omits `max` (unverified upstream support) — this is not a parity violation because the provider both declares the narrower tuple and enforces it at execute time, matching the "declare the weaker value and honor it" rule in the Experimental Providers section below. Any reasoning/thinking content the model returns must be surfaced via `agent_reasoning` events so the dashboard, JSONL logger, and console subscriber render it consistently.
391393
- **Model pricing hook** (issue #265): `AgentProvider.get_model_pricing(model) -> ModelPricing | None` is an **optional** hook (base default returns `None`) that lets a provider supply live per-model rates. Cost resolution order in `engine/pricing.py::get_pricing` is workflow `cost.pricing` override → provider hook → `DEFAULT_PRICING``None`. The engine bridges the async hook to the sync `UsageTracker.record` via `WorkflowEngine._ensure_pricing_resolved(agent, model)` (called before every `record()`; resolves each model once, caches on the tracker). Only Copilot implements it (derives USD from the SDK's `billing.token_prices` in AI Credits, `100 credits = $1` via `_COPILOT_USD_PER_CREDIT`); it must **never raise** (fall back to the table). `WorkflowUsage` exposes `unpriced_agents` / `unpriced_models` / `has_unpriced` so the CLI summary and dashboard surface `~$X (N agents unpriced)` instead of presenting a partial as a complete total.
394+
- **Max token-limit hooks**: `AgentProvider.get_max_prompt_tokens` / `get_max_output_tokens` are **optional** hooks (base default returns `None`) that let a provider supply its SDK-reported per-model input/output caps; compaction uses the output cap to size its output reserve. `claude`, `openai`, and `copilot` implement both; `hermes`, `aca`, and `claude-agent-sdk` legitimately return `None` from the default (no SDK model-listing surface), which the compaction resolution cascade treats as a first-class "fall through to the registry/fallback" signal rather than an error.
392395
- **Context-window measurement** (issue #412): every provider must populate `AgentOutput.last_call_input_tokens` with the prompt-token size of the most recent *single* API call in the execution, or leave it `None` — it must never be an aggregate. This is distinct from `input_tokens`, which sums every call for billing and is not comparable to a model's context-window cap. Per-provider asymmetry: Copilot's `assistant.usage` event and pydantic-ai's `RequestUsage.input_tokens` already include cache reads/writes, so the field is the raw per-call value; the Claude Agent SDK's Anthropic-shaped usage dict reports cached prompt tokens separately, so the provider must sum `input_tokens + cache_read_input_tokens + cache_creation_input_tokens`. A provider that cannot isolate one call (e.g. Hermes after a parse-recovery run, which spins up a fresh `AIAgent` whose usage never reaches the outer result) reports `None` rather than guessing, which is a first-class "hide the bar" signal, not a fallback to the wrong number.
393396
- **Cache-inclusive token accounting**: `AgentOutput.input_tokens` is the **total** prompt across every call and must always *include* `cache_read_tokens` and `cache_write_tokens` — the cache fields are subsets of it, never additions to it. Copilot and pydantic-ai report it that way natively (pydantic-ai's own `UsageBase` documents the identical convention and normalizes the providers that don't); the Claude Agent SDK's Anthropic-shaped usage dict reports cached tokens *outside* its own `input_tokens`, so that provider folds them in via `claude_agent_sdk.py::_read_usage` — the same summation the context figure above uses — and surfaces both buckets alongside. Parsing those three keys in one helper is deliberate: reading them inline at each accumulation site is what let the `.get()` defaults drift apart and double-count the cache. `engine/pricing.py::calculate_cost` then subtracts the buckets back out before applying the input rate, so each physical token is priced exactly once — but **only subtracts a bucket that has a rate to be charged at**, because a `0.0` cache rate in `DEFAULT_PRICING` means "no published rate" (20 of its entries — every GPT, o-series and Gemini model) rather than "free", and subtracting unconditionally would price a cached prompt at nothing. Both halves match `genai_prices.types.ModelPrice.calc_price`; the one deliberate divergence is that it *raises* on a negative residual where conductor clamps and logs once per model, since a cost annotation must never abort a run. Billing the buckets additively instead overstates a cached tool-calling run by roughly an order of magnitude. A provider that populates the cache fields but leaves `input_tokens` exclusive under-bills instead; a provider that populates neither reports cache-free totals and is unaffected (`hermes`), and `aca` satisfies it transitively by relaying its in-sandbox provider's already-compliant figures.
394397

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
108108
this bound restores them; the pin does not change behavior for anyone
109109
already on `mcp` 1.x.
110110

111+
### Added
112+
113+
- **Automatic client-side context compaction for pydantic-ai providers** (`claude` and `openai`). Conductor automatically condenses conversation history when it crosses a calculated trigger threshold. The trigger is computed using an additive reserve formula — context window minus output limit minus an effective tool-output-derived buffer (clamped to at most 25% of the window) — and compaction is disabled with a `disabled_reason` on the `agent_compaction_config` event when the remaining trigger would fall below 4096 tokens. To condense history, the compaction process runs a three-stage strategy: clearing old tool results first, summarizing older messages with a nested model call, and sliding the window as a deterministic fallback.
114+
- **Provider-advertised model token-limit metadata** for compaction sizing. The `claude` and `openai` providers read per-model input/output token limits from their SDK model listings (with full pagination for the Anthropic SDK, and a vendor-field parser for OpenAI-compatible endpoints), the `copilot` provider implements the `get_max_output_tokens` hook, and resolution falls back through the `genai-prices` registry to a conservative default.
115+
116+
### Changed
117+
118+
- **Claude default `max_tokens` raised from 8192 to 16384** when unset. This doubles the worst-case output cost per call for users who never set it; set `runtime.max_tokens` explicitly to keep the former behavior. For Claude thinking agents, `low` or `medium` effort levels without an explicit `max_tokens` limit now send 16384 tokens instead of the former 8192 or 12288 tokens.
119+
- **The `openai` provider honors vendor-advertised token limits** from the models listing when available, using them to size the compaction output reserve.
120+
111121
## [0.1.35](https://github.com/microsoft/conductor/compare/v0.1.34...v0.1.35) - 2026-08-28
112122

113123
### Removed

docs/configuration.md

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -436,12 +436,12 @@ Maximum OUTPUT tokens per response:
436436
```yaml
437437
workflow:
438438
runtime:
439-
max_tokens: 4096 # Required for Claude
439+
max_tokens: 4096 # Optional: defaults to 16384 when unset
440440
```
441441

442442
**Limits**:
443443
- Haiku: 4096 max
444-
- Sonnet/Opus: 8192 max
444+
- Sonnet/Opus: Conductor defaults `max_tokens` to 16384 when unset and does not clamp the configured value to the model's advertised cap — a value above the model limit is rejected by the API. The provider-advertised `ModelInfo.max_tokens` is used only to size the compaction output reserve.
445445

446446
**Note**: This is output tokens, not context window (200K separate limit)
447447

@@ -496,16 +496,9 @@ agents (none of which call a model).
496496
| `xhigh` | 32 768 |
497497
| `max` | 59 904 |
498498

499-
`max` is pinned to `64000 − 4096` — the largest budget that still leaves the
500-
default answer headroom under the 64000-token extended-thinking output cap
501-
(at `max`, `max_tokens` lands exactly on the cap).
499+
The `max` budget is pinned to `64000 - 4096`, which is the largest budget that still leaves the default answer headroom under the 64000-token cap (at `max`, `max_tokens` lands exactly on the cap).
502500

503-
Extended thinking is only valid on thinking-capable models
504-
(`claude-3-7-*`, `claude-opus-4*`, `claude-sonnet-4*`, `claude-haiku-4*`); a
505-
`ValidationError` is raised otherwise. The provider also auto-coerces
506-
`temperature` to `1.0` (required by the Anthropic API for extended thinking,
507-
logged at INFO) and bumps `max_tokens` to fit `budget + 4096`, capped at
508-
`64000` (logged at INFO when clamped).
501+
Extended thinking is only valid on thinking-capable models, including `claude-3-7-*`, `claude-opus-4*`, `claude-sonnet-4*`, and `claude-haiku-4*` formats. A `ValidationError` is raised otherwise. The provider also auto-coerces `temperature` to `1.0` (required by the Anthropic API for extended thinking, logged at INFO) and bumps `max_tokens` to at least `budget + 4096`, capped at `64000` (logged at INFO when clamped). For `low` and `medium` efforts, because the budget plus headroom is below 16384, the default `max_tokens` of 16384 is sent.
509502

510503
Reasoning / thinking content emitted by the model is surfaced via
511504
`agent_reasoning` events and rendered in the dashboard, JSONL logs, and
@@ -790,7 +783,7 @@ Always set `max_tokens`:
790783

791784
```yaml
792785
runtime:
793-
max_tokens: 8192
786+
max_tokens: 16384
794787
```
795788

796789
### "temperature must be between 0.0 and 1.0" (Claude)

0 commit comments

Comments
 (0)