Skip to content
Closed
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
3468983
feat(llm): retain cross-provider reasoning state
furgalep Sep 9, 2026
2113a4f
style(llm): format cross-provider replay
furgalep Sep 9, 2026
0d3b585
fix(llm): harden cross-provider response edges
furgalep Sep 9, 2026
875a946
fix(llm): redact cross-provider replay state from traces
furgalep Sep 9, 2026
af47a3a
fix(llm): scrub Gemini inline replay signatures
furgalep Sep 9, 2026
cfcaa0e
fix(llm): bind Gemini state to tool identities
furgalep Sep 9, 2026
efdb6d4
test(llm): permit replay across gateway routes
furgalep Sep 10, 2026
5c5b6d3
perf(llm): borrow closed-provider replay state
furgalep Sep 10, 2026
ab8ed7c
fix(llm): surface reasoning replay failures
furgalep Sep 10, 2026
f03a2a8
fix(llm): bind chat state to public carriers
furgalep Sep 10, 2026
4d34239
fix(llm): keep replay results consistent and simplify redaction
furgalep Sep 10, 2026
ff9b2c0
fix(llm): distinguish portable summaries from opaque state
furgalep Sep 10, 2026
329af0b
Merge ordered Responses replay fixes into closed-provider support
furgalep Sep 10, 2026
9824e1c
Merge whole-turn replay validation into closed-provider support
furgalep Sep 10, 2026
0766e90
Merge branch 'feat/llm-reasoning-replay-envelope' into feat/llm-anthr…
furgalep Sep 11, 2026
b794579
fix(tracing): preserve readable reasoning in OTLP exports
furgalep Sep 11, 2026
d0c9b6d
Merge compact replay bindings and validate Chat reasoning items
furgalep Sep 11, 2026
042eefe
Merge branch 'feat/llm-reasoning-replay-envelope' into feat/llm-anthr…
furgalep Sep 11, 2026
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
4 changes: 4 additions & 0 deletions skills/nooa-context-and-state/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ async def solve(self, problem: str) -> str:

Event history is what fills the LLM's conversation window. Key model-visible event types (names have no "Event" suffix): `Task`, `Message`, `Reasoning`, `Error`, `Feedback`, `LLMResponse`, `PythonOutput`, `Summary`, `Notification`. `LLMResponse` is both the canonical assistant turn and the home of its token/cost metadata; renderers expose only its conversational fields. Runtime-only events (never shown to the LLM) include `BeforeAgentCall`/`AfterAgentCall` and `LLMCallStart`/`LLMCallEnd`.

`LLMResponse.reasoning` is provider-exposed text and remains useful across model switches, where UnifiedLLM replays it as ordinary assistant text. `LLMResponse.llm_state` is opaque provider state; it is persisted for resume but is replayed only through a matching provider/API/model gate.

An intentional model/API mismatch logs a warning and falls back to the portable text. A malformed current-version envelope or provider signature raises `ReasoningReplayError`; do not catch it and silently continue, because it signals archive corruption or an unsupported provider contract change.

