Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
e211a7e
refactor(llm): make assistant turns canonical events
furgalep Sep 8, 2026
2a13dff
fix(llm): harden canonical assistant turn replay
furgalep Sep 8, 2026
6e6c74e
fix(llm): preserve canonical turns across strategies
furgalep Sep 8, 2026
3957dcc
fix(llm): project canonical assistant text with tools
furgalep Sep 8, 2026
174339b
fix(llm): retain plain reasoning on assistant turns
furgalep Sep 9, 2026
e5ddfa9
style(tests): format text-only recovery test
furgalep Sep 9, 2026
a8621d8
refactor(llm): make response the canonical assistant turn
furgalep Sep 9, 2026
d28f9ee
refactor(llm): remove canonical response compatibility shims
furgalep Sep 9, 2026
f0470dc
style: format canonical response migration
furgalep Sep 9, 2026
cca0c65
test(llm): use canonical usage type
furgalep Sep 9, 2026
421268c
fix(llm): preserve public turns at integration edges
furgalep Sep 9, 2026
0087b33
fix(events): explain legacy response migration
furgalep Sep 9, 2026
4ca94bd
fix(events): reject removed event query names
furgalep Sep 9, 2026
637fb0e
docs(llm): clarify canonical response fields
furgalep Sep 9, 2026
f4a568a
fix(codeact): retain failed canonical turns
furgalep Sep 9, 2026
1d2a3fb
style(llm): format response field docs
furgalep Sep 9, 2026
2aaa9fd
fix(codeact): keep nested tool receipts cache-stable
furgalep Sep 10, 2026
0ec7461
fix(events): omit incomplete linked tool batches
furgalep Sep 10, 2026
6063395
docs(llm): clarify structured output persistence
furgalep Sep 10, 2026
ccca877
fix(llm): replay persisted plain reasoning
furgalep Sep 10, 2026
8de1b22
refactor(llm): share reasoning demotion primitive
furgalep Sep 10, 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
2 changes: 1 addition & 1 deletion examples/advanced/codeact_event_sequence.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
1. Task - The initial task/prompt
2. ToolCallEvent - The LLM's request to call execute_python
3. ToolResultEvent - The result of code execution
4. LLMOutput - The final structured output
4. LLMResponse - The final structured output

Run with:
uv run python examples/advanced/codeact_event_sequence.py
Expand Down
11 changes: 0 additions & 11 deletions examples/arc_agi_3/tests/test_no_action_cap.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,17 +149,6 @@ def _codeact_response(code: str):
ToolCall(id=f"call_{abs(hash(code)) & 0xFFFF}", name="execute_python", arguments=args)
],
finish_reason="tool_calls",
assistant_message={
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": f"call_{abs(hash(code)) & 0xFFFF}",
"type": "function",
"function": {"name": "execute_python", "arguments": args},
}
],
},
reasoning=None,
usage={"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
)
Expand Down
11 changes: 0 additions & 11 deletions examples/arc_agi_3/tests/test_wait_guard_and_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,6 @@ def _tool_response(name: str, args: dict):
content="",
tool_calls=[ToolCall(id=call_id, name=name, arguments=args_json)],
finish_reason="tool_calls",
assistant_message={
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": call_id,
"type": "function",
"function": {"name": name, "arguments": args_json},
}
],
},
reasoning=None,
usage={"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
)
Expand Down
4 changes: 2 additions & 2 deletions examples/nooa-slides-prototype.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -702,9 +702,9 @@
" │ MESSAGES │\n",
" │ ┌────────────────────────────────┐ │\n",
" │ │ [1] Task(\"analyze this data\") │ │\n",
" │ │ [2] LLMOutput(code=\"...\") │ │\n",
" │ │ [2] LLMResponse(code=\"...\") │ │\n",
" │ │ [3] PythonOutput(stdout=\"...\") │ │\n",
" │ │ [4] LLMOutput(code=\"...\") │ │\n",
" │ │ [4] LLMResponse(code=\"...\") │ │\n",
" │ │ [5] Message(\"Here are results\")│ │\n",
" │ └────────────────────────────────┘ │\n",
" │ self.events API: │\n",
Expand Down
17 changes: 10 additions & 7 deletions packages/nooa-acp/src/nooa_acp/event_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@

