Skip to content
Closed
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
35 changes: 32 additions & 3 deletions AI_AGENT_INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,9 @@ Publish request fields:
| `force_extraction` | No | `False` for normal background publishing. `True` for manual learn-now, tests, or final flushes where you intentionally want extraction to run immediately. | `false` |
| `wait_for_response` | SDK/query option | `False` on interactive paths. `True` only when the caller is prepared to wait for extraction results. | `false` |

Each interaction row should resemble Reflexio's `InteractionData` shape:
Each interaction row must use Reflexio's `InteractionData` field names exactly.
Unknown keys are dropped (and reported in the response's `warnings[]`), so a
misspelling silently loses that value rather than being coerced:

```json
{
Expand Down Expand Up @@ -497,8 +499,35 @@ Follow these rules for production agent integrations:
- Keep hook latency bounded. Use short HTTP timeouts on interactive hooks.
- Never let Reflexio exceptions fail the user's agent turn.
- Buffer locally before network calls.
- Advance publish watermarks only after success.
- Retry failed publishes later.
- Advance publish watermarks only after success — but **classify the failure
first**. Holding the watermark on a permanently-rejected batch wedges the
buffer: the same batch is re-sent forever and nothing is ever published.
Advancing on a transient failure loses data. Neither default is safe, so
branch on the status:

| Response | Meaning | Do |
|---|---|---|
| `5xx`, timeout, connection error | Transient | **Hold** the watermark; retry with backoff |
| `408`, `429` | Transient (slow down) | **Hold**; retry with backoff, honour `Retry-After` |
| `401`, `403` | Auth/config broken, not the payload | **Hold** and escalate — do not discard; the data is fine |
| `400`, `422` | The payload itself is invalid | **Advance** past it; retrying cannot succeed |

A `422` names the offending `interaction_data_list` index. Prefer
dead-lettering just that row and re-sending its valid siblings over
discarding the whole batch — the batch is rejected as a unit, so a blind
advance drops good interactions alongside the bad one.
- Retry failed publishes later, using the same classification.
- Build the wire payload with an **allowlist** of `InteractionData` fields, not
by passing your buffer record through minus a few keys. A denylist silently
ships your internal bookkeeping and rots every time you add a record key.
- Every interaction must carry something — `content`, `shadow_content`,
`expert_content`, `interacted_image_url`, `image_encoding`, `tools_used`,
`citations`, `retrieved_learnings`, or a `user_action` other than `"none"`.
A wholly empty interaction is rejected with `422`; drop empty turns before
sending rather than letting one poison the batch.
- Unknown fields are accepted but **dropped**, and named back to you in the
response's `warnings[]`. Check that list: a populated one means a field you
sent did not bind and that data was silently discarded.
- Truncate large tool fields before publishing.
- Do not run extraction synchronously on every turn.
- Make retrieval query-aware; do not dump all memory into every prompt.
Expand Down
24 changes: 24 additions & 0 deletions reflexio/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,22 @@ def publish_interaction(
In ``wait_for_response=True`` mode the server waits for
extraction and the response includes ``request_id``,
storage routing, and real profile/playbook deltas.

``warnings`` reports anything that was quietly altered:
unrecognised interaction fields that were dropped (a key you
sent did not bind and its value was discarded — check for a
typo), and interactions that carried nothing and were
skipped. Indices refer to the list as you passed it. The list
is bounded, so on a large batch treat it as a sample.

Raises:
ValueError: If ``session_id`` is missing or blank.
pydantic.ValidationError: Raised locally, before any HTTP call, if
an interaction carries nothing at all (no content, image,
tools, citations, learnings, or non-"none" user_action), if a
``user_action`` has no ``user_action_description``, or if both
``interacted_image_url`` and ``image_encoding`` are set. The
message names the offending ``interaction_data_list`` index.
"""
if session_id is None or not session_id.strip():
raise ValueError("session_id is required and cannot be empty")
Expand All @@ -490,6 +506,14 @@ def publish_interaction(
result = self._publish_interaction_sync(
request, wait_for_response=wait_for_response
)
# Merge the warnings detected locally. Building InteractionData above
# already stripped the caller's unknown keys, so ``request.model_dump()``
# sends a clean payload and the server never sees them -- it cannot echo
# what it was never told. Without this, unrecognised fields are reported
# over raw HTTP but are invisible through the SDK, which is the primary
# integration path and the one this method's docstring promises.
if local_warnings := request.payload_warnings():
result.warnings = [*result.warnings, *local_warnings]
self._cache.invalidate("get_profiles")
self._cache.invalidate("get_agent_playbooks")
return result
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,26 @@

_LOGGER = logging.getLogger(__name__)


def _log_payload_warnings(result: object) -> None:
"""Log anything the publish quietly altered. Never raises.

The publish has already succeeded by the time this runs, and the caller
advances its publish watermark on the adapter's return value -- so an
exception here would be misread as a publish failure and stall the buffer.
Total belt-and-braces on a diagnostic path is the right trade.
"""
try:
warnings = getattr(result, "warnings", None) or []
if warnings:
_LOGGER.warning(
"publish_interaction altered the payload: %s",
"; ".join(str(warning) for warning in warnings),
)
except Exception: # noqa: BLE001 - diagnostics must never break publishing
_LOGGER.debug("could not render publish warnings", exc_info=True)


_ENV_URL = "REFLEXIO_URL"
_DEFAULT_URL = "http://localhost:8071/"
_SEARCH_MODE_HYBRID = "hybrid" # reflexio.models.config_schema.SearchMode.HYBRID
Expand Down Expand Up @@ -80,7 +100,7 @@ def publish(
if client is None:
return False
try:
client.publish_interaction(
result = client.publish_interaction(
user_id=project_id,
interactions=list(interactions),
agent_version=runtime.agent_version(),
Expand All @@ -89,11 +109,18 @@ def publish(
force_extraction=force_extraction,
skip_aggregation=skip_aggregation,
)
return True
except Exception as exc: # noqa: BLE001
_LOGGER.warning("publish_interaction failed: %s", exc)
return False

# Diagnostics only, and deliberately OUTSIDE the try above: the publish
# has already succeeded, and the caller advances its watermark on our
# return value. Anything raised while merely reporting warnings would be
# caught as a publish failure, stalling the watermark and republishing
# the same batch on every later hook.
_log_payload_warnings(result)
return True

def apply_extraction_defaults(self, *, window_size: int, stride_size: int) -> bool:
"""Push openclaw-smart's preferred extraction defaults to the reflexio server.

Expand Down
32 changes: 29 additions & 3 deletions reflexio/integrations/openclaw/plugin/src/openclaw_smart/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,28 @@

_TOOL_DATA_FIELD_MAX_LEN = 256

# Fields the server's ``InteractionData`` accepts. Declared literally rather
# than imported so this module keeps no runtime dependency on ``reflexio``;
# ``tests/test_state.py`` pins it against the real model so the two cannot
# drift. Anything not in here is buffer-internal bookkeeping and must not
# reach the wire.
_INTERACTION_DATA_FIELDS = frozenset(
{
"created_at",
"role",
"content",
"shadow_content",
"expert_content",
"user_action",
"user_action_description",
"interacted_image_url",
"image_encoding",
"tools_used",
"citations",
"retrieved_learnings",
}
)

_VALID_CITATION_KINDS = frozenset(
{"playbook", "profile", "user_playbook", "agent_playbook"}
)
Expand Down Expand Up @@ -315,9 +337,13 @@ def unpublished_slice(
pending_tools.append(tool_entry)
continue
if role in {"User", "Assistant"}:
turn = {
k: v for k, v in rec.items() if k not in {"role", "ts", "cited_items"}
}
# Allowlist, not denylist. This used to drop three known keys and
# pass everything else through, so buffer-internal bookkeeping
# (``user_id``, ``id``, ``kind``, ``title``, ...) rode along onto
# the wire. The server ignored it, but the denylist rots every time
# a hook adds a record key, and a stricter server turns that rot
# into a publish failure the adapter swallows silently.
turn = {k: v for k, v in rec.items() if k in _INTERACTION_DATA_FIELDS}
turn["role"] = role
if role == "Assistant":
citations = _to_wire_citations(rec.get("cited_items"))
Expand Down
42 changes: 42 additions & 0 deletions reflexio/integrations/openclaw/plugin/tests/test_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,3 +261,45 @@ def test_to_wire_citations_preserves_explicit_playbook_source_kind() -> None:
"user_playbook",
"profile",
]


class TestWirePayloadContract:
"""The wire dict must contain only fields the server's model accepts.

``unpublished_slice`` used to build the payload with a denylist (drop three
known keys, pass the rest through), so buffer bookkeeping such as
``user_id`` rode along. The server ignored it -- until it didn't, at which
point the adapter swallowed the error and the publish watermark never
advanced, so the same batch retried forever and nothing was published.
"""

def test_bookkeeping_keys_never_reach_the_wire(self):
_, turns = state.unpublished_slice(
[{"ts": 1, "role": "User", "content": "x", "user_id": "proj-a", "id": "r1"}]
)
assert turns, "expected one wire turn"
assert "user_id" not in turns[0]
assert "id" not in turns[0]
assert turns[0]["content"] == "x"

def test_allowlist_matches_the_server_model(self):
"""Pin the literal field set against the real InteractionData."""
interaction_data = pytest.importorskip(
"reflexio.models.api_schema.domain.entities"
).InteractionData
assert set(interaction_data.model_fields) == state._INTERACTION_DATA_FIELDS

def test_wire_turn_validates_against_the_server_model(self):
"""Every dict the slicer emits must construct a real InteractionData."""
interaction_data = pytest.importorskip(
"reflexio.models.api_schema.domain.entities"
).InteractionData
_, turns = state.unpublished_slice(
[
{"ts": 1, "role": "User", "content": "hi", "user_id": "p"},
{"ts": 2, "role": "Assistant", "content": "hello", "user_id": "p"},
]
)
for turn in turns:
built = interaction_data(**turn)
assert built.unknown_field_names() == []
Comment on lines +285 to +305

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check whether the plugin's test job installs the reflexio package (so importorskip actually exercises these tests).
fd -e yml -e yaml . .github/workflows 2>/dev/null | xargs -I{} rg -n -i 'openclaw|reflexio' {}
fd pyproject.toml requirements.txt setup.cfg reflexio/integrations/openclaw

Repository: ReflexioAI/reflexio

Length of output: 329


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== workflow matches =="
fd -e yml -e yaml . .github/workflows 2>/dev/null | sort | while read -r f; do
  if rg -n -i 'openclaw|reflexio|pytest|pip install|uv install|poetry' "$f" >/dev/null; then
    echo "--- $f"
    rg -n -i 'openclaw|reflexio|pytest|pip install|uv install|poetry' "$f" || true
  fi
done

echo "== openclaw pyproject =="
cat -n reflexio/integrations/openclaw/plugin/pyproject.toml | sed -n '1,220p'

echo "== nearby test lines =="
cat -n reflexio/integrations/openclaw/plugin/tests/test_state.py | sed -n '250,315p'

Repository: ReflexioAI/reflexio

Length of output: 180


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== workflow files =="
fd -e yml -e yaml . .github/workflows 2>/dev/null | sort | sed -n '1,120p'

echo "== any workflow mentioning openclaw/reflexio/pip/pytest =="
while IFS= read -r f; do
  if rg -n -i 'openclaw|reflexio|pip install|uv install|pytest|pipx install|pdm|poetry|tox' "$f" >/dev/null; then
    echo "--- $f"
    rg -n -i 'openclaw|reflexio|pip install|uv install|pytest|pipx install|pdm|poetry|tox' "$f" || true
  fi
done < <(fd -e yml -e yaml . .github/workflows 2>/dev/null)

echo "== openclaw plugin metadata and dependencies =="
cat -n reflexio/integrations/openclaw/plugin/pyproject.toml | sed -n '1,220p'

echo "== target tests =="
git ls-files 'reflexio/integrations/openclaw/plugin/tests/*' 'reflexio/integrations/openclaw/plugin/**' | sort
cat -n reflexio/integrations/openclaw/plugin/tests/test_state.py | sed -n '250,315p'

echo "== references to imported InteractionData constants/model in openclaw plugin =="
rg -n '_INTERACTION_DATA_FIELDS|InteractionData|unknown_field_names|pydantic' reflexio/integrations/openclaw/plugin -S --glob '*.py' || true

Repository: ReflexioAI/reflexio

Length of output: 178


🏁 Script executed:

#!/bin/bash
set -u

echo "== repo root files =="
git ls-files | sed -n '1,160p'

echo "== workflows (if present) =="
if [ -d .github/workflows ]; then
  fd -e yml -e yaml . .github/workflows -0 | sort -z | tr '\0' '\n'
else
  echo ".github/workflows not present"
fi

echo "== openclaw plugin tree =="
git ls-files 'reflexio/integrations/openclaw/plugin/**' | sort | sed -n '1,200p'

echo "== pyproject/lock/dependency files mentioning openclaw/reflexio =="
for f in $(git ls-files | rg '(^|/)(pyproject\.toml|requirements.*\.txt|setup\.cfg|poetry\.lock|uv\.lock|Pipfile|Pipfile\.lock|tox\.ini|environment\.yaml|environment\.yml)$'); do
  if rg -n -i 'openclaw|reflexio|pytest|pip install|uv install|poetry|pdm|tox' "$f" >/dev/null 2>&1; then
    echo "--- $f"
  fi
done

Repository: ReflexioAI/reflexio

Length of output: 10794


Make the _INTERACTION_DATA_FIELDS assertions fail when the server model changes.

test_allowlist_matches_the_server_model and test_wire_turn_validates_against_the_server_model currently use pytest.importorskip; if reflexio is unavailable, these drift detectors skip instead of failing. Use installed-model fixtures or make missing imports a hard test failure before relying on these assertions to catch publish-field drift.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@reflexio/integrations/openclaw/plugin/tests/test_state.py` around lines 285 -
305, Update test_allowlist_matches_the_server_model and
test_wire_turn_validates_against_the_server_model to require the server
InteractionData model instead of using pytest.importorskip. Use an
installed-model fixture or otherwise make an unavailable reflexio import fail
the tests, ensuring both assertions detect publish-field drift rather than being
silently skipped.

Loading