```python
# Query (AND semantics; chronological; limit keeps most recent)
recent = agent.events.query(limit=20)
Expand Down
7 changes: 6 additions & 1 deletion src/nooa/strategies/codeact.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
run_postconditions,
run_preconditions,
)
from nooa.unifiedllm import LLMResponse, Tool, ToolCall
from nooa.unifiedllm import LLMResponse, ReasoningReplayError, Tool, ToolCall

if TYPE_CHECKING:
from nooa.config.strategy_config import CodeActConfig
Expand Down Expand Up @@ -874,6 +874,8 @@ async def _run_generation(
try:
with _init_hm.timer("time_prefill"):
await self._run_prefill(runtime, call, builtins, session)
except ReasoningReplayError:
raise
except Exception as e:
logger.warning(f"[CODEACT] Prefill error (continuing): {e}")
runtime.event_manager.add(Error(content=f"Prefill error: {e}"))
Expand Down Expand Up @@ -919,6 +921,9 @@ async def _run_generation(
tool_choice=tool_choice,
**self._build_sampling_kwargs(),
)
except ReasoningReplayError:
turn_state.is_final = True
raise
Comment thread
furgalep marked this conversation as resolved.
except BlockSyntaxError as e:
self._handle_block_syntax_error(e, session, runtime)
continue
Expand Down
7 changes: 7 additions & 0 deletions src/nooa/strategies/pure_python.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
ReturnValueValidator,
)
from nooa.strategies.template import TemplateStrategy
from nooa.unifiedllm import ReasoningReplayError

# Import httpx timeout exceptions if available (used by litellm)
try:
Expand Down Expand Up @@ -268,6 +269,8 @@ async def execute(self, runtime: RuntimeServices, call: "CurrentCall") -> Any:
if self.prefill:
try:
await self._run_prefill(runtime, call, builtins, session)
except ReasoningReplayError:
raise
except Exception as e:
logger.warning(f"[PURE_PYTHON] Prefill error (continuing): {e}")
runtime.event_manager.add(Error(content=f"Prefill error: {e}"))
Expand Down Expand Up @@ -305,6 +308,10 @@ async def execute(self, runtime: RuntimeServices, call: "CurrentCall") -> Any:
generate_event_id: str | None = None
try:
code, generate_event_id = await self._generate_code(runtime, session)
except ReasoningReplayError as e:
turn_final = True
turn_exception = type(e).__name__
raise
except _HTTPX_TIMEOUT_EXCEPTIONS as e:
# Catch httpx timeout exceptions and preserve them
session.record_error()
Expand Down
2 changes: 1 addition & 1 deletion src/nooa/tracing/_litellm_journal.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ def _msg_to_dict(msg: Any) -> dict:


def _safe_msg_to_dict(msg: Any) -> dict:
"""Normalize one provider message and remove issuer-only opaque state."""
"""Normalize one provider message and remove provider-only opaque state."""
scrubbed, _ = scrub_value(_msg_to_dict(msg))
return scrubbed if isinstance(scrubbed, dict) else {}

Expand Down
29 changes: 25 additions & 4 deletions src/nooa/tracing/_secret_scrubber.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,18 @@
)

# Provider-owned replay state is not a user credential, but it has the same
# telemetry rule: it may go back to its issuer and nowhere else. Provider
# adapters add their exact wire keys here as support is introduced.
_OPAQUE_PROVIDER_STATE_KEYS = frozenset({"encrypted_content", "nooa_llm_state"})
# telemetry rule: it may go back to its matching provider and nowhere else.
# Provider adapters add their exact wire keys here as support is introduced.
_OPAQUE_PROVIDER_STATE_KEYS = frozenset(
{
"encrypted_content",
"nooa_llm_state",
"thought_signature",
"thought_signatures",
"thoughtsignature",
"thoughtsignatures",
}
)


def _is_sensitive_key(key: Any) -> bool:
Expand Down Expand Up @@ -79,6 +88,11 @@ def _redact_key(key: Any) -> str | None:
# ---------------------------------------------------------------------------

_SECRET_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
# LiteLLM may append Gemini thought signatures to tool-call IDs.
(
"gemini_inline_thought_signature",
re.compile(r"__thought__(?P<secret>[A-Za-z0-9+/=_-]+)"),
),
# AWS Access Key IDs (AKIA, ASIA, AIDA, AROA + 16 alphanumeric)
("aws_access_key", re.compile(r"(?P<secret>(?:AKIA|ASIA|AIDA|AROA)[A-Z0-9]{16})")),
# AWS Secret Access Keys (40-char base64 after known prefix)
Expand Down Expand Up @@ -247,8 +261,15 @@ def scrub_value(value: Any) -> tuple[Any, int]:
if isinstance(value, dict):
scrubbed_mapping: dict[Any, Any] = {}
count = 0
is_thinking = value.get("type") == "thinking"
is_redacted_thinking = value.get("type") == "redacted_thinking"
for key, item in value.items():
if reason := _redact_key(key):
reason = _redact_key(key)
if reason is None and is_thinking and key == "signature":
reason = "opaque_provider_state"
if reason is None and is_redacted_thinking and key == "data":
reason = "opaque_provider_state"
if reason is not None:
scrubbed_mapping[key] = REDACTED
stats.record(reason)
count += 1
Expand Down
2 changes: 2 additions & 0 deletions src/nooa/unifiedllm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
reload_registry,
resolve_api_key_from_config,
)
from nooa.unifiedllm.replay_state import ReasoningReplayError
from nooa.unifiedllm.retry import (
EmptyContentError,
RetryingWrapper,
Expand Down Expand Up @@ -48,6 +49,7 @@
# Response types
"LLMResponse",
"LLMUsage",
"ReasoningReplayError",
# HTTP config
"HttpConfig",
# Retry utilities
Expand Down
17 changes: 1 addition & 16 deletions src/nooa/unifiedllm/http_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,9 @@
from datetime import UTC, datetime
from pathlib import Path

from nooa._llm_state import LLM_STATE_KEY
from nooa.tracing._secret_scrubber import REDACTED, _is_sensitive_key, scrub_value


def _redact_opaque_state(value):
"""Remove replay envelopes and encrypted reasoning from debug logs."""
if isinstance(value, dict):
return {
key: REDACTED
if key in {LLM_STATE_KEY, "encrypted_content"}
else _redact_opaque_state(item)
for key, item in value.items()
}
if isinstance(value, list):
return [_redact_opaque_state(item) for item in value]
return value


def enable_http_request_logging(
output_dir: str | Path = ".",
url_filter: str | None = None,
Expand Down Expand Up @@ -122,7 +107,7 @@ def _redact_body(body):
# in an OAuth authorization-code exchange.
if "code" in scrubbed:
scrubbed["code"] = REDACTED
return _redact_opaque_state(scrubbed)
return scrubbed

def _write_jsonl_entry(entry: dict):
"""Append a JSON entry to the JSONL error file."""
Expand Down
Loading