from nooa.agentdoc import pformat
from nooa.context_blocks.events import EventBase, ResultStatus, ToolCallEvent
from nooa.events import LLMComplete, PythonOutput
from nooa.events import LLMResponse, PythonOutput
from nooa.interactive import AgentMessage

# ACP owns stdout for JSON-RPC; diagnostics belong on stderr, which is where
Expand Down Expand Up @@ -85,7 +85,7 @@ def __init__(self, agent: CodingAgent, client: Client, session_id: str) -> None:
agent.event_manager.on("AgentMessage", self._on_agent_message),
agent.event_manager.on("ToolCallEvent", self._on_tool_call),
agent.event_manager.on("PythonOutput", self._on_python_output),
agent.event_manager.on("LLMComplete", self._on_llm_complete),
agent.event_manager.on("LLMResponse", self._on_llm_response),
agent.event_manager.on("FileEdit", self._on_file_edit),
agent.event_manager.on("TerminalCommandStarted", self._on_terminal_started),
agent.event_manager.on("TerminalCommandOutput", self._on_terminal_output),
Expand Down Expand Up @@ -254,18 +254,21 @@ def _on_terminal_finished(self, event: EventBase) -> None:
)
)

def _on_llm_complete(self, event: EventBase) -> None:
if not isinstance(event, LLMComplete):
def _on_llm_response(self, event: EventBase) -> None:
if not isinstance(event, LLMResponse):
return
self._cost_usd += event.cost_usd
usage = event.usage
if usage is None:
return
self._cost_usd += usage.cost_usd
context_window = getattr(self.agent.llm, "context_window", None)
if context_window is None:
return
self._enqueue(
UsageUpdate(
session_update="usage_update",
used=event.prompt_tokens,
size=max(context_window, event.prompt_tokens),
used=usage.input_tokens,
size=max(context_window, usage.input_tokens),
cost=Cost(amount=self._cost_usd, currency="USD"),
)
)
Expand Down
19 changes: 14 additions & 5 deletions packages/nooa-acp/tests/test_event_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@
)

from nooa.context_blocks.events import ResultStatus, ToolCallEvent
from nooa.events import LLMComplete, PythonOutput
from nooa.events import LLMResponse, PythonOutput
from nooa.interactive import AgentMessage
from nooa.llm_types import LLMUsage
from nooa.unifiedllm import FakeLLMClient


Expand Down Expand Up @@ -80,7 +81,9 @@ async def test_bridge_preserves_message_tool_and_usage_order(tmp_path):
stdout="hello\n",
)
)
agent.event_manager.add(LLMComplete(prompt_tokens=40, completion_tokens=10, cost_usd=0.25))
agent.event_manager.add(
LLMResponse(usage=LLMUsage(input_tokens=40, output_tokens=10, cost_usd=0.25))
)
await bridge.flush()

updates = [update for _, update in client.updates]
Expand Down Expand Up @@ -203,7 +206,9 @@ async def test_bridge_omits_usage_when_context_window_is_unknown(tmp_path):
bridge = ACPEventBridge(agent, client, "session-1") # type: ignore[arg-type]

agent.event_manager.add(AgentMessage(content="alive"))
agent.event_manager.add(LLMComplete(prompt_tokens=40, completion_tokens=10, cost_usd=0.25))
agent.event_manager.add(
LLMResponse(usage=LLMUsage(input_tokens=40, output_tokens=10, cost_usd=0.25))
)
await bridge.flush()

# Positive control: prove the bridge is actually forwarding before asserting
Expand All @@ -217,13 +222,15 @@ async def test_bridge_omits_usage_when_context_window_is_unknown(tmp_path):
await agent.close()

