fix(lineage): keep tool children separate across readers
This commit is contained in:
1 parent
a41bfe8690
commit
f3545da572
3 files changed
+76
No files matched your search
@@ -278,6 +278,26 @@ visibility stage decides whether recovered background rows are shown. In
|
||||
are merged; cross-profile scoping, visibility, deduplication, and final route
|
||||
limits remain downstream responsibilities.
|
||||
|
||||
#### Compression lineage and session-list invalidation
|
||||
|
||||
`api.agent_sessions._is_continuation_session()` is the shared classifier for
|
||||
sidebar projection, lineage metadata/reporting, and `state.db` transcript
|
||||
stitching. It uses the direct parent link, no conflicting non-empty source,
|
||||
`compression` or `cli_close` parent end reason, and the existing two-second
|
||||
`started_at` overlap allowance. A `source="tool"` child is always a separate
|
||||
conversation, even when its parent is also a tool session or has no source.
|
||||
A direct `_branched_from`, `_delegate_from`, or `_reset_from` marker in the
|
||||
child's `model_config` also makes a boundary; inherited ancestor markers do
|
||||
not. Malformed or unverifiable marker evidence fails closed as a boundary.
|
||||
The overlap allowance is the existing master policy, not a new window set by
|
||||
this change.
|
||||
|
||||
The gateway watcher's cheap database fingerprint includes `model_config`, so
|
||||
a marker-only update causes a fresh projection. Its published-payload hash
|
||||
covers every emitted session field, not just ID, activity time and message
|
||||
count; a changed projected title or lineage field can therefore emit
|
||||
`sessions_changed` even without message-row churn.
|
||||
|
||||
### 4.3 SSE Streaming Engine
|
||||
|
||||
This is the most architecturally interesting part. Two endpoints cooperate:
|
||||
|
||||
@@ -462,6 +462,10 @@ def _is_continuation_session(parent: dict | None, child: dict | None) -> bool:
|
||||
return False
|
||||
parent_source = str(parent.get('source') or '').strip().lower()
|
||||
child_source = str(child.get('source') or '').strip().lower()
|
||||
# Agent lineage excludes tool children even when the parent has the same
|
||||
# source (or no source); a timestamp overlap cannot override that boundary.
|
||||
if child_source == 'tool':
|
||||
return False
|
||||
if parent_source and child_source and parent_source != child_source:
|
||||
return False
|
||||
if parent.get('end_reason') not in {'compression', 'cli_close'}:
|
||||
|
||||
@@ -981,6 +981,58 @@ def test_continuation_classification_timestamp_tolerance_and_guards():
|
||||
assert not _is_continuation_session(make_parent(), make_child(started_at='not-a-number'))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("parent_source", ["tool", ""])
|
||||
def test_tool_child_is_not_stitched_into_parent_lineage(_isolate, parent_source):
|
||||
"""A marker-less tool child stays independent even with a matching/empty source."""
|
||||
from api.agent_sessions import (
|
||||
read_importable_agent_session_rows,
|
||||
read_session_lineage_metadata,
|
||||
read_session_lineage_report,
|
||||
)
|
||||
|
||||
conn = _ensure_state_db(_isolate)
|
||||
_ensure_messages_table(conn)
|
||||
t0 = time.time() - 100
|
||||
parent_id, child_id = "tool_boundary_parent", "tool_boundary_child"
|
||||
try:
|
||||
_insert_state_row(
|
||||
conn, parent_id, source=parent_source, title="Parent conversation",
|
||||
started_at=t0, ended_at=t0 + 5, end_reason="compression",
|
||||
)
|
||||
_insert_state_row(
|
||||
conn, child_id, source="tool", title="Tool conversation",
|
||||
parent=parent_id, started_at=t0 + 4.5,
|
||||
)
|
||||
_insert_state_message(
|
||||
conn, parent_id, role="user", content="parent request", timestamp=t0 + 1,
|
||||
)
|
||||
_insert_state_message(
|
||||
conn, child_id, role="user", content="tool request", timestamp=t0 + 6,
|
||||
)
|
||||
|
||||
projected = read_importable_agent_session_rows(
|
||||
_isolate, limit=None, exclude_sources=None,
|
||||
)
|
||||
rows = {row["id"]: row for row in projected}
|
||||
assert set(rows) == {parent_id, child_id}
|
||||
assert rows[child_id]["relationship_type"] == "child_session"
|
||||
assert "_lineage_root_id" not in rows[child_id]
|
||||
assert "_lineage_tip_id" not in rows[parent_id]
|
||||
|
||||
metadata = read_session_lineage_metadata(_isolate, {parent_id, child_id})
|
||||
assert metadata[child_id]["relationship_type"] == "child_session"
|
||||
assert "_lineage_root_id" not in metadata[child_id]
|
||||
assert read_session_lineage_report(_isolate, parent_id)["total_segments"] == 1
|
||||
assert read_session_lineage_report(_isolate, child_id)["total_segments"] == 1
|
||||
|
||||
messages = models.get_state_db_session_messages(
|
||||
child_id, stitch_continuations=True,
|
||||
)
|
||||
assert [message["content"] for message in messages] == ["tool request"]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_state_db_stitch_keeps_branched_child_out_of_parent_transcript(_isolate):
|
||||
"""#7021 r2: the open/import transcript stitcher (get_state_db_session_messages
|
||||
with stitch_continuations=True) must apply the same model_config branch-marker
|
||||
|
||||
Reference in new issue
Block a user