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
52 changes: 28 additions & 24 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -154,23 +154,25 @@ jobs:
import sys
from pathlib import Path

from droste.testing import (
runner_v10_refusal_ndjson,
trace_v9_execution_ndjson,
trace_v9_lifecycle_ndjson,
)
from importlib.resources import files

from droste.testing import conformance_fixture_names

# Enumerated, never named: an ABI rename must not have to be found
# here. Naming the helpers is what broke the v9 and v10 migrations.
source = Path(sys.argv[1])
assert trace_v9_execution_ndjson() == (source / "trace-v9-execution.ndjson").read_bytes()
assert trace_v9_lifecycle_ndjson() == (source / "trace-v9-lifecycle.ndjson").read_bytes()
assert runner_v10_refusal_ndjson() == (source / "runner-v10-refusal.ndjson").read_bytes()
names = conformance_fixture_names()
assert names, "conformance corpus is empty"
for name in names:
packaged = files("droste.testing").joinpath("fixtures", name).read_bytes()
assert packaged == (source / name).read_bytes(), name
PY
sdist_root="$(tar tzf dist/droste-*.tar.gz | sed -n '1s#/.*##p')"
tar xzf dist/droste-*.tar.gz -C "$tmp"
cmp src/droste/testing/fixtures/trace-v9-lifecycle.ndjson \
"$tmp/$sdist_root/src/droste/testing/fixtures/trace-v9-lifecycle.ndjson"
cmp src/droste/testing/fixtures/trace-v9-execution.ndjson \
"$tmp/$sdist_root/src/droste/testing/fixtures/trace-v9-execution.ndjson"
for f in $(cd src/droste/testing/fixtures && ls *.ndjson | sort); do
cmp "src/droste/testing/fixtures/$f" \
"$tmp/$sdist_root/src/droste/testing/fixtures/$f"
done
cmp src/droste/testing/fixtures/runner-v10-refusal.ndjson \
"$tmp/$sdist_root/src/droste/testing/fixtures/runner-v10-refusal.ndjson"

Expand Down Expand Up @@ -368,23 +370,25 @@ jobs:
import sys
from pathlib import Path

from droste.testing import (
runner_v10_refusal_ndjson,
trace_v9_execution_ndjson,
trace_v9_lifecycle_ndjson,
)
from importlib.resources import files

from droste.testing import conformance_fixture_names

# Enumerated, never named: an ABI rename must not have to be found
# here. Naming the helpers is what broke the v9 and v10 migrations.
source = Path(sys.argv[1])
assert trace_v9_execution_ndjson() == (source / "trace-v9-execution.ndjson").read_bytes()
assert trace_v9_lifecycle_ndjson() == (source / "trace-v9-lifecycle.ndjson").read_bytes()
assert runner_v10_refusal_ndjson() == (source / "runner-v10-refusal.ndjson").read_bytes()
names = conformance_fixture_names()
assert names, "conformance corpus is empty"
for name in names:
packaged = files("droste.testing").joinpath("fixtures", name).read_bytes()
assert packaged == (source / name).read_bytes(), name
PY
sdist_root="$(tar tzf dist/droste-*.tar.gz | sed -n '1s#/.*##p')"
tar xzf dist/droste-*.tar.gz -C "$tmp"
cmp src/droste/testing/fixtures/trace-v9-lifecycle.ndjson \
"$tmp/$sdist_root/src/droste/testing/fixtures/trace-v9-lifecycle.ndjson"
cmp src/droste/testing/fixtures/trace-v9-execution.ndjson \
"$tmp/$sdist_root/src/droste/testing/fixtures/trace-v9-execution.ndjson"
for f in $(cd src/droste/testing/fixtures && ls *.ndjson | sort); do
cmp "src/droste/testing/fixtures/$f" \
"$tmp/$sdist_root/src/droste/testing/fixtures/$f"
done
cmp src/droste/testing/fixtures/runner-v10-refusal.ndjson \
"$tmp/$sdist_root/src/droste/testing/fixtures/runner-v10-refusal.ndjson"

