fix(engine): classify first-person agent experiences as 'experience' fact type (#775)

* fix(engine): classify first-person agent experiences as 'experience' fact type

The extraction prompt defined "assistant" too narrowly as only "interactions
with assistant (requests, recommendations)", causing the LLM to classify
first-person agent actions (code changes, debugging, discoveries) as "world".

Broadened the fact_type definition in the prompt and Pydantic model descriptions
to cover all first-person actions, experiences, and observations by the speaker.

* style: fix line length in fact_extraction.py
This commit is contained in:
Nicolò Boschi 2026-03-31 09:06:36 +02:00 committed by GitHub
parent b104bad02c
commit 0096115678
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 140 additions and 5 deletions

View file

@ -159,7 +159,9 @@ class ExtractedFact(BaseModel):
fact_kind: str = Field(default="conversation", description="'event' or 'conversation'") fact_kind: str = Field(default="conversation", description="'event' or 'conversation'")
occurred_start: str | None = Field(default=None, description="ISO timestamp for events") occurred_start: str | None = Field(default=None, description="ISO timestamp for events")
occurred_end: str | None = Field(default=None, description="ISO timestamp for event end") occurred_end: str | None = Field(default=None, description="ISO timestamp for event end")
fact_type: Literal["world", "assistant"] = Field(description="'world' or 'assistant'") fact_type: Literal["world", "assistant"] = Field(
description="'world' = objective/external facts. 'assistant' = first-person actions, experiences, or observations by the speaker."
)
entities: list[Entity] | None = Field(default=None, description="People, places, concepts") entities: list[Entity] | None = Field(default=None, description="People, places, concepts")
causal_relations: list[FactCausalRelation] | None = Field( causal_relations: list[FactCausalRelation] | None = Field(
default=None, description="Links to previous facts (target_index < this fact's index)" default=None, description="Links to previous facts (target_index < this fact's index)"
@ -261,7 +263,7 @@ class ExtractedFactVerbose(BaseModel):
) )
fact_type: Literal["world", "assistant"] = Field( fact_type: Literal["world", "assistant"] = Field(
description="'world' = about the user/others (background, experiences). 'assistant' = experience with the assistant." description="'world' = objective/external facts about other people, events, general knowledge. 'assistant' = first-person actions, experiences, or observations by the speaker (e.g., 'I changed X', 'I discovered Y')."
) )
entities: list[Entity] | None = Field( entities: list[Entity] | None = Field(
@ -352,7 +354,9 @@ class VerbatimExtractedFact(BaseModel):
fact_kind: str = Field(default="conversation", description="'event' or 'conversation'") fact_kind: str = Field(default="conversation", description="'event' or 'conversation'")
occurred_start: str | None = Field(default=None, description="ISO timestamp for events") occurred_start: str | None = Field(default=None, description="ISO timestamp for events")
occurred_end: str | None = Field(default=None, description="ISO timestamp for event end") occurred_end: str | None = Field(default=None, description="ISO timestamp for event end")
fact_type: Literal["world", "assistant"] = Field(description="'world' or 'assistant'") fact_type: Literal["world", "assistant"] = Field(
description="'world' = objective/external facts. 'assistant' = first-person actions, experiences, or observations by the speaker."
)
entities: list[Entity] | None = Field(default=None, description="People, places, concepts") entities: list[Entity] | None = Field(default=None, description="People, places, concepts")
@field_validator("entities", mode="before") @field_validator("entities", mode="before")
@ -499,8 +503,8 @@ fact_kind:
- "conversation": Ongoing state, preference, trait (no dates) - "conversation": Ongoing state, preference, trait (no dates)
fact_type: fact_type:
- "world": About user's life, other people, external events - "world": About other people, external events, general knowledge, objective facts
- "assistant": Interactions with assistant (requests, recommendations) - "assistant": First-person actions, experiences, or observations by the speaker/author (e.g., "I changed X", "I discovered Y", "I debugged Z"). Also includes interactions with the user (requests, recommendations). If the narrator describes something they did, tried, learned, or decided use "assistant".
TEMPORAL HANDLING TEMPORAL HANDLING

View file

@ -0,0 +1,131 @@
"""
Test that first-person agent experiences are classified as 'experience' fact_type,
not 'world'. This is critical for AI agent systems that store their own operational
experiences (debugging, code changes, user interactions) separately from world knowledge.
"""
from datetime import datetime
import pytest
from hindsight_api import LLMConfig
from hindsight_api.config import _get_raw_config
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_text
class TestAgentExperienceClassification:
"""Tests that first-person coding agent experiences get classified as 'experience'."""
@pytest.mark.asyncio
async def test_code_changes_classified_as_experience(self):
"""First-person code change descriptions should be experience, not world."""
text = """
I changed the return type of the `process_request` function from `dict` to `ResponseModel`.
After that, I updated the three callers in `api/handlers.py` to destructure the new model fields.
The type checker was happy after the change but I noticed one test was still using the old dict keys.
"""
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text,
event_date=datetime(2025, 3, 28),
llm_config=llm_config,
agent_name="coding-agent",
context="agent work log",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
world_facts = [f for f in facts if f.fact_type == "world"]
experience_facts = [f for f in facts if f.fact_type == "experience"]
assert len(experience_facts) > len(world_facts), (
f"First-person code changes should be mostly 'experience', "
f"got {len(experience_facts)} experience vs {len(world_facts)} world. "
f"Facts: {[(f.fact, f.fact_type) for f in facts]}"
)
@pytest.mark.asyncio
async def test_debugging_session_classified_as_experience(self):
"""First-person debugging narrative should be experience, not world."""
text = """
The tests were failing with a ConnectionRefusedError on the Redis integration suite.
I traced it to the connection pool not being initialized before the first test ran.
I added a setup fixture that ensures the pool is warmed up, and all 47 tests pass now.
"""
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text,
event_date=datetime(2025, 3, 28),
llm_config=llm_config,
agent_name="coding-agent",
context="agent work log",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
world_facts = [f for f in facts if f.fact_type == "world"]
experience_facts = [f for f in facts if f.fact_type == "experience"]
assert len(experience_facts) > len(world_facts), (
f"First-person debugging should be mostly 'experience', "
f"got {len(experience_facts)} experience vs {len(world_facts)} world. "
f"Facts: {[(f.fact, f.fact_type) for f in facts]}"
)
@pytest.mark.asyncio
async def test_user_interaction_classified_as_experience(self):
"""Agent describing interactions with the user should be experience."""
text = """
The user asked me to refactor the authentication middleware to support JWT tokens.
I proposed splitting it into two modules: token_validation.py and session_management.py.
The user approved my approach and I started with the token validation logic.
I discovered that the existing tests were mocking the wrong interface, so I had to rewrite them first.
"""
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text,
event_date=datetime(2025, 3, 28),
llm_config=llm_config,
agent_name="coding-agent",
context="agent work log",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
world_facts = [f for f in facts if f.fact_type == "world"]
experience_facts = [f for f in facts if f.fact_type == "experience"]
assert len(experience_facts) > len(world_facts), (
f"Agent-user interactions should be mostly 'experience', "
f"got {len(experience_facts)} experience vs {len(world_facts)} world. "
f"Facts: {[(f.fact, f.fact_type) for f in facts]}"
)
@pytest.mark.asyncio
async def test_mixed_agent_and_world_facts(self):
"""Mix of agent experiences and world knowledge should be classified correctly."""
text = """
Python 3.12 introduced a new type parameter syntax for generic classes.
I migrated our codebase from the old TypeVar approach to the new syntax.
The migration touched 23 files but was mostly mechanical.
PEP 695 defines the new type statement that makes generics more readable.
"""
llm_config = LLMConfig.for_memory()
facts, _, _ = await extract_facts_from_text(
text=text,
event_date=datetime(2025, 3, 28),
llm_config=llm_config,
agent_name="coding-agent",
context="agent work log",
config=_get_raw_config(),
)
assert len(facts) > 0, "Should extract at least one fact"
world_facts = [f for f in facts if f.fact_type == "world"]
experience_facts = [f for f in facts if f.fact_type == "experience"]
# Should have both types - world facts about Python 3.12/PEP 695,
# experience facts about the migration work
assert len(world_facts) >= 1, (
f"Should have at least 1 world fact about Python 3.12/PEP 695. "
f"Facts: {[(f.fact, f.fact_type) for f in facts]}"
)
assert len(experience_facts) >= 1, (
f"Should have at least 1 experience fact about the migration. "
f"Facts: {[(f.fact, f.fact_type) for f in facts]}"
)