Skip to content
51 changes: 51 additions & 0 deletions docs/concepts/strategies.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,57 @@ variables, helper definitions persist across cells, and generated code can call
visible methods and tools on `self`. The loop ends when a value validates
against the return annotation.

### Recovering from a text-only CodeAct turn

CodeAct expects every model turn to call `execute_python` or `return_result`,
but models sometimes emit plain prose instead. NOOA keeps that original
assistant turn in history, then asks a configurable callback what to do:

```python
from nooa import CodeActStrategy, return_text_as_result, strategy


class Summarizer(Agent, llm=llm):
@strategy(CodeActStrategy(on_text_only=return_text_as_result))
async def summarize(self, text: str) -> str:
"""Summarize the text."""
...
```

`TextOnlyResponseAction` is the callback's return value; it is not passed to
`@strategy`. The wiring is:
Comment on lines +74 to +75

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an internal note based on an agent conversation, not general documentation. Make sure this is just documentation.


1. `@strategy(...)` attaches a `CodeActStrategy` to the method.
2. `CodeActStrategy(on_text_only=handler)` registers a sync or async callback.
3. When a text-only response occurs, the handler receives a
`TextOnlyResponseContext` and returns a `TextOnlyResponseAction`.

The default handler, `retry_text_only_response`, appends a model-visible
`Error` telling the model to use one of its tools and retries. The opt-in
`return_text_as_result` handler validates the bare text against the method's
return type. Custom handlers can return `TextOnlyResponseAction.return_result`,
`.retry`, or `.tool_calls`; all paths preserve the provider's original turn.

For example, an application can supply its own model-visible correction:

```python
from nooa import TextOnlyResponseAction, TextOnlyResponseContext
from nooa.events import Error


def require_tool(context: TextOnlyResponseContext) -> TextOnlyResponseAction:
return TextOnlyResponseAction.retry(
Error(content=f"Plain text cannot finish {context.call.method_name}; call a tool.")
)


class Investigator(Agent, llm=llm):
@strategy(CodeActStrategy(on_text_only=require_tool))
async def investigate(self, question: str) -> str:
"""Investigate the question."""
...
```

## Strategy selection is not model selection

The strategy controls the interaction pattern. The LLM setting controls which
Expand Down
8 changes: 1 addition & 7 deletions packages/nooa-bench/src/nooa_bench/bench_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,13 +206,7 @@ async def _run_evaluation(self, task_input: dict) -> dict:
_logger.error("BenchAgent failed: %s", e)
return {"response": "", "success": False, "error": str(e)}