Expand Down
30 changes: 30 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,36 @@ Ordered newest first. "Embedder" means anything that builds on the engine
beyond the `droste` CLI: hosts calling `run_rlm` in-process, `droste_runner`
consumers, and Pyodide-substrate integrations staging the Deno relay.

## 0.25.0

### Trace ABI v10: a run reports what it did without

Every result now carries `degradations`, a list that is empty on a clean run
and otherwise names each thing the run continued without: `site`,
`error_type`, `detail`, and — the field that matters to a reader —
`consequence`, which records not that a callback raised but what the run then
did without it.

Several boundaries here deliberately refuse to let a host-supplied callback end
a run: a broken logging sink or observer must not destroy a user's work. That
resilience is correct. It was paid for with a `warnings.warn` that reaches no
consumer of the result, so a run that lost a budget event, ran its terminal
extract without host context, or discarded salvageable work returned something
byte-identical to a clean run. The loss was real and unobservable.

Recorded today at `budget_event_sink`, `extract_context_provider`, and
`extractable_work_probe`. `BudgetLedger.dropped_events()` exposes the same
facts for a directly-held ledger.

**Hosts should treat a non-empty `degradations` as a degraded answer** — worth
surfacing, logging, or refusing, depending on what the run lost. Nothing new
can end a run; this only makes the existing fallbacks visible.

The v9 -> v10 rename moves the `trace_v9_*` helpers and `trace-v9-*` fixtures
to `trace_v10_*` / `trace-v10-*`. Response builders in `droste_runner` emit
`degradations: []` on every shape, so a consumer reads one field rather than
treating a missing key as "nothing was lost".

## 0.24.0

### Trace ABI v9 reports which ready-time gates a run armed
Expand Down
8 changes: 4 additions & 4 deletions docs/trace-abi.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,12 +230,12 @@ and sdist. Python consumers load them through package resources:
```python
from droste.testing import (
runner_v10_refusal_ndjson,
trace_v9_execution_ndjson,
trace_v9_lifecycle_ndjson,
trace_v10_execution_ndjson,
trace_v10_lifecycle_ndjson,
)

execution_lines = trace_v9_execution_ndjson().splitlines()
event_lines = trace_v9_lifecycle_ndjson().splitlines()
execution_lines = trace_v10_execution_ndjson().splitlines()
event_lines = trace_v10_lifecycle_ndjson().splitlines()
pre_admission_refusal = runner_v10_refusal_ndjson()
```

