feat: trace events as single source of truth + flatten agents folder - #35
Conversation
Kaiohz
left a comment
There was a problem hiding this comment.
PR Review — chore: remove bricks folder structure from agents (#35)
Note on PR scope: The PR title and body describe a folder-flattening chore, but the actual diff is much larger: a 3-revision alembic migration (005→007) introduces a new trace_events table that replaces messages as the single source of truth, with Message reduced to a computed projection. The agent runner (deepagent/adapter.py, +360/-173) is rewritten to emit TraceEvents (HUMAN_MESSAGE, THINKING, CONTENT, TOOL_CALL, TOOL_RESULT, AI_MESSAGE) instead of the legacy StreamEvent/Message flow. New routes, use cases, repositories and a new error message catalogue come with it. I'd recommend renaming the PR to something like feat: trace events as single source of truth + flatten agents folder so reviewers and the changelog reflect what's actually shipping.
That said, the new design is clean and well-thought-out. The hexagonal boundaries (port / adapter / use case) are respected, the entity is frozen=True, the trace is reconstructable, and the agent runner is split into small, testable classifiers (_classify_thinking, _classify_tool_call_chunks, _classify_tool_result). Lots of detail to look at below.
Score: 7 / 10 — Approve after a few small fixes
The architecture is solid and the test coverage looks strong. Deducting points for: a misleading PR title, one real bug in the migration downgrade, a metadata_ ORM/attribute naming mismatch, and a "messages" backward-compat attr that can lie. Once those are fixed and the title is fixed, this is a clean ship.
Blocking issues (request changes)
1. Alembic 006 downgrade is broken (data not actually back-migrated).
src/alembic/versions/006_migrate_messages_to_trace_events.py's downgrade() builds the INSERT INTO messages statement but truncates the parameter list: the SQL is
INSERT INTO messages (id, thread_id, ro… and is cut off mid-line. The downgrade is therefore untested and would raise a SQL syntax error the first time anyone tries to roll back. With a 3-step migration that drops the legacy table in 007, an untested downgrade is a real risk for disaster recovery. Either:
- Build the row dict the same way
upgrade()does (onedictper row) and useconn.execute(text(INSERT_SQL), values), or - Drop the downgrade and document that 006/007 are one-way (acceptable, but say so explicitly).
2. metadata_ attribute shadowing will bite consumers.
TraceEventModel.metadata_: Mapped[dict | None] = mapped_column("metadata", JSON, ...) is mapped to the column name metadata (good), but the ORM attribute is metadata_ because metadata is reserved by SQLAlchemy's DeclarativeBase. In postgres_trace/adapter.py you correctly use model.metadata_ to build the domain entity — but postgres_thread/adapter.py's _model_to_thread reads m.metadata_ too (line ~46) which works only because every call site uses the trailing underscore. Anyone writing a new query that does TraceEventModel.metadata will silently hit Base.metadata. Two safer options:
- Rename the ORM attr to
event_metadataand keep the column name asmetadata(mapped_column("metadata", ..., name="metadata")). - Or expose a
@property metadata(self)that returnsself.metadata_, so the public API is stable.
Pick one and apply it consistently in both adapters.
3. Thread.messages is not in insertion order — it will misorder interleaved turns.
Thread.messages (in src/domain/entities/thread.py) sorts by timestamp only:
message_events = sorted(
(e for e in self.trace_events if e.type in (HUMAN_MESSAGE, AI_MESSAGE)),
key=lambda e: e.timestamp,
)If two events of a multi-turn conversation land in the same millisecond (very common for batched persistence and a fresh turn_id per turn), the order falls back to Python's stable sort — which is the iteration order of self.trace_events. That order is only guaranteed by the SQL order_by="TraceEventModel.timestamp" clause in the relationship, which again doesn't break ties. For a turn that emits HUMAN_MESSAGE → AI_MESSAGE followed by a new HUMAN_MESSAGE in <1 ms, the projection can return [HUMAN(t1), HUMAN(t2), AI(t1)] instead of [HUMAN(t1), AI(t1), HUMAN(t2), AI(t2)]. The fix is trivial: sort by (timestamp, sequence) — sequence is the monotonic in-turn counter and is already guaranteed unique within a turn. GetThreadHistoryUseCase.execute does this correctly; the entity's messages property does not.
Strong suggestions (not blocking)
4. PostgresTraceEventRepository.add opens a session per event in the stream path — and the stream use case calls it once per emitted event. That's N round-trips to Postgres per turn (every THINKING / CONTENT / TOOL_CALL chunk is a separate INSERT). It's intentional for durability (so a stream disconnect keeps the partial trace), but the cost is real: a tool-heavy turn with 50 events = 50 inserts. Two pragmatic options:
- Add a
flush_in_progressmode on the repo that buffers events in-memory and flushes every N events or T seconds, with afinally: flush()inStreamMessageUseCase. - Or document the cost explicitly in the use case docstring and make it configurable (settings.lemtrace_flush_interval).
At minimum, add a log line CHAT_STREAM_FLUSH %d events every N events so this is visible in production.
5. trace_repo.list_by_thread / list_by_turn don't LIMIT and don't filter on thread existence first. get_thread_history and list_trace already call use_case.execute(thread_id) first, so the 404 is enforced, but list_by_thread itself will silently return [] for a non-existent thread (no 404), which is inconsistent with add / add_batch that raise ThreadNotFoundError. Pick a side: either always 404 (cheapest: also call _assert_thread_exists here) or never 404 (drop the assert in add and use ON DELETE CASCADE + an empty list). The current asymmetry will confuse anyone calling the repo directly.
6. _make_trace_event for AI_MESSAGE embeds status both in the Message JSON and in the event metadata. In _collect_trace:
yield self._make_trace_event(
...,
TraceEventType.AI_MESSAGE,
None, None,
final_message.model_dump_json(),
{"status": final_message.status.value if final_message.status else None},
)Message.model_dump_json() already serialises status (it's a field on the entity), and the migration backfills it the same way (payload["status"] = row.status). The redundant metadata.status is harmless but inconsistent: Message.from_trace_event reads status from the JSON payload, so metadata.status is dead weight. Either drop it or document that metadata is the authoritative source and the JSON payload is a snapshot for legacy readers.
7. _patch_tool_node_error_handling monkey-patches deepagents at runtime. The TODO is right — ToolsNode._handle_tool_errors = True is reaching into private state. Two cleanups:
- If the project's pinned deepagents version exposes
handle_tool_errorsat construction (most do), set it infactory.pyviacreate_deep_agent(tools=[...], handle_tool_errors=True). - If not, wrap the patch in a
try/exceptforAttributeErrorand log at WARNING (not INFO) the first time it fires — currently thehasattrguard means the WARNING logs are noise that nobody reads.
8. _extract_source is fragile to langgraph's NS_SEP. parts.index("task") will raise ValueError if "task" ever disappears from the namespace, and the helper assumes the third-to-last token is always the subagent name. Add a unit test that pins the expected output for a known checkpoint namespace (e.g. "tools:task:security-auditor:tools" → "security-auditor") and another for the parent-agent case ("agent" or "" → None). Right now the only path to "subagent trace events" is the integration test, which is slow and runs against a real graph.
9. The PR adds 4 new agent YAMLs but no docs / catalog link. The README.md change (+260/-40) likely covers the trace-events refactor, but the 4 new agents (haiku-files-local-structured.yaml, haiku-rag-formation.yaml, haiku-rag-local.yaml, orchestrator-test-structured.yaml) are not referenced anywhere. If they're meant to ship, they need a catalog entry (the existing README table is gone after the refactor — please confirm) or a CHANGELOG line. If they're test fixtures, they shouldn't be in agents/ — move them to tests/fixtures/agents/ or agents/examples/.
10. _unpack_stream_item is dead-branch-heavy. The del metadata, source in _classify signals the params aren't used downstream — that's a code smell. Either thread metadata through (source is already extracted upstream) or drop the parameters from _classify entirely. Right now the signature lies about what the function uses, which is a future-bug magnet.
Nitpicks
stream_message.pyline ~50: the broadexcept Exceptioninside the innertryre-raises asStorageErrorand then is caught by the outerexcept Exceptionthat logs and re-raises again. The error path isStorageError → logged as "stream error" → re-raised as StorageError. The intent is probably to log the underlying persistence error and surface a clean domain error, but the double log is confusing. Drop the innerlogger.exceptionand keep just theraise StorageError(...) from exc— the outer use-case logger will pick it up.dependencies.py: the newCompositionRootdataclass replaces module-level globals but the old globalsecurityandtracing_providerare still module-level. That's fine, but worth a one-line comment in the dataclass that some singletons (security, tracing_provider) stay module-level on purpose because they're not async-init.haiku-rag-formation.yaml"minor content change" per the PR body — the new agent's prompt is good, but the URL ishttp://raganything-api:8000/classical/mcp(no auth header) whilehaiku-rag-local.yamlsetsX-API-Key: "${MCP_RAGANYTHING_API_KEY}". Two agents, two contracts against the same backend — pick one and use it everywhere, or document the difference.add_batchusessession.add_all(...)but the modelmetadata_column isJSONB(server-side default) — fine, but theTextcolumns oncontentandnamelack an explicitnullable=True(they default to nullable in SQLAlchemy whenMapped[str | None]is used, but it's clearer to be explicit in the migration).trace_event_repository.py'slist_messagesis implemented but never called from any use case (the use cases go throughlist_by_thread+ themessagesproperty). If it's a public API for the future, keep it; if not, drop it to avoid the maintenance surface.
What's good
- Architecture is clean. The hexagonal split is respected: the runner port emits
TraceEvent, the persistence port storesTraceEvent, the entity is immutable, the use case is purely a projection. No layer leaks. - Migration is reversible in shape (downgrade schema exists, even if 006's data downgrade is broken — fix that, and you're golden).
- The
Message.from_trace_eventprojection is a beautiful example of "make the new schema the source of truth, derive the old shape lazily." It keeps every downstream consumer working without changing their contracts. - Test coverage is strong —
test_trace_event,test_trace_repository,test_get_thread_history,test_send_message,test_stream_message,test_routes(incl. /trace and /history integration tests). 194 lines of new tests for the use case alone. frozen=TrueonTraceEventand thesequenceordering make the entity correctly immutable and totally reconstructable. Nice.lazy="raise"on thetrace_eventsrelationship inThreadModelis the right call — silent lazy loads are a classic N+1 trap and you've eliminated it.- The stream use case persists before yielding. This is the right durability-vs-latency trade-off for a chat API and the code is clear about it.
make_validation_modelhashes the schema so Pydantic re-uses the class across invocations — small but real performance win for repeat calls with the same response_format.- The error catalogue is centralised in
domain/logging/messages.pyanddomain/errors/messages.py. Greppable, auditable, and the new trace events added their own error messages (TRACE_FAILED_ADD, etc.) instead of inlining strings.
Pre-merge checklist
- Rename the PR (it's not just a folder flattening)
- Fix alembic 006 downgrade or document it as one-way
- Rename
metadata_or add ametadataproperty on the model - Sort
Thread.messagesby(timestamp, sequence) - Make
list_by_thread404 symmetric withadd/add_batch, or drop the assert inadd - Add
_extract_sourceunit tests - Either move the 4 new agents to a
examples/ortests/fixtures/agents/subfolder, or add them to the catalog - Verify the integration test (
test_deepagent_real_graph.py) still passes — only +1/-3 lines there, and it exercises the whole new trace-events flow
Solid work. The new design is a real improvement over the dual messages / stream_event mess; this is the kind of refactor that pays for itself the first time you need to add a tool_call event to a persisted thread.
55b562b to
61197b8
Compare
Kaiohz
left a comment
There was a problem hiding this comment.
PR Review — feat: trace events as single source of truth + flatten agents folder (#35)
Score: 8.5/10 — Très gros refactor architectural (74 fichiers, +4185/-1357) qui remplace l'ancien modèle messages par un store d'événements immutable trace_events, et qui aplatit agents/bricks/ → agents/single/. La CI passe (test ✓). La description de la PR est trompeuse : c'est ~80% refactor TraceEvent et ~20% flatten.
✅ Points forts
- Vraie single source of truth : la table
trace_eventscapture maintenant tous les types d'événements (HUMAN_MESSAGE,AI_MESSAGE,THINKING,CONTENT,TOOL_CALL,TOOL_RESULT) avec unturn_idUUID v4 + unsequencemonotone.Messagedevient une projection réversible deHUMAN_MESSAGE+AI_MESSAGEviaMessage.from_trace_event— propre et backward-compatible. - Migrations Alembic propres (005/006/007) : la 005 crée la table avec 3 index bien choisis (
thread_turn,thread_type,thread_tspour les 3 access patterns). La 006 est réversible :upgrade()migre les anciens rôlesmessagesvers les typestrace_events(human/ai/tool/system), etdowngrade()reconstruit la tablemessagesen déserialisant le JSON de l'AI_MESSAGEpayload. La 007 drop simplement. - Use cases minces et clairs :
SendMessageUseCasegénère unturn_id, invoke le runner, persiste viaadd_batch()en un seul round-trip.StreamMessageUseCasepersiste chaque event au fil de l'eau viaadd()— la trace survit même si le client se déco en plein stream. - Architecture hexagonale respectée : nouveau port
TraceEventRepository(ABC), adapterPostgresTraceEventRepositoryqui ouvre sa propre session par méthode,TraceEventimmutable (frozen=True) avecStrEnumpour les types. - Décomposition du runner :
_classify_thinking,_classify_tool_call_chunks,_classify_tool_result,_extract_source(sous-agent depuislanggraph_checkpoint_ns)._collect_traceyieldHUMAN_MESSAGE→ intermediates →AI_MESSAGEavecsequencemonotone. - Test coverage solide : nouveau
test_trace_repository.py(8 tests : add, add_batch, list_by_turn, list_messages, thread_not_found, empty),test_get_thread_history.py(194 lignes),test_send_message.py(+133/-55),test_stream_message.py(+100/-61),test_trace_event.py(147 lignes),test_schema_utils.py(+448/-4 sur 81 cas probablement). - Migration de la config agents : tous les YAMLs de
bricks/→single/, README +260 lignes (nouveau formatresponse_formatnatif avecAutoStrategyProvider/Tool langchain,haiku-rag-formation.yamlmis à jour),agents/README.mdcréé. - README bien synchronisé : nouveau bloc "Breaking change (trace events)" qui prévient de la suppression de
StreamEventSSE et de la tablemessages. Section "Structured Output" qui documente la nouvelle voie native (vs l'ancien hack_create_response_tool). CompositionRootdataclass dansdependencies.pyau lieu de globals mutables — bonne pratique déjà en place.- TODO laissé visible :
_patch_tool_node_error_handlingreconnaît lui-même que c'est un hack de runtime et suggère de le faire à la construction du ToolNode dans le factory.
💡 Suggestions (mineures, à discuter — pas bloquantes)
-
API privée
langgraph._internal._constants.NS_SEP(ligne ~46 deinfrastructure/deepagent/adapter.py) :from langgraph._internal._constants import NS_SEPutilise un chemin préfixé par_internal_. LangGraph peut le renommer ou le déplacer à n'importe quelle release mineure (ils le font déjà régulièrement). Suggéré : tester l'import au démarrage avec un fallback propre, ou extraire le séparateur depuis un appelstr(metadata["langgraph_checkpoint_ns"])côté runtime plutôt que d'importer le nom. -
Migration 006 —
tool_call_idperdu sur lestoolmessages :_build_event_rowmappemessages.role='tool'→trace_events.type='tool_result'mais ne récupère pas letool_call_id(qui n'existait probablement pas comme colonne dans l'ancienne tablemessages, d'où le passage parmetadata.tool_calls). Si l'ancien modèle stockaittool_call_idquelque part, il est perdu. À vérifier : la tablemessageslegacy avait-elle une colonnetool_call_id? Si oui, il faut l'ajouter à_fetch_messages+_build_event_row. Le code runner actuel stocke bientool_call_iddansmetadatapour les TOOL_RESULT, donc la migration est probablement OK si la legacy table n'avait pas cette info — à confirmer. -
Race condition possible sur
add_batch: si un client fait 2addconcurrents sur le mêmeturn_id, lessequencepeuvent se chevaucher (chacun appelleseqlocalement dans le runner, OK pour un seul turn). Mais 2 turns concurrents sur le même thread →add_batchse base sur le compteur séquence du runner, pas de la DB. Vu que chaque turn a son propre runner invoke, c'est probablement safe en pratique. À documenter dans une ADR si pas déjà fait. -
Message.from_trace_eventlèveValueErrorsur un type autre que HUMAN/AI_MESSAGE : c'est OK pour le contrat actuel mais ça veut dire qu'on a une exception runtime non-typée domain (ValueErrorPython) qui peut remonter du use case. Suggéré : créer unMessageBuildError(déjà dansdomain/errors/probablement) pour que le use case puisse le catch et le convertir enAgentError500 cohérent. -
ThreadModel.trace_eventsaveclazy="raise": si jamais un autre code accède àthread.trace_eventssans avoir explicitement chargé la relation (viaselectinloadoujoinedload), SQLAlchemy lèvera uneMissingGreenletouDetachedInstanceError. C'est une décision volontaire et OK si tous les accès se font viatrace_repo.list_by_thread(...). À documenter en commentaire, sinon le prochain dev qui touche le code va se faire piéger. -
CompositionRoot reset() : il y a un
def reset()dansdependencies.pyqui mutNonetous les attributs du dataclass — mais c'est exposé publiquement. Suggéré : le préfixer avec_(ou le rendre package-private) pour éviter qu'un test l'appelle accidentellement en prod. -
README — section "Multi-Agent Architecture" : la mention "Each
TraceEventthey emit includes the sub-agent name in itssourcefield" est cohérente avec_extract_sourcemais ça veut dire que le client doit connaître la liste des sub-agents pour grouper. Suggéré : ajouter un exemple concret d'utilisation côté frontend (même minimal) ou un lien vers la doc. -
Test manquant potentiel : pas vu de test d'intégration qui round-trip un turn complet (HUMAN → THINKING → TOOL_CALL → TOOL_RESULT → AI_MESSAGE) et vérifie l'ordre et la persistance via
add_batch. Le testtest_send_message.pymocke le runner — un test du_collect_traceend-to-end avec un fake graph serait précieux. (Note :test_runner_tracing.pyexiste peut-être déjà, à vérifier.)
📋 Verdict
APPROVE avec commentaires. Le refactor est bien pensé, bien testé, bien migré. La CI est verte, l'archi hexagonale est respectée, le format TraceEvent est extensible (rajouter un type plus tard = juste un StrEnum + un cas dans _classify). Le seul vrai risque est l'import langgraph._internal._constants qui devrait être neutralisé avant un upgrade de langgraph. Le flatten de agents/ est un bonus et bien géré (README + table bricks legacy supprimée proprement).
Solide travail. 🚀
61197b8 to
abb28a5
Compare
Kaiohz
left a comment
There was a problem hiding this comment.
Code Review — PR #35: feat: trace events as single source of truth + flatten agents folder
Summary
Substantial refactor that introduces trace_events as the new single source of truth, replaces the legacy messages table via a 3-step migration (005 → 006 → 007), and flattens the agents folder structure. The Message entity is downgraded to a backward-compatible computed_field projection over trace_events. Architecture hexagonal respected end-to-end (new TraceEventRepository port + PostgresTraceEventRepository adapter). Squash commit, 75 files, +4205/-1357.
Score: 7.5 / 10
Solid architectural move, very clean refactor of the runner and schema utilities, good test coverage of the new use case and repository. The follow-up review after the force-push (#1 → #2) confirms concerns from the previous round were addressed (PR is now mergeable, all critical bugs from review #1 are fixed). However a few real bugs and a couple of architectural smells remain.
✅ Strengths
-
Messageas acomputed_fieldprojection overTraceEvent— excellent use of Pydantic'scomputed_fieldto keep backward compat without duplicating state. Themessagesfield is rebuilt on access fromtrace_events, and any consumer that readsthread.messageskeeps working unchanged. -
Message.from_trace_eventstatic factory — single, well-documented entry point for reconstructing Messages from events. HandlesHUMAN_MESSAGEandAI_MESSAGEonly, raisesMessageBuildErrorfor other types. Clean. -
TraceEventisfrozen=Truewith aStrEnumtype discriminator — immutable, type-safe, serializable. Exactly the right model for an event-sourced log. -
Migration downgrade is full — 006's downgrade rebuilds the
messagestable AND backfills HUMAN_MESSAGE + AI_MESSAGE rows from the trace events (it even reconstructs the JSON payload forai_message). This is rare and valuable; most teams skip downgrades. -
New ports and use cases are clean —
TraceEventRepositoryport,GetThreadHistoryUseCase,TraceEventTypeenum,ThreadHistory/Turnresponse DTOs. Hexagonal boundaries respected. -
Schema utilities refactor — the new
schema_to_pydantic_modelhandlesenum,anyOf, andtype: ["a", "null"](nullable unions) where the oldmake_validation_modelonly handled primitives and objects. Better correctness. -
Tests are thorough and realistic —
test_get_thread_historycovers the chronological-vs-alphabetical ordering edge case (turn_id is a random UUID, so it must NOT be used for ordering), andtest_send_message.test_each_call_generates_new_turn_idvalidates turn_id uniqueness across consecutive calls. This is the right kind of paranoia. -
Flatten
agents/folder — the bricks/ subfolder was never useful, dropping it is a clear win. -
All review #1 critical issues are fixed —
Message,MessageModel,MessageNotFound/etc. errors are gone; the onlymessagesimport remaining independencies.pyis the static ErrorMessage enum entry. The PR is nowmergeable: clean.
🐛 Bugs
1. test_invoke_timeout_raises_agent_error test is broken — passes for the wrong reason
tests/unit/test_deep_agent_runner.py lines ~366-378:
async def _astream_hang(_input, **_kwargs):
await asyncio.sleep(10)
return
yield # noqa: F841
graph.astream = _astream_hangThe return statement before yield makes this an async generator that yields zero times before returning. The first await anext(stream_iter) in _stream_intermediate_events will hit StopAsyncIteration immediately and exit the loop — never reaching the await asyncio.sleep(10) and never triggering the idle timeout.
The test will pass, but it doesn't actually test what it claims to test. The test should await asyncio.sleep(10) before any yield, or use await asyncio.sleep(0.05); yield (...) to simulate a slow chunk.
Fix: either yield at least once after a sleep, or test the timeout against _stream_intermediate_events with a producer that yields slowly.
2. Migration 006 downgrade drops tool_result events
src/alembic/versions/006_migrate_messages_to_trace_events.py:
- Upgrade maps
role="tool"→trace_events.type="tool_result"(preservingtool_callsandstatusin metadata). - Downgrade SELECT only filters
type IN ('human_message', 'ai_message')—tool_resultrows are silently dropped.
If someone rolls back to the legacy messages table, the tool messages vanish. The downgrade should either:
- (a) backfill
role='tool'rows from thetool_resulttrace events, or - (b) document loudly that
toolmessages are lost on downgrade (acceptable trade-off, but must be explicit in the docstring of the downgrade).
The upgrade docstring is already honest about the same tool_call_id limitation, so the precedent is set. Just apply the same honesty to the downgrade.
3. _build_response lost the _try_parse_json fallback
The old adapter had a 3-step fallback for structured_response:
- extract from
tool_calls(ToolStrategy mode) - extract from
result["structured_response"](ProviderStrategy native) - fall back to parsing the last message content as JSON (covers legacy agents that emit JSON in their text content)
Step 1 is also gone. The new logic is:
# 1. Native structured_response (ProviderStrategy/ToolStrategy native mode).
raw_structured = result.get("structured_response")
if hasattr(raw_structured, "model_dump"):
structured_response = raw_structured.model_dump()
elif isinstance(raw_structured, dict):
structured_response = raw_structuredIf an agent still emits tool_calls=[{"name": "structured_response", ...}] (the pre-Ticket-3 pattern) or JSON in its content, the field stays None and validation against the response_format model is skipped. That may or may not be intentional, but it's a behavior change and there's no migration guide. Worth either:
- (a) keeping the old fallbacks (cheap, defensive), or
- (b) explicitly documenting that the legacy patterns are unsupported post-migration.
Same comment for the legacy _extract_source parsing of langgraph_checkpoint_ns: the test for this is missing entirely.
4. stream_message use case: hard-coded chunk_count=0 on error
src/application/use_cases/stream_message.py:
except Exception:
logger.exception(LogMessage.CHAT_STREAM_ERROR_UC, thread_id, thread.agent_name, 0)
raiseThe previous version tracked a real chunk_count. The new one always logs 0, which makes the log line useless for production debugging. If persist-on-each-event throws on the 5th event of a 20-event turn, the log will say "0 events" and operators will be misled.
Fix: track a counter and pass the actual count.
5. first_chunk log message removed without replacement
The old adapter logged LogMessage.AGENT_FIRST_CHUNK on the first chunk with elapsed time — useful for monitoring TTFT (time-to-first-token) in production. The new adapter silently dropped this. Not a bug per se, but it's a regression in observability.
🏗️ Architectural smells
6. del metadata, source in _classify is a code smell
@staticmethod
def _classify(chunk, metadata: dict, source: str | None) -> list[ClassifiedEvent]:
del metadata, source
events: list[ClassifiedEvent] = []
...Using del to mark unused parameters is non-Pythonic and confusing — readers will think you're cleaning up locals. Either:
- prefix unused args with
_(_metadata,_source) so the linter accepts them, or - drop them from the signature entirely (they're not used by the classifier;
sourceis computed in_stream_intermediate_eventsand passed in by the caller but the classifier ignores it).
The latter is cleaner: _classify(chunk) doesn't need either.
7. NS_SEP fallback hides a brittle dependency
try:
from langgraph._internal._constants import NS_SEP
except ImportError:
NS_SEP = "|"Importing from langgraph._internal is by definition unstable — the underscore prefix signals it can change without notice. If langgraph ever renames or relocates this constant, subagent detection silently falls back to "|" and may parse namespaces incorrectly without any warning. Consider:
- wrapping the import in a custom
SubagentSourceExtractorinterface (port) with the current implementation ininfrastructure/deepagent, - logging a warning on the
ImportErrorso the dependency drift is visible, - or pinning the langgraph version that ships this symbol.
8. stream_message and send_message use cases don't validate body.message early
/api/v1/threads/{id}/chat/stream accepts a ChatRequest. Looking at the new stream_message route, it passes body.message or "" to the use case — silently converting a missing message to an empty string. The use case then calls runner.stream(thread_id, "", turn_id) which is meaningless. The previous version also had this bug, but the refactor was a good moment to add a 422 if body.message is empty/whitespace.
Same in SendMessageUseCase.execute — the early HITL-action validation is great, but there's no equivalent check for if message and not message.strip(): raise InvalidRequestError.
9. ThreadHistory.turns ordering is by turn_order (insertion order), not the explicit sort key in the docstring
get_thread_history.py:
sorted_events = sorted(trace_events, key=lambda e: (e.timestamp, e.sequence))
turns_map: dict[str, list] = defaultdict(list)
turn_order: list[str] = []
for ev in sorted_events:
if ev.turn_id not in turns_map:
turn_order.append(ev.turn_id)
turns_map[ev.turn_id].append(ev)The code is correct (turn_order is appended in iteration order of sorted_events, which is by timestamp), but it's indirect. A more explicit version:
turn_order = list(dict.fromkeys(e.turn_id for e in sorted_events)) # first-seen order preservedwould be one line, no defaultdict, and self-documenting. Not blocking, just nicer to read.
10. The new src/application/routes/trace.py only validates thread existence but the response payload is a free-form dict
@router.get("/{thread_id}/trace") returns dict, not a typed DTO. The events field is then serialized through Pydantic's model_dump of TraceEvent — but the response type is dict[str, list[TraceEvent]]. Should be a typed DTO TraceListResponse(events=list[TraceEvent]) to keep the contract explicit and OpenAPI accurate.
📋 Minor / nits
-
PR description is too short — "Move agent YAML configs from agents/bricks/ to agents/ root level" and "Updated haiku-rag-formation.yaml (minor content change)" misses the biggest change in the PR (the trace_events migration). Reviewers had to discover the architectural shift by reading the diff. A two-paragraph description with "WHY" (single source of truth, eliminate the dual-write to messages + langgraph checkpoint) and a short "MIGRATION" section would help reviewers and future-archeologists.
-
.DS_Storewas committed (agents/bricks/.DS_Store→ renamed to root with no content change). macOS artifact. Should be in.gitignore. -
STORAGE_FAILED_PERSIST_STREAMerror — used bystream_messagewhentrace_repo.addfails, but the ErrorMessage enum is inmessages.pyand the format is{error}. The error chain is helpful but the user-facing message is just "Failed to persist: ". Consider a more user-friendly message in the route layer and only log the technical detail. -
tests/unit/test_thread_management.pyformatting changes — pre-existing YAML tests had multi-line strings reformatted to one-liners, presumably by a formatter. The result is harder to read. Pure noise in the diff; should have been a separate commit or skipped. -
No integration test for the new
GET /threads/{id}/historyandGET /threads/{id}/traceroutes — unit tests cover the use cases but not the FastAPI route layer. Same gap as for the chat routes, but a 2-3 line TestClient test would catch schema mismatches. -
schema_utils._build_array_modeldoesn't handleitems: {anyOf: [...]}— only handlesitems: {type: "object"}vs primitive. If a JSON schema hasitems: {anyOf: [{type: "string"}, {type": "null"}]}(an array of nullable strings), the current code falls through tolist[str], which is wrong. Probably out of scope but worth noting. -
tests/unit/test_trace_event.pyuses__import__("json").dumps(payload)instead of a cleanimport jsonat the top of the file. Unusual style, looks like it was added under deadline pressure.
🎯 Verdict
APPROVE with minor changes recommended (would have asked for changes before #2 due to the test timeout bug, the migration downgrade data loss, and the _build_response regression).
Before merge:
- Fix
test_invoke_timeout_raises_agent_errorso it actually tests a timeout (1-line change). - Decide on the 006 downgrade policy for
tool_resultevents and update the docstring (or backfill them). - Restore a
chunk_countinstream_messageerror log (or accept the regression and remove the parameter from the log message). - Add an integration smoke test for
/threads/{id}/history.
Nice to have (post-merge):
- Restore
first_chunklog line with TTFT. - Replace
del metadata, sourcewith proper_prefix. - Move
_extract_sourceto a port with a fallback warning for the langgraph internal import. - Expand the PR description to explain the "why" of the trace_events migration.
The architectural decision is sound: trace_events as a single source of truth, Message as a projection, hexagonal ports everywhere, and a real (not faked) backward-compat layer. The implementation is mostly clean and the tests are above average. The bugs listed are small and well-isolated. Solid work.
- Move agent YAML configs from agents/bricks/ to agents/ root level - Trace events replace messages as the single source of truth - Message entity reduced to a computed projection from trace events - Agent runner rewritten to emit TraceEvents (HUMAN_MESSAGE, THINKING, CONTENT, TOOL_CALL, TOOL_RESULT, AI_MESSAGE) - New routes, use cases, repositories and error message catalogue - Alembic migrations 005-007 for trace_events table
abb28a5 to
219bd42
Compare
Kaiohz
left a comment
There was a problem hiding this comment.
PR Review — feat: trace events as single source of truth + flatten agents folder
Score global : 7/10 — refactor de fond bien pensé et aligné avec les principes hexagonal/SOLID du repo, mais avec quelques angles morts (idempotence des retries, persistance HITL, tests manquants sur schema_utils, migration downgrade fragile). CI verte (test ✓), PR mergeable, 1 commit (squash ready), 78 fichiers / +4219 / −1356.
✅ Points forts
- Migration
Message→ projection desTraceEvent—Message.from_trace_event()+Thread.messagesencomputed_field→ backward-compat gratuite pour tous les call-sites existants. Excellent design, le bon tradeoff. - Migration Alembic en 3 étapes (005 create → 006 backfill → 007 drop) avec docstrings claires et le caveat downgrade bien noté.
- Découplage
AgentRunner.invoke → (Message, list[TraceEvent])— le use case batch-persiste viaadd_batch, le runner n'a plus aucune dépendance à la persistance. Hexagonal respecté. - Native
response_formatdans la factory — suppression de l'injection destructured_responsetool custom + de l'instructionSTRUCTURED_OUTPUT_INSTRUCTION. Passage en mode natif langchain/deepagents, plus simple et plus compatible avec les providers strict-mode. GetThreadHistoryUseCasetesté sur les edge cases critiques : turn crashed (no AI_MESSAGE), ordering chronologique vs UUID v4, empty thread. C'est exactement le piège duturn_idrandom qui aurait pu silencieusement inverser l'ordre — bien vu.lazy="raise"surThreadModel.trace_events— bloque les N+1 silencieuses. À généraliser aux autres relations du repo.- Index DB bien pensés :
(thread_id, turn_id),(thread_id, type),(thread_id, timestamp)— couvrent les 3 access patterns (list_by_turn,list_messages,list_by_thread). - Tests d'intégration réels (SQLite in-memory) sur
PostgresTraceEventRepositoryau lieu de mocks. Meilleure confiance qu'un test mocké. - Validation HITL déplacée en tête de
SendMessageUseCase.execute→ contract 422 préservé avant tout I/O, évite un round-trip DB inutile. - README mis à jour avec section "Breaking Changes" + format SSE documenté. Rare sur ce repo, appréciable.
🐛 Bugs / comportements à risque
1. Perte de la détection de duplicate human message (changement silencieux)
send_message.py et stream_message.py ont supprimé _is_duplicate_human_message() (le helper de retry-safety). Avant : si le client retry après un crash mid-turn, on évitait d'insérer 2x le HUMAN_MESSAGE. Maintenant : turn_id UUID v4 généré par le use case à chaque appel → un retry produit 2x HUMAN_MESSAGE (avec des turn_id différents) dans la table. Comportement changé silencieusement, à documenter ou compenser.
Suggestion : soit ajouter le check au début (list_messages → si dernier event est HUMAN_MESSAGE avec content == message et pas d'AI_MESSAGE pour le même turn_id, retourner le thread state) ; soit documenter explicitement la nouvelle sémantique (idempotence = responsabilité client).
2. DeepAgentRunner.invoke peut double-invoquer le LLM
Si _collect_trace ne yield aucun AI_MESSAGE (graph mal-comporté, tool error silencieux, etc.), le fallback ré-appelle self._graph.ainvoke() une 2e fois. C'est potentiellement 2 appels LLM payants pour 1 turn. Le commentaire dit "shouldn't happen with a well-behaved graph" mais c'est un piège prod.
Suggestion : lever AgentError(ErrorMessage.AGENT_NO_FINAL_MESSAGES) comme _build_response le fait déjà, plutôt que de re-invoker.
3. HITL ne persiste rien → turn history incomplète
SendMessageUseCase HITL path : "no trace persistence — HITL does not currently emit trace events". Conséquence : un user qui rejette 3 tool calls successifs puis approuve aura dans /history des turns ai_message sans human_message/intermédiaires, et l'ordering chronologique des turns sera trompeur.
Suggestion minimale : persister un AI_MESSAGE minimal dans le HITL path (via add direct) avec un flag metadata={"hitl": true} pour que la history reste cohérente. Ou documenter explicitement le mode dégradé.
4. Downgrade Alembic 006 → 007 perd des données
Déjà documenté dans le code ("losing them on rollback does not affect conversation history"), mais : la perte concerne tool_result + content events. Si un user a une vraie trace d'agent avec tool calls, downgrade = perte d'audit. C'est acceptable mais devrait être dans le revision message affiché par alembic history (pas juste un docstring).
🧪 Tests manquants / couverture
-
schema_utils.py(100 lignes ajoutées) n'a pas de tests directs. Tout passe partest_factory.pyavecWEATHER_SCHEMAsimple. Les casanyOf,enum,type: [a, b, "null"](utilisés massivement danshaiku-files-local-structured.yaml) ne sont pas testés en isolation. Bug latent :Literal[tuple(values)]casse sivaluescontient des non-hashables. -
Pas de test d'intégration Alembic sur la migration 006 (legacy
messages→trace_events). Le mapping role→type, le casai_messageJSON payload, le castool_resultmetadata — tout est non-testé. Le filet de sécurité c'est la lecture du code, c'est insuffisant pour une migration de prod. -
Pas de test du chemin
subgraphs=TruedansDeepAgentRunner._collect_trace. Le code changestream_mode="messages"+subgraphs=Truepour capturer les events des sub-agents, mais aucun test ne vérifie que les eventssource="security-auditor"remontent bien. -
Pas de test sur
_extract_source— la détectionparts.index("task")est hard-codée. Un test unitairens="Agent:task:security-auditor:tools" → "security-auditor"figerait le contrat.
🛠️ Suggestions de qualité
-
router /threads/{id}/traceretournedictnon typé alors que/historyretourne unThreadHistory(BaseModel). Incohérence. CréerTraceListResponse(BaseModel)avecevents: list[TraceEvent]. -
WebSocket termine par
[END], SSE par[DONE]— incohérence cross-channel préservée de l'ancien code. La refacto était l'occasion d'aligner. -
Thread.messages(computed_field) recalcule sort+filter à chaque accès — surthread_repo.getpuis lecture, on refait le travail plusieurs fois.@cached_property(Pydantic 2) ou matérialiser une fois. -
Schema
trace_events.sequenceestInteger NOT NULL DEFAULT 0— sur 2 events insérés dans la même µs (peu probable mais possible),order_by(timestamp, sequence)est non-déterministe. ConsidérerBigSerialcôté DB ou générer lesequencecôté Python avec une garantie d'unicité (ex: microsec + counter). -
add_batchn'est pas streamé — OK jusqu'à ~10k params PG, mais un turn halluciné avec 5000 THINKING chunks dépasserait. À surveiller. -
_apply_optional_kwargsest un wrapper de 4 if qui ne fait que factoriser. Avant c'était 4 if explicites plus lisibles. Trivial mais du over-engineering. -
NS_SEPimporté depuislanggraph._internal._constantsest un import private API. Susceptible de casser à toute upgrade langgraph. Remonter un ticket upstream, ou commenter pourquoi on ne peut pas faire autrement. -
Le
__init__.pydepostgres_trace/est vide (+0 -0). C'est normal pour un namespace package, mais pas explicite — ajouter un commentaire ou un re-export des éléments clés.
📋 Hors-scope mais vu en passant
.DS_Storetrackés dans le repo et supprimés par cette PR — c'est OK mais considérer l'ajout d'un*.DS_Storeglobal au.gitignoreracine pour éviter le prochain.- L'aplatissement
agents/bricks/→agents/est propre, le nouveauagents/README.mdest bien fait. RAS.
🎯 Pour ship
Bloqueurs à mon sens : #1 (duplicate human message) et #5/#7 (manque de tests sur schema_utils et subgraphs).
Nice-to-have avant merge : #2 (double-invoke fallback), #3 (HITL no-trace), #9 (route dict non typé).
Le reste peut être traité en follow-up tickets.
Revue par SoluBot (SoluDevTech) — 2026-07-23 — basée sur la lecture du diff + HEAD 219bd42.
Changes
agents/bricks/toagents/root levelbrickssubfolderhaiku-rag-formation.yaml(minor content change)