# Paired positive: the same event with a known context window must emit a
# UsageUpdate. Without this, `return` at the top of _on_llm_complete passes
# UsageUpdate. Without this, `return` at the top of _on_llm_response passes
# both halves — an AgentMessageChunk control comes from a different handler
# and cannot tell "the guard works" from "usage never fires".
sized = CodingAgent(llm=FakeLLMClient(), cwd=tmp_path)
sized_client = _RecordingClient()
sized_bridge = ACPEventBridge(sized, sized_client, "session-2") # type: ignore[arg-type]
sized.event_manager.add(LLMComplete(prompt_tokens=40, completion_tokens=10, cost_usd=0.25))
sized.event_manager.add(
LLMResponse(usage=LLMUsage(input_tokens=40, output_tokens=10, cost_usd=0.25))
)
await sized_bridge.flush()
assert any(isinstance(update, UsageUpdate) for _, update in sized_client.updates)
await sized_bridge.close()
Expand Down Expand Up @@ -259,6 +266,7 @@ async def test_bridge_emits_structured_file_edit(tmp_path):
assert update.locations is not None
assert update.locations[0].path == path
assert update.locations[0].line == 2
assert update.content is not None
content = cast(FileEditToolCallContent, update.content[0])
assert content.path == path
assert content.old_text == "old\n"
Expand Down Expand Up @@ -293,6 +301,7 @@ async def test_bridge_emits_terminal_lifecycle(tmp_path):
assert started.kind == "execute"
assert started.title == "$ pytest -q"
progress = cast(ToolCallProgress, updates[1])
assert progress.content is not None
content = cast(ContentToolCallContent, progress.content[0])
assert content.content.text == "2 passed\n"
finished = cast(ToolCallProgress, updates[2])
Expand Down
4 changes: 3 additions & 1 deletion packages/nooa-bench/src/nooa_bench/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,9 @@ def _write_trajectory(agent: Any) -> None:
{
"event_id": event_id,
"event_type": type(event).__name__,
**event.model_dump(mode="json"),
# Opaque provider replay state belongs only in the durable event
# backend and compatible provider requests, never debug exports.
**event.model_dump(mode="json", exclude={"llm_state"}),
}
for event_id, event in manager.items()
]
Expand Down
19 changes: 18 additions & 1 deletion packages/nooa-bench/tests/test_bench_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@

import pytest
from nooa_bench import bench_agent as bench_agent_module
from nooa_bench import runner
from nooa_bench.bench_agent import BenchAgent, TaskResult

from nooa.agentdoc import doc
from nooa.unifiedllm import FakeLLMClient
from nooa.unifiedllm import FakeLLMClient, LLMResponse


class _FakeShell:
Expand All @@ -34,6 +35,22 @@ def __init__(self, root: str, session: object | None = None) -> None:
self.session = session


def test_trajectory_excludes_opaque_provider_state(monkeypatch, tmp_path):
response = LLMResponse(
content="public answer",
llm_state={"encrypted_content": "provider-secret"},
)
agent = type("Agent", (), {"event_manager": {response.id: response}})()
monkeypatch.setattr(runner, "LOGS_DIR", tmp_path)

runner._write_trajectory(agent)

payload = (tmp_path / "trajectory.json").read_text()
assert "public answer" in payload
assert "provider-secret" not in payload
assert "llm_state" not in payload


