Skip to content

Commit 98b7634

Browse files
author
Jason Robert
committed
Merge remote-tracking branch 'origin/main' into fix/dialog-multiline-terminal-input
# Conflicts: # CHANGELOG.md
2 parents f3faeb8 + 0939597 commit 98b7634

12 files changed

Lines changed: 1503 additions & 1569 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ step-by-step checklist.
229229
- **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.
230230
- **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.
231231

232-
- **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+
- **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. A second, density-calibrated estimate guards the hard window against token-dense content (CJK, base64, minified data) the ~4-chars-per-token heuristic undercounts: it matches the heuristic on ordinary prose, so it never compacts a history that is merely large, and when it fires the tier chain is driven directly against that measurement rather than delegated to the inner strategy's own (heuristic) gate. Token telemetry stays on the primary scale — the density value travels as `density_tokens` and `trigger_reason` on the start event, with `degraded_estimators` / `still_over_window` on the complete event and an `agent_compaction_skipped` event when both estimators fail. 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.
233233

234234
### Debugging `--web-bg` failures
235235

CHANGELOG.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased](https://github.com/microsoft/conductor/compare/v0.1.36...HEAD)
99

10+
### Fixed
11+
12+
- **`openai`: retry transient errors delivered inside an SSE stream** (#506) —
13+
the OpenAI SDK raises a bare `openai.APIError` (no HTTP status) for an
14+
`error` object embedded in a stream, which pydantic-ai does not translate,
15+
so a configured `retry:` policy was skipped and the run failed after the
16+
first attempt. Now retried: OpenAI mid-stream 5xx (`server_error` /
17+
`internal_server_error`), OpenAI rate limits (`type` `requests` / `tokens`
18+
with code `rate_limit_exceeded`), Anthropic-shaped gateway errors proxied
19+
unchanged (`rate_limit_error` / `overloaded_error` / `api_error`), and
20+
stream errors with no parseable payload `type` (a non-object `error` value
21+
from an Ollama/vLLM gateway, or an Azure-style `{"code": ...}` shape),
22+
which are treated like broken streams. Still fatal: recognized client-side
23+
payload types (e.g. `invalid_request_error`) and every HTTP 4xx. Errors a
24+
narrowed `retry_on:` declines are now wrapped in `ProviderError` naming the
25+
declined category instead of escaping as raw SDK exceptions, a declined
26+
retry is logged at warning level (a taken one already was), and a fatal
27+
bare `APIError`'s message now carries the payload `type`/`code` the SDK
28+
leaves out of `str(e)`.
29+
1030
### Added
1131

1232
- **Opt-in `runtime.provider.setting_sources` on `claude-agent-sdk`** (#501) —
@@ -29,6 +49,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2949

3050
### Fixed
3151

52+
- **Context compaction window guard against token-dense drift** (#507) — the
53+
`claude` / `openai` providers' compaction trigger anchors on
54+
provider-reported token usage and estimates everything after the anchor
55+
with a ~4-characters-per-token heuristic, which undercounts token-dense
56+
content (CJK and other non-Latin scripts, base64, hex, minified data) by
57+
2-4x. A dense suffix could therefore grow the real request past a known
58+
context window while the trigger estimate stayed below the threshold, and
59+
the provider rejected the request with `context_length_exceeded`. A second,
60+
density-calibrated estimate now guards the hard window: it matches the
61+
primary heuristic on ordinary prose, counts text with a substantial
62+
non-ASCII share at ~1 token per character, and whitespace-poor ASCII blobs
63+
at ~2 characters per token, so it fires only on genuinely dense content —
64+
never on a history that is merely large. When it fires, the tier chain is
65+
driven directly against that measurement (the inner strategy's own gate
66+
would re-measure with the same heuristic that under-counted the content
67+
and no-op), until the estimate is back under the target. Telemetry stays on
68+
the token scale: `agent_compaction_start` gains `trigger_reason`
69+
(`"trigger"` / `"window_guard"`) and a separate `density_tokens` field
70+
instead of overloading `tokens_before`, and `agent_compaction_complete`
71+
gains `degraded_estimators` and `still_over_window` so a guard compaction
72+
that could not get back under the window reads as degraded, not as false
73+
success. A failed primary measurement falls back to an independent
74+
density-calibrated estimate that shares no code with it, and a double
75+
failure is reported as a new `agent_compaction_skipped` event
76+
(`reason: "estimate_unavailable"`) rather than vanishing into stderr. See
77+
[Workflow Syntax → Context Compaction](docs/workflow-syntax.md#context-compaction).
78+
3279
- **A multi-line reply to a terminal dialog is now one turn** (#509) —
3380
dialog mode was the only free-text human-input surface that could not accept
3481
a multi-line answer (`QuestionDef.multiline` defaults to `True` and

docs/workflow-syntax.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2426,6 +2426,14 @@ Below is how these values resolve in practice for different configurations using
24262426

24272427
When the reserve (output limit plus effective tool buffer) leaves no viable headroom below the window, compaction is disabled for the agent execution rather than armed with a degenerate threshold. The `agent_compaction_config` event then carries `enabled: false` and a `disabled_reason`, so the condition is visible per run instead of surfacing as a one-shot log warning. To resolve this, lower `runtime.max_tokens` or `tool_output.max_chars`.
24282428

2429+
#### Window Guard Against Token-Dense Content
2430+
2431+
The trigger is measured by the primary estimator: the provider's reported token usage for the history up to the most recent response, plus a ~4-characters-per-token heuristic for everything after it. That heuristic undercounts token-dense content — CJK and other non-Latin scripts, base64, hex, or minified data — by 2-4x, so a dense suffix can grow the real request past the known context window while the trigger estimate stays below the threshold.
2432+
2433+
A second, density-calibrated estimate guards the hard window. It matches the primary heuristic on ordinary prose, counts text with a substantial non-ASCII share at ~1 token per character, and whitespace-poor ASCII blobs at ~2 characters per token. When that estimate reaches the known window, compaction runs even if the trigger never fired, and the tier chain is driven against the density-calibrated measurement until the history fits the target. Because the two estimates agree on ordinary text, the guard never compacts a history that is merely large.
2434+
2435+
The start event reports which gate fired via `trigger_reason` (`"trigger"` or `"window_guard"`) and carries the density-calibrated value separately as `density_tokens`; `tokens_before` always stays the primary token estimate.
2436+
24292437
### Compaction Tiers
24302438

24312439
Conductor uses three sequential tiers to compress the history down to the target:
@@ -2463,12 +2471,13 @@ All tokens consumed by summarizing compaction are added to the workflow's total
24632471

24642472
### Observability and Events
24652473

2466-
Compaction operates in a fail-open manner. If an error occurs during compaction, Conductor logs a warning, disables compaction for the rest of that agent's execution, and continues with the uncompacted history.
2474+
Compaction operates in a fail-open manner. If an error occurs during compaction, Conductor logs a warning, disables compaction for the rest of that agent's execution, and continues with the uncompacted history. A failed context measurement never disables anything: the primary estimate falls back to an independent density-calibrated one, and only when both fail is compaction skipped for that request alone, reported as `agent_compaction_skipped` with `reason: "estimate_unavailable"`.
24672475

2468-
Conductor emits three event types to track compaction:
2476+
Conductor emits four event types to track compaction:
24692477
* `agent_compaction_config`: Emitted once at the start of agent execution to log resolved window and limit values.
2470-
* `agent_compaction_start`: Emitted when context size exceeds the trigger threshold and compaction begins.
2471-
* `agent_compaction_complete`: Emitted when compaction completes, detailing token savings or errors.
2478+
* `agent_compaction_start`: Emitted when compaction begins. `trigger_reason` names the gate that fired (`"trigger"` or `"window_guard"`), and `density_tokens` carries the density-calibrated estimate alongside the primary-scale `tokens_before`.
2479+
* `agent_compaction_complete`: Emitted when compaction completes, detailing token savings or errors. Degraded outcomes are named rather than hidden: `degraded_tiers` for recovered tier failures, `degraded_estimators` for lost measurements, `still_over_trigger` when the history remains above the trigger, and `still_over_window` when a window-guard compaction could not get back below the known window.
2480+
* `agent_compaction_skipped`: Emitted when compaction did not run because the context size could not be measured at all.
24722481

24732482
### Dashboard Caveat
24742483

src/conductor/cli/run.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1242,13 +1242,19 @@ def on_event(self, event: WorkflowEvent) -> None:
12421242
messages_after = d.get("messages_after", 0)
12431243
elapsed = d.get("elapsed", 0.0)
12441244
degraded_tiers = d.get("degraded_tiers") or []
1245+
degraded_estimators = d.get("degraded_estimators") or []
12451246
still_over_trigger = d.get("still_over_trigger", False)
1246-
if degraded_tiers or still_over_trigger:
1247+
still_over_window = d.get("still_over_window", False)
1248+
if degraded_tiers or degraded_estimators or still_over_trigger or still_over_window:
12471249
reasons: list[str] = []
12481250
if degraded_tiers:
12491251
reasons.append(f"tier(s) degraded: {', '.join(degraded_tiers)}")
1252+
if degraded_estimators:
1253+
reasons.append(f"estimator(s) degraded: {', '.join(degraded_estimators)}")
12501254
if still_over_trigger:
12511255
reasons.append("history remains above the trigger")
1256+
if still_over_window:
1257+
reasons.append("history remains above the known context window")
12521258
verbose_log(
12531259
styled(
12541260
" WARNING: context compacted for '[bold]{}[/bold]': "
@@ -1277,6 +1283,17 @@ def on_event(self, event: WorkflowEvent) -> None:
12771283
)
12781284
)
12791285

1286+
elif t == "agent_compaction_skipped":
1287+
verbose_log(
1288+
styled(
1289+
" WARNING: compaction skipped for '[bold]{}[/bold]' ({}) — "
1290+
"context size could not be measured for this request",
1291+
d.get("agent_name", "?"),
1292+
d.get("reason", "unknown"),
1293+
),
1294+
style="yellow",
1295+
)
1296+
12801297
elif t == "guidance_received":
12811298
pending = d.get("pending", 1)
12821299
verbose_log(

0 commit comments

Comments
 (0)