@strategy(
CodeActStrategy(
config=CodeActConfig(
max_iterations=300, max_retries=10, text_only_stop_behavior="synthetic_comment"
)
)
)
@strategy(CodeActStrategy(config=CodeActConfig(max_iterations=300, max_retries=10)))
async def _solve_task(self, description: str) -> TaskResult:
"""Solve the task.

Expand Down
11 changes: 10 additions & 1 deletion skills/nooa-agent-authoring/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,16 @@ async def classify(self, text: str) -> Intent: ...
async def implement(self, task: str) -> str: ...
```

**Constructors take `config=` only.** `PredictStrategy(max_retries=3)` and `CodeActStrategy(max_iterations=10)` are errors — wrap options in `PredictConfig(...)`/`CodeActConfig(...)`. Useful `CodeActConfig` fields: `max_iterations`, `max_retries`, `cell_timeout`, `max_tokens`, `temperature`, `max_consecutive_text_only`, `restrictions`.
**Configuration fields go through `config=`.** `PredictStrategy(max_retries=3)` and `CodeActStrategy(max_iterations=10)` are errors — wrap options in `PredictConfig(...)`/`CodeActConfig(...)`. Strategy-level extension points such as `CodeActStrategy(on_text_only=...)` remain direct constructor arguments. Useful `CodeActConfig` fields: `max_iterations`, `max_retries`, `cell_timeout`, `max_tokens`, `temperature`, `max_consecutive_text_only`, `restrictions`.

CodeAct preserves a model response that contains prose but no tool call. By
default it appends an `Error` asking the model to use `execute_python` or
`return_result`, then retries. For a method where prose is a valid final value,
use `CodeActStrategy(on_text_only=return_text_as_result)`. `on_text_only`
receives a sync or async callback; the callback receives

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove "The action is
the callback result—it is not passed to @strategy."

`TextOnlyResponseContext` and returns `TextOnlyResponseAction`. The action is
the callback result—it is not passed to `@strategy`. See
`nooa-codeact-advanced` for custom retry and synthetic-tool examples.

`max_iterations` is a safety net, not the main tuning dial — decompose the task instead of raising the cap. For prefill control, truncation tuning, code restrictions, and the full config surface, see `nooa-codeact-advanced`.

Expand Down
64 changes: 60 additions & 4 deletions skills/nooa-codeact-advanced/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: nooa-codeact-advanced
description: Advanced tuning of NOOA strategies — CodeAct prefill (understanding, disabling, custom, pre-ellipsis code), loop guards (max_iterations, retries, text-only stop), truncation tuning (TruncationConfig/CaptureConfig/FormatConfig), code restrictions (RestrictionsConfig), execution environment internals, and PredictStrategy tuning (retries, param guards, output_serialization). Use when configuring CodeActConfig or PredictConfig beyond defaults, writing a custom prefill, restricting generated code, or debugging truncation/eviction behavior.
description: Advanced tuning of NOOA strategies — CodeAct prefill, loop guards, text-only recovery callbacks, truncation, code restrictions, execution internals, and PredictStrategy tuning. Use when configuring CodeActConfig or PredictConfig beyond defaults, handling models that return prose instead of tool calls, writing a custom prefill, restricting generated code, or debugging truncation and eviction.
compatibility: nooa package
---

Expand All @@ -10,7 +10,7 @@ The authoring basics are in `nooa-agent-authoring`. This skill covers the deep c

## Config plumbing rules (read first)

- Strategy constructors take **`config=` only**: `CodeActStrategy(config=CodeActConfig(...))`, `PredictStrategy(PredictConfig(...))`. Flat kwargs (`CodeActStrategy(max_iterations=10)`, `CodeActStrategy(prefill=...)`) do not exist, despite some docstring examples.
- Configuration fields go through **`config=`**: `CodeActStrategy(config=CodeActConfig(...))`, `PredictStrategy(PredictConfig(...))`. Flat config kwargs (`CodeActStrategy(max_iterations=10)`, `CodeActStrategy(prefill=...)`) do not exist. Strategy-level extension points such as `CodeActStrategy(on_text_only=...)` and `CodeActStrategy(error_formatter=...)` are separate keyword arguments.
- All config objects are frozen Pydantic models with `merge_with(other)`: only fields explicitly set on `other` override. **Configs must be freshly constructed** — anything round-tripped through `model_dump()`/`model_validate()` has an empty `model_fields_set` and `merge_with` raises.
- Truncation layers: framework default → `Agent` class kwarg `truncation=` → instance kwarg → `@strategy(..., truncation=...)` per method. `TruncationConfig.merge_with` deep-merges sub-configs field-by-field; the strategy configs merge flat.

Expand Down Expand Up @@ -42,15 +42,71 @@ class Notifier(Agent, llm=llm):
| `max_iterations` | `None` | **Unlimited.** The loop then stops only on completion, the error budget, or a hard abort. |
| `max_retries` | `3` | **Cumulative session error budget, not consecutive** (the counter is never reset). LLM API errors, bad tool JSON, empty code, and `return_result` validation failures all count. |
| `max_consecutive_text_only` | `3` | Consecutive no-tool-call text replies before hard abort; `0` disables. Any real tool call resets the counter. |
| `text_only_stop_behavior` | `"return_result"` | Text-only reply → try to validate the text as the final result; on failure, a visible correction `Error` is added. `"synthetic_reasoning"` instead converts the text to a no-op `reasoning(...)` cell whose tool result says the task is NOT finished. |
| `cell_timeout` | `None` | Per-cell `asyncio.wait_for` limit in seconds; `None` = unlimited. Cannot interrupt a truly blocking sync syscall — that's what the blocking-call AST validation is for. |
| `max_tokens` / `temperature` / `top_p` | `None` | Passed to every generation call when set (model defaults otherwise). On empty responses with `finish_reason="length"` CodeAct aborts and tells you to raise `max_tokens` (16384+ for reasoning models). |
| `translate_tool_calls` | `False` | When a weak model calls an agent method directly as a tool (instead of via `execute_python`), rewrite it into equivalent code and run it — teaching the right pattern. Off = error listing the two valid tools. |
| `restrictions` | `RestrictionsConfig()` | See Restrictions below. |
| `prefill` | `InspectInputsPrefill()` | See Prefill above. |
| `max_tool_calls` | `None` | **Dead — declared but never read.** Setting it does nothing. |

`tool_choice` is hardcoded `"auto"`. There is no `allow_text_response` option (older docs mention one) — text handling is entirely the two text-only knobs.
`tool_choice` is hardcoded `"auto"`.

## Text-only recovery callback

CodeAct expects each model turn to call `execute_python` or `return_result`.
When a model emits only prose, NOOA preserves that exact assistant turn and
then invokes the strategy's `on_text_only` callback.

Do not pass `TextOnlyResponseAction` to `@strategy`. The objects have distinct

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"The action is
the callback result—it is not passed to @strategy."

remove.

roles:

```text
@strategy attaches CodeActStrategy to a method
-> CodeActStrategy(on_text_only=handler) registers the callback
-> handler(TextOnlyResponseContext) returns TextOnlyResponseAction
```

The callback may be synchronous or asynchronous. Its context contains the
untouched `LLMResponse`, normalized text, current method call, and declared
return type. Its action chooses one of three append-only recovery paths:

| Action constructor | Effect |
|---|---|
| `TextOnlyResponseAction.return_result(value)` | Validate `value` through CodeAct's normal result path. Invalid values produce model-visible correction feedback. |
| `TextOnlyResponseAction.retry(*events)` | Append feedback events, then ask the model again. |
| `TextOnlyResponseAction.tool_calls(*calls)` | Run synthetic calls while retaining the original assistant turn. Use `execute_python`, not TUI-only tools. |

The safe default is `retry_text_only_response`: it appends an `Error` asking
the model to call `return_result(value)` or `execute_python(code)`, then retries.
Opt into accepting prose only when that is appropriate for the method contract:

```python
from nooa import Agent, CodeActStrategy, return_text_as_result, strategy


class Summarizer(Agent, llm=llm):
@strategy(CodeActStrategy(on_text_only=return_text_as_result))
async def summarize(self, text: str) -> str:
"""Summarize the text."""
...
```

For custom policy, return an action from the callback:

```python
from nooa import TextOnlyResponseAction, TextOnlyResponseContext
from nooa.events import Error


def require_tool(context: TextOnlyResponseContext) -> TextOnlyResponseAction:
return TextOnlyResponseAction.retry(
Error(content=f"Plain text cannot finish {context.call.method_name}; call a tool.")
)


@strategy(CodeActStrategy(on_text_only=require_tool))
async def investigate(self, question: str) -> str: ...
```

## `return_result` mechanics

Expand Down
10 changes: 10 additions & 0 deletions src/nooa/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,12 @@
GenerationStrategy,
InspectInputsPrefill,
PredictStrategy,
TextOnlyResponseAction,
TextOnlyResponseContext,
TextOnlyResponseHandler,
get_default_strategy,
retry_text_only_response,
return_text_as_result,
set_default_strategy,
)
from nooa.strategy_validation import ( # noqa: E402
Expand Down Expand Up @@ -119,6 +124,11 @@ def __getattr__(name):
# Strategies
"GenerationStrategy",
"CodeActStrategy",
"TextOnlyResponseAction",
"TextOnlyResponseContext",
"TextOnlyResponseHandler",
"retry_text_only_response",
"return_text_as_result",
"CodeActLiteStrategy",
"ReflexionStrategy",
"PredictStrategy",
Expand Down
45 changes: 25 additions & 20 deletions src/nooa/config/strategy_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
# SPDX-License-Identifier: Apache-2.0
"""Strategy configuration for CodeAct, Predict, and Reflexion strategies."""

from collections.abc import Sequence
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Any, Literal

from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator

from nooa.runtime.restrictions import RestrictionsConfig
from nooa.runtime.sandbox.config import SandboxConfig
Expand Down Expand Up @@ -41,26 +41,31 @@ class CodeActConfig(BaseModel):
max_retries: int = 3
# Maximum consecutive turns where the LLM returns plain text instead of a
# tool call before the run is aborted. A real tool call resets the counter.
# Set to 0 to disable the guard (legacy behavior). See also
# text_only_stop_behavior for how each text-only response is handled.
# Set to 0 to disable the guard.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
max_consecutive_text_only: int = 3
# How to handle finish_reason="stop" (text-only, no tool call) responses:
# - "return_result": Route through return_result(content) validation. If the
# return type matches, the session terminates cleanly. If not, the LLM gets
# an actionable validation error to self-correct. (Recommended — breaks
# loops faster and often terminates successfully.)
# - "synthetic_comment": Convert to an execute_python call whose code is the
# text as a `#` comment — a no-op synthetic call that preserves the text
# in traces. The LLM sees "status: complete" and must still call
# return_result() explicitly.
text_only_stop_behavior: Literal["return_result", "synthetic_comment"] = "return_result"

@field_validator("text_only_stop_behavior", mode="before")

@model_validator(mode="before")
@classmethod
def _migrate_synthetic_reasoning(cls, v: str) -> str:
if v == "synthetic_reasoning":
return "synthetic_comment"
return v
def _reject_removed_text_only_options(cls, value: Any) -> Any:
"""Fail loudly when configuration uses the superseded recovery API."""
if not isinstance(value, Mapping):
return value

removed = sorted(
{
"text_only_stop_behavior",
"text_only_correction",
"text_only_correction_fn",
}.intersection(value)
)
if removed:
fields = ", ".join(repr(field) for field in removed)
raise ValueError(
f"CodeActConfig field(s) {fields} were removed. Pass recovery behavior "
"to CodeActStrategy(on_text_only=...) instead; use the default retry, "
"return_text_as_result, or a callback returning TextOnlyResponseAction."
)
return value

cell_timeout: float | None = None
max_tokens: int | None = None
Expand Down
23 changes: 7 additions & 16 deletions src/nooa/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,7 @@ class TextOnlyReply(EventBase): # type: ignore[misc]
but ``Role.METADATA`` — never rendered to the model, so capturing a
text-only reply does not change generation. This is the durable, structured record that
powers ``/bug`` capture and time-travel replay; the model-visible
correction is a separate ``Error``/``Feedback`` event (``Role.USER``).

Replaces the lossy ``DebugTrace`` previously written on the CodeAct
text-only path, which truncated the content and could not be relied on by
downstream consumers.
recovery action is recorded separately and may be supplied by the host.
"""