def test_task_result_model():
"""TaskResult validates required fields with solution_description."""
r = TaskResult(
Expand Down
2 changes: 1 addition & 1 deletion packages/nooa-memory/src/nooa_memory/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ specific query; spontaneous recall covers the passive "what's relevant right now
|---|---|
| `last_message` *(default)* | the most recent user-text message |
| `recent_events` | the last `recent_events_n` events, concatenated |
| `working_state` | recent `PythonOutput`/`LLMOutput` (the agent's scratch state) |
| `working_state` | recent `PythonOutput`/`LLMResponse` (the agent's scratch state) |
| `distilled` | LLM-distilled query — falls back to `recent_events` with no LLM |

If no anchor can be derived (e.g. empty turn) **nothing is injected** — it never
Expand Down
2 changes: 1 addition & 1 deletion packages/nooa-memory/src/nooa_memory/retrieval.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ def _strategy_recent_events(agent: object, cfg) -> list[str]:
def _strategy_working_state(agent: object, cfg) -> list[str]:
texts = []
for ev in _recent_events(agent, cfg.recent_events_n * 2):
if type(ev).__name__ in ("PythonOutput", "LLMOutput"):
if type(ev).__name__ in ("PythonOutput", "LLMResponse"):
Comment thread
coderabbitai[bot] marked this conversation as resolved.
txt = _event_text(ev) or getattr(ev, "output", "")
if isinstance(txt, str) and txt:
texts.append(txt)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ def test_distinct_facts_survive_reconciliation():
assert mgr.store.count() == 3 # the model said "distinct" -> nothing archived


def test_malformed_llm_output_is_contained():
def test_malformed_llm_response_is_contained():
agent = MemAgent()
llm = ScriptedLLM(lambda prompt: "sorry, I can't produce JSON today")
mgr = _install(agent, reconciler=llm_reconciler(lambda: llm))
Expand Down
2 changes: 1 addition & 1 deletion skills/nooa-codeact-advanced/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ Single LLM turn, no tools, no code. The prompt is the docstring plus each parame
| `max_retries` | `10` | Validation retries. Each failure adds an `Error` event with the formatted validation error plus the raw output truncated to `max_error_chars` (1000). Exhaustion raises `GenerationError` (not ValidationError). |
| `max_param_chars` | `200_000` | **Hard pre-flight guard, not a truncator**: any parameter whose `repr` exceeds it raises `ValueError` — Predict is single-shot, so a silently-truncated input would mean silently-wrong output. Chunk the input or raise/disable (`None`) the limit. |
| `max_tokens` / `temperature` / `top_p` | `None` | Forwarded to the LLM call when set. |
| `output_serialization` | `"event"` | How the result is recorded in history: `"event"` keeps the LLMOutput (replayed as a plain assistant message); `"tool_call"` replaces it with a synthetic `return_result` ToolCallEvent — prefer it when a downstream tool-using model reads this history. |
| `output_serialization` | `"event"` | How the result is recorded in history: `"event"` keeps the LLMResponse (replayed as a plain assistant message); `"tool_call"` also appends a synthetic `return_result` ToolCallEvent — prefer it when a downstream tool-using model reads this history. |

- Return-type handling: `Optional[X]` unwraps; `dict[K,V]` uses a root-object schema; bare `list`/scalars are wrapped in a hidden `{"value": ...}` schema (Responses-API rejects array-rooted schemas) and unwrapped after validation; models with hidden fields get a public-subset schema and are rehydrated. Non-JSON-serializable **return types** (DataFrame, ndarray) are rejected up front with a pointer to CodeAct (parameters are only size-checked via `max_param_chars`).
- Reasoning models: the JSON must land in `content`; `reasoning` is only used as a fallback when content is empty. Prose-in-content + JSON-in-reasoning fails and retries.
Expand Down
2 changes: 1 addition & 1 deletion skills/nooa-context-and-state/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ async def solve(self, problem: str) -> str:

## Events

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`, `LLMOutput`, `PythonOutput`, `Summary`, `Notification`. Runtime-only events (never shown to the LLM) include `BeforeAgentCall`/`AfterAgentCall`, `LLMCallStart`/`LLMCallEnd`, `LLMComplete` (token/cost metrics).
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`.

```python
# Query (AND semantics; chronological; limit keeps most recent)
Expand Down
10 changes: 5 additions & 5 deletions skills/nooa-middleware-hooks/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: nooa-middleware-hooks
description: Intercept and observe NOOA execution — middleware via event_manager.intercept() (guardrails, input/output transforms, blocking), event observers via event_manager.on() (react to Task/Error/LLMComplete/turn events), and the InstrumentationHooks protocol for observability backends. Use when adding guardrails, redacting or rewriting prompts, blocking or faking an LLM call or code execution, rate-limiting agent methods, subscribing to lifecycle events, or wiring custom telemetry.
description: Intercept and observe NOOA execution — middleware via event_manager.intercept() (guardrails, input/output transforms, blocking), event observers via event_manager.on() (react to Task/Error/LLMResponse/turn events), and the InstrumentationHooks protocol for observability backends. Use when adding guardrails, redacting or rewriting prompts, blocking or faking an LLM call or code execution, rate-limiting agent methods, subscribing to lifecycle events, or wiring custom telemetry.
compatibility: nooa package
---

Expand Down Expand Up @@ -44,7 +44,7 @@ unsubscribe() # intercept() returns a remo
Verified semantics:

- **Order**: registration order = execution order; first registered is outermost. Nesting across kinds: `agent_call` → per-turn `llm_call` → per-cell `execute_python`.
- **Short-circuiting** (don't call `nxt`) is allowed for guardrails/caching, but you MUST set the output slot (`ctx.result` / `ctx.response`) — the runtime raises `RuntimeError` if middleware returns without it. To fake an LLM turn, construct an `LLMResponse` (`content`, `tool_calls=[]`, `finish_reason="stop"`, `assistant_message={...}`, `raw_response=None`).
- **Short-circuiting** (don't call `nxt`) is allowed for guardrails/caching, but you MUST set the output slot (`ctx.result` / `ctx.response`) — the runtime raises `RuntimeError` if middleware returns without it. To fake an LLM turn, return a fresh `LLMResponse(content=..., tool_calls=[], finish_reason="stop")`; response instances cannot be reused across turns because runtime correlation data is stamped onto the same object that is recorded.
- **Blocking**: raise from the middleware — the exception propagates to the caller exactly like a failure of the wrapped operation (for `llm_call`, CodeAct counts it against its session error budget).
- **Exceptions are NOT swallowed** — middleware is control flow, unlike hooks.
- Per-agent: registered on that agent's `EventManager`; subagents have their own.
Expand All @@ -63,8 +63,8 @@ unsub = agent.event_manager.on("Error", lambda e: log.warning("agent error: %s",
agent.event_manager.on("*", audit) # wildcard: every event
```

- Useful runtime-only events (never rendered to the model): `BeforeTurn` / `AfterTurn` (per generation turn; `AfterTurn.is_final` marks method completion) and `LLMComplete` (tokens, cost, model_name, tool_calls, reasoning metadata per round-trip — emitted precisely so you don't need `intercept("llm_call")` just to read LLM metrics).
- Model-visible events (`Task`, `Message`, `Error`, `PythonOutput`, ...) are observable the same way — see `nooa-context-and-state` for the full list.
- Useful runtime-only events (never rendered to the model): `BeforeTurn` / `AfterTurn` (per generation turn; `AfterTurn.is_final` marks method completion) and `LLMCallStart` / `LLMCallEnd`.
- `LLMResponse` is the canonical, model-visible assistant turn. It also carries hidden token, cost, model, and reasoning metadata, so observers can read LLM metrics without wrapping `intercept("llm_call")`. Other model-visible events (`Task`, `Message`, `Error`, `PythonOutput`, ...) are observable the same way — see `nooa-context-and-state` for the full list.
- The summarizers are the house pattern: subscribe to `AfterTurn` to *schedule* work, apply it at the next `BeforeTurn` (`agents/summarization.py:159-160`).

## InstrumentationHooks (`set_hooks`)
Expand All @@ -90,7 +90,7 @@ set_hooks(TimingHooks()) # set_hooks(None) removes

## Pitfalls

- Don't use hooks for app logic (they're swallowed-exception observational); don't use middleware for metrics you can get from `LLMComplete` (you'd pay complexity for nothing).
- Don't use hooks for app logic (they're swallowed-exception observational); don't use middleware for metrics you can get from `LLMResponse` (you'd pay complexity for nothing).
- `AgentCallContext.result` uses a not-set sentinel — a short-circuiting `agent_call` middleware that "returns None" on purpose must still assign `ctx.result = None`.
- Middleware lives on the instance's event manager: install in `__init__` (after `super().__init__()`) or on the constructed agent, not on the class.
- Keep `llm_call` middleware fast — it's on the critical path of every turn, and runs again on context-window retries.
Expand Down
Loading
Loading