Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
20 changes: 20 additions & 0 deletions src/nooa/tracing/_litellm_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
defined in OpenInference semantic conventions).
3. Missing reasoning_content capture for reasoning models (DeepSeek, o1, Nemotron, etc.)
4. Extract <think> tags from content for models that embed reasoning (Nemotron, QwQ)
5. Preserve Responses reasoning summaries when journal mode strips message attributes

Bug: https://github.com/Arize-ai/openinference/issues (to be filed)
Affected version: openinference-instrumentation-litellm v0.1.28+
Expand Down Expand Up @@ -251,13 +252,30 @@ def _patched_get_attributes_from_message_param(
yield (MessageAttributes.MESSAGE_TOOL_CALL_ID, tool_call_id)


def _patched_get_attributes_from_response_output(result: Any) -> dict[str, Any]:
"""Keep readable Responses summaries on the same OTLP field as Chat reasoning."""
from openinference.instrumentation.litellm._responses_attributes import (
_get_attributes_from_response_output,
)

from nooa.unifiedllm.replay_state import responses_reasoning_text

attributes = _get_attributes_from_response_output(result)
if reasoning := responses_reasoning_text(result.output):
# Message attributes may be stripped in journal mode. This field remains
# on the span and contains only visible text, never encrypted state.
attributes["llm.reasoning_content"] = reasoning
return attributes


def apply_litellm_patch() -> None:
"""Apply monkey patches to fix litellm instrumentation bugs.

This patches:
1. _set_output_message_value - fixes null/empty content handling, adds
reasoning_content, and stamps llm.cost.* (not emitted by the instrumentor)
2. _get_attributes_from_message_param - adds missing tool_call.id capture
3. _get_attributes_from_response_output - keeps Responses summaries on OTLP

It also enables ``litellm.return_response_headers`` so the gateway's
``x-litellm-response-cost`` headers are retained on the response for cost
Expand All @@ -278,6 +296,8 @@ def apply_litellm_patch() -> None:

# Patch 1: Fix null/empty content handling
litellm._set_output_message_value = _patched_set_output_message_value
# This imported private helper is the Responses instrumentor's patch point.
litellm._get_attributes_from_response_output = _patched_get_attributes_from_response_output # pyright: ignore[reportPrivateImportUsage]

# Patch 2: Fix missing tool_call.id
# Store the original function so our patch can call it
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 @@ -249,8 +263,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
Loading