diff --git a/hindsight-api/hindsight_api/api/http.py b/hindsight-api/hindsight_api/api/http.py index cc2be5af..eaee5ac1 100644 --- a/hindsight-api/hindsight_api/api/http.py +++ b/hindsight-api/hindsight_api/api/http.py @@ -100,7 +100,12 @@ class ChunkIncludeOptions(BaseModel): class SourceFactsIncludeOptions(BaseModel): """Options for including source facts for observation-type results.""" - max_tokens: int = Field(default=4096, description="Maximum tokens for source facts") + max_tokens: int = Field( + default=4096, description="Maximum total tokens for source facts across all observations (-1 = unlimited)" + ) + max_tokens_per_observation: int = Field( + default=-1, description="Maximum tokens of source facts per observation (-1 = unlimited)" + ) class IncludeOptions(BaseModel): @@ -2224,6 +2229,9 @@ def _register_routes(app: FastAPI): # Determine source facts inclusion settings include_source_facts = request.include.source_facts is not None max_source_facts_tokens = request.include.source_facts.max_tokens if include_source_facts else 4096 + max_source_facts_tokens_per_observation = ( + request.include.source_facts.max_tokens_per_observation if include_source_facts else -1 + ) pre_recall = time.time() - handler_start # Run recall with tracing (record metrics) @@ -2245,6 +2253,7 @@ def _register_routes(app: FastAPI): max_chunk_tokens=max_chunk_tokens, include_source_facts=include_source_facts, max_source_facts_tokens=max_source_facts_tokens, + max_source_facts_tokens_per_observation=max_source_facts_tokens_per_observation, request_context=request_context, tags=request.tags, tags_match=request.tags_match, diff --git a/hindsight-api/hindsight_api/config.py b/hindsight-api/hindsight_api/config.py index 45f4c0f9..2b3c620a 100644 --- a/hindsight-api/hindsight_api/config.py +++ b/hindsight-api/hindsight_api/config.py @@ -292,6 +292,10 @@ ENV_ENABLE_OBSERVATIONS = "HINDSIGHT_API_ENABLE_OBSERVATIONS" ENV_CONSOLIDATION_BATCH_SIZE = "HINDSIGHT_API_CONSOLIDATION_BATCH_SIZE" ENV_CONSOLIDATION_LLM_BATCH_SIZE = "HINDSIGHT_API_CONSOLIDATION_LLM_BATCH_SIZE" ENV_CONSOLIDATION_MAX_TOKENS = "HINDSIGHT_API_CONSOLIDATION_MAX_TOKENS" +ENV_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS = "HINDSIGHT_API_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS" +ENV_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION = ( + "HINDSIGHT_API_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION" +) ENV_OBSERVATIONS_MISSION = "HINDSIGHT_API_OBSERVATIONS_MISSION" # Webhook configuration (global, static - server-level only) @@ -446,6 +450,12 @@ DEFAULT_ENABLE_OBSERVATIONS = True # Observations enabled by default DEFAULT_CONSOLIDATION_BATCH_SIZE = 50 # Memories to load per batch (internal memory optimization) DEFAULT_CONSOLIDATION_LLM_BATCH_SIZE = 8 # Facts per LLM call (1 = no batching; >1 = batch mode) DEFAULT_CONSOLIDATION_MAX_TOKENS = 512 # Max tokens for recall when finding related observations +DEFAULT_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS = ( + -1 +) # Total token budget for source facts in consolidation recall (-1 = unlimited) +DEFAULT_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION = ( + 256 # Max tokens of source facts per observation in consolidation prompt (-1 = unlimited) +) DEFAULT_OBSERVATIONS_MISSION = None # Declarative spec of what observations are for this bank # Database migrations @@ -712,6 +722,8 @@ class HindsightConfig: consolidation_batch_size: int consolidation_llm_batch_size: int consolidation_max_tokens: int + consolidation_source_facts_max_tokens: int + consolidation_source_facts_max_tokens_per_observation: int observations_mission: str | None # Entity labels (controlled vocabulary of key:value classification labels extracted at retain time) @@ -812,6 +824,9 @@ class HindsightConfig: "entities_allow_free_form", # Consolidation settings "enable_observations", + "consolidation_llm_batch_size", + "consolidation_source_facts_max_tokens", + "consolidation_source_facts_max_tokens_per_observation", "observations_mission", # Reflect settings "reflect_mission", @@ -1162,6 +1177,15 @@ class HindsightConfig: consolidation_max_tokens=int( os.getenv(ENV_CONSOLIDATION_MAX_TOKENS, str(DEFAULT_CONSOLIDATION_MAX_TOKENS)) ), + consolidation_source_facts_max_tokens=int( + os.getenv(ENV_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS, str(DEFAULT_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS)) + ), + consolidation_source_facts_max_tokens_per_observation=int( + os.getenv( + ENV_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION, + str(DEFAULT_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION), + ) + ), observations_mission=os.getenv(ENV_OBSERVATIONS_MISSION) or DEFAULT_OBSERVATIONS_MISSION, entity_labels=None, entities_allow_free_form=True, diff --git a/hindsight-api/hindsight_api/engine/consolidation/consolidator.py b/hindsight-api/hindsight_api/engine/consolidation/consolidator.py index 8211e3a1..542131d7 100644 --- a/hindsight-api/hindsight_api/engine/consolidation/consolidator.py +++ b/hindsight-api/hindsight_api/engine/consolidation/consolidator.py @@ -913,10 +913,9 @@ async def _find_related_observations( """ # Use recall to find related observations with token budget # max_tokens naturally limits how many observations are returned - from ...config import get_config from ...tracing import get_tracer, is_tracing_enabled - config = get_config() + config = await memory_engine._config_resolver.resolve_full_config(bank_id, request_context) # SECURITY: Use all_strict matching if tags provided to prevent cross-scope consolidation tags_match = "all_strict" if tags else "any" @@ -941,7 +940,8 @@ async def _find_related_observations( tags=tags, # Filter by source memory's tags tags_match=tags_match, # Use strict matching for security include_source_facts=True, # Embed source facts so we avoid a separate DB fetch - max_source_facts_tokens=-1, # No token limit — we need all source facts for consolidation + max_source_facts_tokens=config.consolidation_source_facts_max_tokens, + max_source_facts_tokens_per_observation=config.consolidation_source_facts_max_tokens_per_observation, _quiet=True, # Suppress logging ) finally: @@ -1005,14 +1005,17 @@ async def _consolidate_batch_with_llm( observations_text = "[]" def _fact_line(m: dict[str, Any]) -> str: - parts = [f"[{m['id']}] {m['text']}"] + text = f"[{m['id']}] {m['text']}" + temporal_parts = [] if m.get("occurred_start"): - parts.append(f"occurred_start={m['occurred_start']}") + temporal_parts.append(f"occurred_start={m['occurred_start']}") if m.get("occurred_end"): - parts.append(f"occurred_end={m['occurred_end']}") + temporal_parts.append(f"occurred_end={m['occurred_end']}") if m.get("mentioned_at"): - parts.append(f"mentioned_at={m['mentioned_at']}") - return " | ".join(parts) + temporal_parts.append(f"mentioned_at={m['mentioned_at']}") + if temporal_parts: + text += f" ({', '.join(temporal_parts)})" + return text facts_lines = "\n".join(_fact_line(m) for m in memories) diff --git a/hindsight-api/hindsight_api/engine/consolidation/prompts.py b/hindsight-api/hindsight_api/engine/consolidation/prompts.py index 58f890c2..67db5c48 100644 --- a/hindsight-api/hindsight_api/engine/consolidation/prompts.py +++ b/hindsight-api/hindsight_api/engine/consolidation/prompts.py @@ -35,8 +35,25 @@ Compare the facts against existing observations: _BATCH_OUTPUT_FORMAT = """ Output a JSON object with three arrays. -Example (showing the required UUID format for all IDs): -{{"creates": [{{"text": "Alice lives in Berlin", "source_fact_ids": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890", "b2c3d4e5-f6a7-8901-bcde-f12345678901"]}}], +## EXAMPLE + +Input facts: +[a1b2c3d4-e5f6-7890-abcd-ef1234567890] Alice mentioned she works long hours, often past midnight | Involving: Alice (occurred_start=2024-01-15, mentioned_at=2024-01-15) +[b2c3d4e5-f6a7-8901-bcde-f12345678901] Alice said she's exhausted from the project deadlines | Involving: Alice (occurred_start=2024-01-20, mentioned_at=2024-01-20) + +Good observation text — clean prose, no metadata, each fact tracked distinctly: + "Alice works long hours, often past midnight." + "Alice feels exhausted from project deadlines." + +Bad observation text — NEVER do this (verbatim copy of fact text with metadata): + "Alice mentioned she works long hours, often past midnight | Involving: Alice (occurred_start=2024-01-15, mentioned_at=2024-01-15)" + +Observation text rules: +- Write clean prose — NEVER copy raw fact lines or their metadata (temporal fields, "Involving:", "When:" labels, UUIDs). +- Parenthesized metadata like (occurred_start=...) and pipe-separated labels like "| Involving: ..." are fact formatting — strip them entirely from observation text. +- How many observations to create and how much to aggregate is driven by the MISSION above. + +{{"creates": [{{"text": "Alice works long hours, often past midnight.", "source_fact_ids": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890"]}}, {{"text": "Alice feels exhausted from project deadlines.", "source_fact_ids": ["b2c3d4e5-f6a7-8901-bcde-f12345678901"]}}], "updates": [{{"text": "Alice works at Acme Corp as a senior engineer", "observation_id": "c3d4e5f6-a7b8-9012-cdef-123456789012", "source_fact_ids": ["d4e5f6a7-b8c9-0123-defa-234567890123"]}}], "deletes": [{{"observation_id": "e5f6a7b8-c9d0-1234-efab-345678901234"}}]}} diff --git a/hindsight-api/hindsight_api/engine/memory_engine.py b/hindsight-api/hindsight_api/engine/memory_engine.py index e91189df..c412c746 100644 --- a/hindsight-api/hindsight_api/engine/memory_engine.py +++ b/hindsight-api/hindsight_api/engine/memory_engine.py @@ -2237,6 +2237,7 @@ class MemoryEngine(MemoryEngineInterface): max_chunk_tokens: int = 8192, include_source_facts: bool = False, max_source_facts_tokens: int = 4096, + max_source_facts_tokens_per_observation: int = -1, request_context: "RequestContext", tags: list[str] | None = None, tags_match: TagsMatch = "any", @@ -2378,6 +2379,7 @@ class MemoryEngine(MemoryEngineInterface): quiet=_quiet, include_source_facts=include_source_facts, max_source_facts_tokens=max_source_facts_tokens, + max_source_facts_tokens_per_observation=max_source_facts_tokens_per_observation, ) break # Success - exit retry loop except Exception as e: @@ -2504,6 +2506,7 @@ class MemoryEngine(MemoryEngineInterface): quiet: bool = False, include_source_facts: bool = False, max_source_facts_tokens: int = 4096, + max_source_facts_tokens_per_observation: int = -1, ) -> RecallResultModel: """ Search implementation with modular retrieval and reranking. @@ -3125,18 +3128,9 @@ class MemoryEngine(MemoryEngineInterface): encoding = _get_tiktoken_encoding() source_facts_dict = {} - total_source_tokens = 0 - for sid in source_ids_ordered: - if sid not in source_row_by_id: - continue - r = source_row_by_id[sid] - fact_tokens = len(encoding.encode(r["text"])) - if ( - max_source_facts_tokens >= 0 - and total_source_tokens + fact_tokens > max_source_facts_tokens - ): - break - source_facts_dict[sid] = MemoryFact( + + def _make_source_fact(sid: str, r: Any) -> MemoryFact: + return MemoryFact( id=sid, text=r["text"], fact_type=r["fact_type"], @@ -3148,7 +3142,37 @@ class MemoryEngine(MemoryEngineInterface): chunk_id=str(r["chunk_id"]) if r["chunk_id"] else None, tags=r["tags"] or None, ) - total_source_tokens += fact_tokens + + if max_source_facts_tokens_per_observation >= 0: + # Per-observation capping: each observation independently selects + # source facts up to its token budget. + for obs_id, sids in source_fact_ids_by_obs.items(): + obs_tokens = 0 + for sid in sids: + if sid not in source_row_by_id: + continue + r = source_row_by_id[sid] + fact_tokens = len(encoding.encode(r["text"])) + if obs_tokens + fact_tokens > max_source_facts_tokens_per_observation: + break + obs_tokens += fact_tokens + if sid not in source_facts_dict: + source_facts_dict[sid] = _make_source_fact(sid, r) + else: + # Global budget: fill in order of first appearance until exhausted. + total_source_tokens = 0 + for sid in source_ids_ordered: + if sid not in source_row_by_id: + continue + r = source_row_by_id[sid] + fact_tokens = len(encoding.encode(r["text"])) + if ( + max_source_facts_tokens >= 0 + and total_source_tokens + fact_tokens > max_source_facts_tokens + ): + break + source_facts_dict[sid] = _make_source_fact(sid, r) + total_source_tokens += fact_tokens # Get entities for each fact if include_entities is requested fact_entity_map = {} # unit_id -> list of (entity_id, entity_name) diff --git a/hindsight-api/hindsight_api/main.py b/hindsight-api/hindsight_api/main.py index cf18897f..48c35ad0 100644 --- a/hindsight-api/hindsight_api/main.py +++ b/hindsight-api/hindsight_api/main.py @@ -278,6 +278,8 @@ def main(): consolidation_batch_size=config.consolidation_batch_size, consolidation_llm_batch_size=config.consolidation_llm_batch_size, consolidation_max_tokens=config.consolidation_max_tokens, + consolidation_source_facts_max_tokens=config.consolidation_source_facts_max_tokens, + consolidation_source_facts_max_tokens_per_observation=config.consolidation_source_facts_max_tokens_per_observation, observations_mission=config.observations_mission, entity_labels=config.entity_labels, entities_allow_free_form=config.entities_allow_free_form, diff --git a/hindsight-api/tests/test_consolidation.py b/hindsight-api/tests/test_consolidation.py index bdc94ed9..dff55e34 100644 --- a/hindsight-api/tests/test_consolidation.py +++ b/hindsight-api/tests/test_consolidation.py @@ -6,12 +6,14 @@ Note: Consolidation runs automatically after retain via SyncTaskBackend in tests import uuid from datetime import datetime, timezone -from unittest.mock import patch +from unittest.mock import AsyncMock, call, patch import pytest +from hindsight_api.config import _get_raw_config from hindsight_api.engine.consolidation.consolidator import ( _aggregate_source_fields, + _find_related_observations, run_consolidation_job, ) from hindsight_api.engine.memory_engine import MemoryEngine @@ -2418,3 +2420,81 @@ class TestAggregateSourceFields: assert agg.occurred_end == d assert agg.mentioned_at == d assert agg.tags == ["x"] + + +class TestConsolidationSourceFactsConfig: + """Tests that consolidation uses the source_facts token config when calling recall.""" + + @pytest.fixture(autouse=True) + def enable_observations(self): + config = _get_raw_config() + original = config.enable_observations + config.enable_observations = True + yield + config.enable_observations = original + + @pytest.mark.asyncio + async def test_consolidation_passes_source_facts_max_tokens_to_recall( + self, memory: MemoryEngine, request_context + ): + """consolidation_source_facts_max_tokens from config is forwarded to recall_async.""" + bank_id = f"test-sf-config-total-{uuid.uuid4().hex[:8]}" + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + raw = _get_raw_config() + fake_config = type(raw)(**{ + **{f: getattr(raw, f) for f in raw.__dataclass_fields__}, + "consolidation_source_facts_max_tokens": 999, + "consolidation_source_facts_max_tokens_per_observation": -1, + }) + + try: + with ( + patch.object(memory._config_resolver, "resolve_full_config", return_value=fake_config), + patch.object(memory, "recall_async", wraps=memory.recall_async) as mock_recall, + ): + await _find_related_observations( + memory_engine=memory, + bank_id=bank_id, + query="test query", + request_context=request_context, + ) + assert mock_recall.called + _, kwargs = mock_recall.call_args + assert kwargs.get("max_source_facts_tokens") == 999 + assert kwargs.get("max_source_facts_tokens_per_observation") == -1 + finally: + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_consolidation_passes_source_facts_per_obs_tokens_to_recall( + self, memory: MemoryEngine, request_context + ): + """consolidation_source_facts_max_tokens_per_observation from config is forwarded to recall_async.""" + bank_id = f"test-sf-config-per-obs-{uuid.uuid4().hex[:8]}" + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + raw = _get_raw_config() + fake_config = type(raw)(**{ + **{f: getattr(raw, f) for f in raw.__dataclass_fields__}, + "consolidation_source_facts_max_tokens": -1, + "consolidation_source_facts_max_tokens_per_observation": 128, + }) + + try: + with ( + patch.object(memory._config_resolver, "resolve_full_config", return_value=fake_config), + patch.object(memory, "recall_async", wraps=memory.recall_async) as mock_recall, + ): + await _find_related_observations( + memory_engine=memory, + bank_id=bank_id, + query="test query", + request_context=request_context, + ) + assert mock_recall.called + _, kwargs = mock_recall.call_args + assert kwargs.get("max_source_facts_tokens") == -1 + assert kwargs.get("max_source_facts_tokens_per_observation") == 128 + finally: + await memory.delete_bank(bank_id, request_context=request_context) diff --git a/hindsight-api/tests/test_hierarchical_config.py b/hindsight-api/tests/test_hierarchical_config.py index 126909b2..f3c67d32 100644 --- a/hindsight-api/tests/test_hierarchical_config.py +++ b/hindsight-api/tests/test_hierarchical_config.py @@ -75,6 +75,9 @@ async def test_hierarchical_fields_categorization(): assert "retain_custom_instructions" in configurable assert "retain_chunk_size" in configurable assert "enable_observations" in configurable + assert "consolidation_llm_batch_size" in configurable + assert "consolidation_source_facts_max_tokens" in configurable + assert "consolidation_source_facts_max_tokens_per_observation" in configurable assert "observations_mission" in configurable assert "reflect_mission" in configurable assert "disposition_skepticism" in configurable @@ -86,7 +89,7 @@ async def test_hierarchical_fields_categorization(): assert "entity_labels" in configurable # Verify count is correct - assert len(configurable) == 14 + assert len(configurable) == 17 # Verify credential fields (NEVER exposed) assert "llm_api_key" in credentials diff --git a/hindsight-api/tests/test_source_facts_tokens.py b/hindsight-api/tests/test_source_facts_tokens.py new file mode 100644 index 00000000..cfc84e93 --- /dev/null +++ b/hindsight-api/tests/test_source_facts_tokens.py @@ -0,0 +1,170 @@ +"""Tests for source_facts token limiting in recall. + +Covers: +- max_source_facts_tokens: total token budget across all source facts +- max_source_facts_tokens_per_observation: per-observation cap + +Both parameters are tested at the recall_async level and verified to produce +fewer source facts when the budget is tight vs. unlimited. +""" + +import pytest + +from hindsight_api.config import _get_raw_config +from hindsight_api.engine.memory_engine import Budget + + +@pytest.fixture(autouse=True) +def enable_observations(): + config = _get_raw_config() + original = config.enable_observations + config.enable_observations = True + yield + config.enable_observations = original + + +async def _setup_bank_with_observations(memory, bank_id, request_context): + """Retain several memories and trigger consolidation to produce observations with source facts.""" + contents = [ + "Alice is a software engineer who loves Python programming.", + "Alice has been working at TechCorp for 5 years.", + "Alice recently completed a machine learning certification course.", + "Alice mentors junior developers on the team.", + "Alice prefers functional programming patterns in her code.", + ] + for content in contents: + await memory.retain_async( + bank_id=bank_id, + content=content, + request_context=request_context, + ) + await memory.run_consolidation(bank_id=bank_id, request_context=request_context) + + +class TestRecallSourceFactsPerObservationCap: + @pytest.mark.asyncio + async def test_per_observation_cap_reduces_source_facts(self, memory, request_context): + """A tight per-observation token cap should return fewer source facts than unlimited.""" + bank_id = "test-sf-per-obs-cap" + try: + await _setup_bank_with_observations(memory, bank_id, request_context) + + result_limited = await memory.recall_async( + bank_id=bank_id, + query="Alice engineer", + fact_type=["observation"], + max_tokens=4096, + include_source_facts=True, + max_source_facts_tokens_per_observation=1, # Effectively cuts all source facts + budget=Budget.MID, + request_context=request_context, + ) + + result_unlimited = await memory.recall_async( + bank_id=bank_id, + query="Alice engineer", + fact_type=["observation"], + max_tokens=4096, + include_source_facts=True, + max_source_facts_tokens_per_observation=-1, + budget=Budget.MID, + request_context=request_context, + ) + + unlimited_count = len(result_unlimited.source_facts) if result_unlimited.source_facts else 0 + limited_count = len(result_limited.source_facts) if result_limited.source_facts else 0 + + if unlimited_count > 0: + assert limited_count <= unlimited_count, ( + f"Per-observation cap should yield fewer source facts ({limited_count} <= {unlimited_count})" + ) + finally: + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_per_observation_cap_does_not_mix_between_observations(self, memory, request_context): + """Each observation's source facts are capped independently — not as a shared pool.""" + bank_id = "test-sf-per-obs-independent" + try: + await _setup_bank_with_observations(memory, bank_id, request_context) + + # With a generous per-observation limit each observation can have facts; + # with a global limit of 1 token the first observation would consume the whole budget. + result_per_obs = await memory.recall_async( + bank_id=bank_id, + query="Alice engineer", + fact_type=["observation"], + max_tokens=4096, + include_source_facts=True, + max_source_facts_tokens=4096, # large global budget + max_source_facts_tokens_per_observation=512, # reasonable per-obs limit + budget=Budget.MID, + request_context=request_context, + ) + + # Should not raise; source_facts may be populated for multiple observations + assert result_per_obs.source_facts is not None or len(result_per_obs.results) == 0 + finally: + await memory.delete_bank(bank_id, request_context=request_context) + + +class TestRecallSourceFactsTotalBudget: + @pytest.mark.asyncio + async def test_total_budget_limits_source_facts(self, memory, request_context): + """A tight total token budget should return fewer source facts than unlimited.""" + bank_id = "test-sf-total-budget" + try: + await _setup_bank_with_observations(memory, bank_id, request_context) + + result_tight = await memory.recall_async( + bank_id=bank_id, + query="Alice engineer", + fact_type=["observation"], + max_tokens=4096, + include_source_facts=True, + max_source_facts_tokens=1, # Effectively cuts all source facts + budget=Budget.MID, + request_context=request_context, + ) + + result_unlimited = await memory.recall_async( + bank_id=bank_id, + query="Alice engineer", + fact_type=["observation"], + max_tokens=4096, + include_source_facts=True, + max_source_facts_tokens=-1, + budget=Budget.MID, + request_context=request_context, + ) + + unlimited_count = len(result_unlimited.source_facts) if result_unlimited.source_facts else 0 + tight_count = len(result_tight.source_facts) if result_tight.source_facts else 0 + + if unlimited_count > 0: + assert tight_count <= unlimited_count, ( + f"Total budget should yield fewer source facts ({tight_count} <= {unlimited_count})" + ) + finally: + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_no_source_facts_without_flag(self, memory, request_context): + """source_facts should be None when include_source_facts is not set.""" + bank_id = "test-sf-no-flag" + try: + await _setup_bank_with_observations(memory, bank_id, request_context) + + result = await memory.recall_async( + bank_id=bank_id, + query="Alice engineer", + fact_type=["observation"], + max_tokens=4096, + include_source_facts=False, # default + budget=Budget.MID, + request_context=request_context, + ) + + assert result.source_facts is None or len(result.source_facts) == 0 + finally: + await memory.delete_bank(bank_id, request_context=request_context) diff --git a/hindsight-clients/go/api/openapi.yaml b/hindsight-clients/go/api/openapi.yaml index 223f6cec..54f38580 100644 --- a/hindsight-clients/go/api/openapi.yaml +++ b/hindsight-clients/go/api/openapi.yaml @@ -4591,9 +4591,15 @@ components: properties: max_tokens: default: 4096 - description: Maximum tokens for source facts + description: Maximum total tokens for source facts across all observations + (-1 = unlimited) title: Max Tokens type: integer + max_tokens_per_observation: + default: -1 + description: Maximum tokens of source facts per observation (-1 = unlimited) + title: Max Tokens Per Observation + type: integer title: SourceFactsIncludeOptions TagItem: description: Single tag with usage count. diff --git a/hindsight-clients/go/model_source_facts_include_options.go b/hindsight-clients/go/model_source_facts_include_options.go index b6086cc1..8a8fbe95 100644 --- a/hindsight-clients/go/model_source_facts_include_options.go +++ b/hindsight-clients/go/model_source_facts_include_options.go @@ -19,8 +19,10 @@ var _ MappedNullable = &SourceFactsIncludeOptions{} // SourceFactsIncludeOptions Options for including source facts for observation-type results. type SourceFactsIncludeOptions struct { - // Maximum tokens for source facts + // Maximum total tokens for source facts across all observations (-1 = unlimited) MaxTokens *int32 `json:"max_tokens,omitempty"` + // Maximum tokens of source facts per observation (-1 = unlimited) + MaxTokensPerObservation *int32 `json:"max_tokens_per_observation,omitempty"` } // NewSourceFactsIncludeOptions instantiates a new SourceFactsIncludeOptions object @@ -31,6 +33,8 @@ func NewSourceFactsIncludeOptions() *SourceFactsIncludeOptions { this := SourceFactsIncludeOptions{} var maxTokens int32 = 4096 this.MaxTokens = &maxTokens + var maxTokensPerObservation int32 = -1 + this.MaxTokensPerObservation = &maxTokensPerObservation return &this } @@ -41,6 +45,8 @@ func NewSourceFactsIncludeOptionsWithDefaults() *SourceFactsIncludeOptions { this := SourceFactsIncludeOptions{} var maxTokens int32 = 4096 this.MaxTokens = &maxTokens + var maxTokensPerObservation int32 = -1 + this.MaxTokensPerObservation = &maxTokensPerObservation return &this } @@ -76,6 +82,38 @@ func (o *SourceFactsIncludeOptions) SetMaxTokens(v int32) { o.MaxTokens = &v } +// GetMaxTokensPerObservation returns the MaxTokensPerObservation field value if set, zero value otherwise. +func (o *SourceFactsIncludeOptions) GetMaxTokensPerObservation() int32 { + if o == nil || IsNil(o.MaxTokensPerObservation) { + var ret int32 + return ret + } + return *o.MaxTokensPerObservation +} + +// GetMaxTokensPerObservationOk returns a tuple with the MaxTokensPerObservation field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *SourceFactsIncludeOptions) GetMaxTokensPerObservationOk() (*int32, bool) { + if o == nil || IsNil(o.MaxTokensPerObservation) { + return nil, false + } + return o.MaxTokensPerObservation, true +} + +// HasMaxTokensPerObservation returns a boolean if a field has been set. +func (o *SourceFactsIncludeOptions) HasMaxTokensPerObservation() bool { + if o != nil && !IsNil(o.MaxTokensPerObservation) { + return true + } + + return false +} + +// SetMaxTokensPerObservation gets a reference to the given int32 and assigns it to the MaxTokensPerObservation field. +func (o *SourceFactsIncludeOptions) SetMaxTokensPerObservation(v int32) { + o.MaxTokensPerObservation = &v +} + func (o SourceFactsIncludeOptions) MarshalJSON() ([]byte, error) { toSerialize,err := o.ToMap() if err != nil { @@ -89,6 +127,9 @@ func (o SourceFactsIncludeOptions) ToMap() (map[string]interface{}, error) { if !IsNil(o.MaxTokens) { toSerialize["max_tokens"] = o.MaxTokens } + if !IsNil(o.MaxTokensPerObservation) { + toSerialize["max_tokens_per_observation"] = o.MaxTokensPerObservation + } return toSerialize, nil } diff --git a/hindsight-clients/python/hindsight_client_api/models/source_facts_include_options.py b/hindsight-clients/python/hindsight_client_api/models/source_facts_include_options.py index 0199887c..cf91189c 100644 --- a/hindsight-clients/python/hindsight_client_api/models/source_facts_include_options.py +++ b/hindsight-clients/python/hindsight_client_api/models/source_facts_include_options.py @@ -26,8 +26,9 @@ class SourceFactsIncludeOptions(BaseModel): """ Options for including source facts for observation-type results. """ # noqa: E501 - max_tokens: Optional[StrictInt] = Field(default=4096, description="Maximum tokens for source facts") - __properties: ClassVar[List[str]] = ["max_tokens"] + max_tokens: Optional[StrictInt] = Field(default=4096, description="Maximum total tokens for source facts across all observations (-1 = unlimited)") + max_tokens_per_observation: Optional[StrictInt] = Field(default=-1, description="Maximum tokens of source facts per observation (-1 = unlimited)") + __properties: ClassVar[List[str]] = ["max_tokens", "max_tokens_per_observation"] model_config = ConfigDict( populate_by_name=True, @@ -80,7 +81,8 @@ class SourceFactsIncludeOptions(BaseModel): return cls.model_validate(obj) _obj = cls.model_validate({ - "max_tokens": obj.get("max_tokens") if obj.get("max_tokens") is not None else 4096 + "max_tokens": obj.get("max_tokens") if obj.get("max_tokens") is not None else 4096, + "max_tokens_per_observation": obj.get("max_tokens_per_observation") if obj.get("max_tokens_per_observation") is not None else -1 }) return _obj diff --git a/hindsight-clients/typescript/generated/types.gen.ts b/hindsight-clients/typescript/generated/types.gen.ts index 4d066e78..feb5b54a 100644 --- a/hindsight-clients/typescript/generated/types.gen.ts +++ b/hindsight-clients/typescript/generated/types.gen.ts @@ -1960,9 +1960,15 @@ export type SourceFactsIncludeOptions = { /** * Max Tokens * - * Maximum tokens for source facts + * Maximum total tokens for source facts across all observations (-1 = unlimited) */ max_tokens?: number; + /** + * Max Tokens Per Observation + * + * Maximum tokens of source facts per observation (-1 = unlimited) + */ + max_tokens_per_observation?: number; }; /** diff --git a/hindsight-control-plane/src/components/bank-config-view.tsx b/hindsight-control-plane/src/components/bank-config-view.tsx index bcda2eab..ba1c63c7 100644 --- a/hindsight-control-plane/src/components/bank-config-view.tsx +++ b/hindsight-control-plane/src/components/bank-config-view.tsx @@ -39,6 +39,9 @@ type RetainEdits = { type ObservationsEdits = { enable_observations: boolean | null; + consolidation_llm_batch_size: number | null; + consolidation_source_facts_max_tokens: number | null; + consolidation_source_facts_max_tokens_per_observation: number | null; observations_mission: string | null; }; @@ -143,6 +146,10 @@ function retainSlice(config: Record): RetainEdits { function observationsSlice(config: Record): ObservationsEdits { return { enable_observations: config.enable_observations ?? null, + consolidation_llm_batch_size: config.consolidation_llm_batch_size ?? null, + consolidation_source_facts_max_tokens: config.consolidation_source_facts_max_tokens ?? null, + consolidation_source_facts_max_tokens_per_observation: + config.consolidation_source_facts_max_tokens_per_observation ?? null, observations_mission: config.observations_mission ?? null, }; } @@ -489,7 +496,7 @@ export function BankConfigView() { >