_role: ClassVar[Role] = Role.METADATA
Expand All @@ -141,22 +137,17 @@ class TextOnlyReply(EventBase): # type: ignore[misc]
finish_reason: Annotated[
str, Field(description="LLM finish_reason for the text-only turn (e.g. 'stop')")
] = ""
route: Annotated[
handler: Annotated[
str,
Field(description="Qualified name of the text-only response handler"),
] = ""
action: Annotated[
str,
Field(description="Handling route: 'return_result' or 'synthetic_comment'"),
Field(description="Handler action: 'return_result', 'retry', or 'tool_calls'"),
] = ""
consecutive_text_only: Annotated[
int, Field(description="Count of consecutive text-only turns including this one")
] = 0
recovered: Annotated[
bool,
Field(
description=(
"Set True on a later turn if the model issued a real tool call "
"after this text-only reply (i.e. the corrective feedback worked)."
)
),
] = False


class LLMOutput(EventBase): # type: ignore[misc]
Expand Down
14 changes: 14 additions & 0 deletions src/nooa/runtime/context_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
ResolvedBlock,
Role,
)
from nooa.events import LLMOutput

if TYPE_CHECKING:
from nooa.config.truncation_config import FormatConfig
Expand Down Expand Up @@ -454,6 +455,19 @@ def _phase_events(
new_blocks: list[ResolvedBlock] = []

for event in events:
# Keep empty provider turns in the event log for persistence and
# diagnostics, but do not send an empty assistant message back to an
# API. CodeAct's text-only recovery appends its feedback after this
# event, so removing only the provider-visible block preserves the
# append-only history without producing an invalid message.
if (
isinstance(event, LLMOutput)
and not event.content
and not getattr(event, "llm_state", None)
and not getattr(event, "reasoning", None)
):
continue

tag = event.tag if event.tag is not None else event.id
event_role = getattr(event, "_role", Role.USER)
meta = BlockMetadata(expr=f'self.events["{tag}"]', tag=tag)
Expand Down
14 changes: 13 additions & 1 deletion src/nooa/strategies/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@

from nooa.config import CodeActConfig
from nooa.strategies.base import GenerationStrategy, RuntimeServices
from nooa.strategies.codeact import CodeActStrategy
from nooa.strategies.codeact import (
CodeActStrategy,
TextOnlyResponseAction,
TextOnlyResponseContext,
TextOnlyResponseHandler,
retry_text_only_response,
return_text_as_result,
)
from nooa.strategies.codeact_lite import CodeActLiteStrategy
from nooa.strategies.composite import CompositeStrategy
from nooa.strategies.current_call import CurrentCall
Expand Down Expand Up @@ -91,6 +98,11 @@ def set_default_strategy(strategy: GenerationStrategy | None) -> None:
"CompositeStrategy",
"TemplateStrategy",
"CodeActStrategy",
"TextOnlyResponseAction",
"TextOnlyResponseContext",
"TextOnlyResponseHandler",
"retry_text_only_response",
"return_text_as_result",
"CodeActLiteStrategy",
"ReflexionStrategy",
"PredictStrategy",
Expand Down
Loading
Loading