Expand Down
2 changes: 1 addition & 1 deletion examples/pyodide-host/e2e_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const RUNNER_REFUSAL_FIXTURE = new URL(
import.meta.url,
);
const TRACE_LIFECYCLE_FIXTURE = new URL(
"../../src/droste/testing/fixtures/trace-v9-lifecycle.ndjson",
"../../src/droste/testing/fixtures/trace-v10-lifecycle.ndjson",
import.meta.url,
);
const TEST_BUDGET = {
Expand Down
2 changes: 1 addition & 1 deletion pyodide/event_channel_probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ const mode = Deno.args[0];
const channel = eventChannelFromEnvironment();
const fixture = await Deno.readTextFile(
new URL(
"../src/droste/testing/fixtures/trace-v9-lifecycle.ndjson",
"../src/droste/testing/fixtures/trace-v10-lifecycle.ndjson",
import.meta.url,
),
);
Expand Down
2 changes: 1 addition & 1 deletion pyodide/event_channel_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
import { isRlmEvent } from "../src/droste/substrates/_relay/events.ts";

const TRACE_LIFECYCLE_FIXTURE = new URL(
"../src/droste/testing/fixtures/trace-v9-lifecycle.ndjson",
"../src/droste/testing/fixtures/trace-v10-lifecycle.ndjson",
import.meta.url,
);

Expand Down
7 changes: 4 additions & 3 deletions pyodide/events_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ const BODIES: Record<string, Record<string, unknown>> = {
status: "success",
ready: true,
extracted: false,
degradations: [],
iterations: 1,
usage: {
kind: "resolved",
Expand Down Expand Up @@ -170,7 +171,7 @@ function wire(
run_id: "run-1",
seq: 1,
timestamp: "2026-07-14T00:00:00Z",
version: 9,
version: 10,
persistence_class: persistence ?? PERSISTENCE_BY_TYPE[type],
depth: 0,
...body,
Expand Down Expand Up @@ -384,7 +385,7 @@ Deno.test("successful output beginning ERROR remains an output event", () => {

Deno.test("Python and relay accept the same execution golden NDJSON", async () => {
const fixture = new URL(
"../src/droste/testing/fixtures/trace-v9-execution.ndjson",
"../src/droste/testing/fixtures/trace-v10-execution.ndjson",
import.meta.url,
);
const lines = (await Deno.readTextFile(fixture)).trim().split("\n");
Expand Down Expand Up @@ -428,7 +429,7 @@ Deno.test("Python and relay accept the same execution golden NDJSON", async () =

Deno.test("Python and relay accept the same lifecycle golden NDJSON", async () => {
const fixture = new URL(
"../src/droste/testing/fixtures/trace-v9-lifecycle.ndjson",
"../src/droste/testing/fixtures/trace-v10-lifecycle.ndjson",
import.meta.url,
);
const lines = (await Deno.readTextFile(fixture)).trim().split("\n");
Expand Down
4 changes: 2 additions & 2 deletions pyodide/heartbeat_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const { isRlmEvent, PERSISTENCE_BY_TYPE, RLM_EVENT_TYPES } = await import(
function wire(body: Record<string, unknown>): string {
return JSON.stringify({
type: "heartbeat",
version: 9,
version: 10,
run_id: "run-1",
seq: 4,
timestamp: "2026-08-03T00:00:00Z",
Expand Down Expand Up @@ -97,7 +97,7 @@ Deno.test("a heartbeat a live subcall produced is forwarded", () => {
depth: 1,
seq: 2,
timestamp: "2026-08-03T21:46:54.805Z",
version: 9,
version: 10,
persistence_class: "transient",
};

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "droste"
version = "0.24.0"
version = "0.25.0"
description = "Recursive analysis engine for data too large for a context window, built with Recursive Language Models (RLMs)"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
18 changes: 18 additions & 0 deletions src/droste/execution/budget.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@ class BudgetLedger:
_closed: bool = field(default=False, init=False, repr=False)
_event_journal: list[dict[str, Any]] = field(default_factory=list, init=False, repr=False)
_emitted_events: int = field(default=0, init=False, repr=False)
# Events this ledger produced but could not deliver. A failing sink must
# not end the run, but the loss must not vanish either: without this the
# stream is short by one event and nothing anywhere says so.
_dropped_events: list[dict[str, str]] = field(default_factory=list, init=False, repr=False)
_emit_lock: RLock = field(default_factory=RLock, init=False, repr=False)

def __post_init__(self) -> None:
Expand Down Expand Up @@ -538,6 +542,12 @@ def _queue_event_locked(self, action: str, resource: str, amount: int, call_id:
}
)

def dropped_events(self) -> tuple[dict[str, str], ...]:
"""Budget events this ledger produced but failed to deliver."""

with self._lock:
return tuple(dict(item) for item in self._dropped_events)

def _drain_events(self) -> None:
"""Emit the ledger journal in mutation order without holding its lock."""

Expand All @@ -560,6 +570,14 @@ def _drain_events(self) -> None:
RuntimeWarning,
stacklevel=2,
)
with self._lock:
self._dropped_events.append(
{
"error_type": type(exc).__name__,
"detail": str(exc),
"event": str(event.get("event", "budget")),
}
)


def _plain_json(value: Any) -> Any:
Expand Down
54 changes: 54 additions & 0 deletions src/droste/execution/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,35 @@
)


@dataclass(frozen=True)
class RunDegradation:
"""One thing the run silently did without, recorded so it cannot stay silent.

Several boundaries here deliberately refuse to let a host-supplied callback
end a run -- a broken logging sink or observer must not destroy a user's
work. That resilience is correct, but it used to be paid for with a
``warnings.warn`` that reaches no consumer of the result, so a run that
lost an event, ran an extract pass without its context, or discarded
salvageable work returned something indistinguishable from a clean run.

``consequence`` is the part that matters to a reader: not that a callback
raised, but what the run then did without.
"""

site: str
error_type: str
detail: str
consequence: str

def as_dict(self) -> dict[str, str]:
return {
"site": self.site,
"error_type": self.error_type,
"detail": self.detail,
"consequence": self.consequence,
}


@dataclass
class ExecutionContext:
"""Context for tracking recursive LLM calls within sandbox execution."""
Expand All @@ -34,6 +63,7 @@ class ExecutionContext:
ledger: BudgetLedger = field(default_factory=lambda: BudgetLedger(Budget()))
_emission_lock: RLock = field(default_factory=RLock, init=False, repr=False)
_iteration: int = field(default=0, init=False, repr=False)
_degradations: list[RunDegradation] = field(default_factory=list, init=False, repr=False)

def __post_init__(self) -> None:
if self.ledger.budget != self.config.budget:
Expand All @@ -50,6 +80,30 @@ def emit_progress(self, status: str) -> None:
self.config.on_progress(status)
self.emit_event(progress_event(status))

def record_degradation(self, *, site: str, error: BaseException, consequence: str) -> None:
"""Record that the run continued without something it should have had.

Callers keep whatever fallback they already had; this only ensures the
fallback is visible in the run's own output instead of a warning the
host never sees.
"""

with self._emission_lock:
self._degradations.append(
RunDegradation(
site=site,
error_type=type(error).__name__,
detail=str(error),
consequence=consequence,
)
)

def degradations(self) -> tuple[RunDegradation, ...]:
"""Everything this run did without, in the order it happened."""

with self._emission_lock:
return tuple(self._degradations)

def emit_event(self, event: dict[str, Any]) -> None:
"""Deliver a structured loop event (#1) to the attached sink.

Expand Down
3 changes: 3 additions & 0 deletions src/droste/execution/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ def project_result(
"subcalls": result.sub_calls_made,
"successful_subcalls": int(getattr(result, "sub_calls_succeeded", 0)),
"extracted": bool(getattr(result, "extracted", False)),
# Always present, empty on a clean run: a consumer must never have to
# read silence as "nothing was lost".
"degradations": [dict(item) for item in getattr(result, "degradations", ())],
"error": error_payload(result.error, include_details=include_error_details),
"extract_error": error_payload(
getattr(result, "extract_error", None), include_details=include_error_details
Expand Down
8 changes: 7 additions & 1 deletion src/droste/execution/trace.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from typing import Any, Callable, Mapping
from uuid import uuid4

TRACE_ABI_VERSION = 9
TRACE_ABI_VERSION = 10


class PersistenceClass(str, Enum):
Expand Down Expand Up @@ -177,6 +177,9 @@ class PersistenceClass(str, Enum):
"status": str,
"ready": bool,
"extracted": bool,
# Same field as the result event: a host that reads only the
# terminal event must still learn what the run did without.
"degradations": list,
"iterations": int,
"usage": Mapping,
"budget": Mapping,
Expand Down Expand Up @@ -464,6 +467,9 @@ def _validate_structured_body(event_type: str, body: Mapping[str, Any]) -> None:
"subcalls": int,
"successful_subcalls": int,
"extracted": bool,
# Always present, empty on a clean run: a consumer must never
# have to read silence as "nothing was lost".
"degradations": list,
"error": (Mapping, _NONE_TYPE),
"extract_error": (Mapping, _NONE_TYPE),
"recovered_error": (Mapping, _NONE_TYPE),
Expand Down
Loading