eigent/backend/app/memory/context_builder.py
Tong Chen cf3cb8cbcd
feat: Space layer above Project + RunContext + Single Agent CDP (#1655)
Co-authored-by: Douglas Lai <115660088+Douglasymlai@users.noreply.github.com>
Co-authored-by: Douglas <douglas.ym.lai@gmail.com>
2026-05-29 16:19:48 +08:00

382 lines
14 KiB
Python

# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
"""ProjectContextBuilder — assemble durable context for the agent (§8 design).
The runtime entry point is `build(...)` which returns an `AgentContextBundle`.
Callers usually do not consume the bundle directly; they call
`bundle.to_prompt(mode)` to get a string ready to splice into the system
prompt. Keeping rendering separate from data lets future callers (audit,
diagnostics, alternative model formats) reuse the same fetched payload.
Token budget is a rough char-based proxy: 1 token ~= 4 chars matches what
chatStore.ts uses on the frontend side. Section weights are tuned so the most
important continuity signal -- recent assistant/user turns -- gets the
majority share.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal
from app.memory.events import ConversationEvent, MemoryArtifact, MemoryFact
from app.memory.local_store import LocalMemoryStore
# Section weights when allocating a token budget. They sum to ~1.0 plus a
# small slack so the final assembled prompt rarely overshoots.
_HEADER_WEIGHT = 0.20 # space + project summary + facts overview
_RECENT_CONVO_WEIGHT = 0.65 # most recent conversation tail
_ARTIFACTS_WEIGHT = 0.10
_TODOS_WEIGHT = 0.05
# Hard caps to keep any one section from dominating regardless of budget.
_MAX_RECENT_CONVO_EVENTS = 24
ContextMode = Literal[
"single_agent",
"workforce_coordinator",
"workforce_worker",
]
def _chars_for(budget_tokens: int, weight: float) -> int:
return max(0, int(budget_tokens * 4 * weight))
def _truncate(text: str, max_chars: int, ellipsis: str = "...") -> str:
if max_chars <= 0:
return ""
if len(text) <= max_chars:
return text
return text[: max(0, max_chars - len(ellipsis))] + ellipsis
@dataclass
class AgentContextBundle:
"""Mode-agnostic context payload assembled from the local memory store."""
space_name: str
space_summary: str
project_name: str
project_summary: str
recent_conversation: list[ConversationEvent] = field(default_factory=list)
relevant_facts: list[MemoryFact] = field(default_factory=list)
relevant_artifacts: list[MemoryArtifact] = field(default_factory=list)
open_todos: list[str] = field(default_factory=list)
current_run_instruction: str = ""
def is_empty(self) -> bool:
"""True when the bundle has no durable signal to inject.
Callers use this to decide whether to fall back to the legacy
`project_context` bridge.
"""
return (
not self.space_summary
and not self.project_summary
and not self.recent_conversation
and not self.relevant_facts
and not self.relevant_artifacts
and not self.open_todos
)
def to_prompt(self, mode: ContextMode) -> str:
"""Render the bundle into a string ready to splice into a system prompt."""
if mode == "single_agent":
return self._render_single_agent()
if mode == "workforce_coordinator":
return self._render_workforce_coordinator()
if mode == "workforce_worker":
return self._render_workforce_worker()
return self._render_single_agent()
# ----- Renderers -----
def _render_single_agent(self) -> str:
# §9 single agent profile: continuous narrative.
sections: list[str] = []
sections.append("=== Persisted Project Context ===")
if self.space_name or self.space_summary:
sections.append(
_section("Space", self.space_name, self.space_summary)
)
if self.project_name or self.project_summary:
sections.append(
_section("Project", self.project_name, self.project_summary)
)
if self.relevant_facts:
lines = ["Known facts:"]
for fact in self.relevant_facts:
lines.append(f"- {fact.text}")
sections.append("\n".join(lines))
if self.relevant_artifacts:
lines = ["Relevant artifacts:"]
for art in self.relevant_artifacts:
lines.append(f"- {art.path} ({art.kind})")
sections.append("\n".join(lines))
if self.recent_conversation:
lines = ["Recent conversation:"]
for event in self.recent_conversation:
tag = event.role.capitalize()
lines.append(f"{tag}: {event.content}")
sections.append("\n".join(lines))
if self.open_todos:
lines = ["Open todos:"]
for todo in self.open_todos:
lines.append(f"- {todo}")
sections.append("\n".join(lines))
if self.current_run_instruction:
sections.append(
_section("Current turn", "", self.current_run_instruction)
)
sections.append("=== End Persisted Project Context ===")
return "\n\n".join(s for s in sections if s)
def _render_workforce_coordinator(self) -> str:
# §10 coordinator profile: planning view. Minimal first-cut --
# full per-role split is a follow-up branch (M6+).
return self._render_single_agent()
def _render_workforce_worker(self) -> str:
# §10 worker profile: only assignment + narrow facts. Skeleton until
# the workforce milestone wires assignments through.
sections: list[str] = ["=== Worker Assignment ==="]
if self.current_run_instruction:
sections.append(self.current_run_instruction)
if self.relevant_artifacts:
lines = ["Relevant artifacts:"]
for art in self.relevant_artifacts:
lines.append(f"- {art.path} ({art.kind})")
sections.append("\n".join(lines))
if self.relevant_facts:
lines = ["Narrow facts:"]
for fact in self.relevant_facts:
lines.append(f"- {fact.text}")
sections.append("\n".join(lines))
sections.append("=== End Worker Assignment ===")
return "\n\n".join(sections)
def _section(label: str, name: str, body: str) -> str:
title = f"{label}: {name}".strip(": ").rstrip() if name else label
body = body.strip()
if not body:
return ""
return f"{title}\n{body}"
class ProjectContextBuilder:
"""Reads LocalMemoryStore + assembles a budgeted AgentContextBundle."""
def __init__(self, store: LocalMemoryStore) -> None:
self._store = store
def build(
self,
*,
user_key: str,
space_id: str,
project_id: str,
run_id: str,
mode: ContextMode,
token_budget: int,
current_user_prompt: str,
) -> AgentContextBundle:
"""Return a bundle sized to roughly `token_budget` tokens.
`run_id` is accepted for future filtering (e.g. exclude the in-flight
run's own user prompt from recent_conversation) but is otherwise
informational in this milestone.
"""
space = self._store.read_space(user_key, space_id)
project = self._store.read_project(user_key, space_id, project_id)
project_summary_raw = self._store.read_project_summary(
user_key, space_id, project_id
)
facts_raw = self._store.read_facts(user_key, space_id, project_id)
artifacts_raw = self._store.read_artifacts(
user_key, space_id, project_id
)
recent_conv_raw = self._store.read_conversation_tail(
user_key, space_id, project_id, limit=_MAX_RECENT_CONVO_EVENTS
)
# Drop debug_only / audit_only events from the context view -- only
# `context`-visibility turns may show up in prompts.
recent_conv_raw = [
event
for event in recent_conv_raw
if event.visibility == "context"
and event.run_id
!= run_id # exclude the in-flight run's own prompt
]
# Drop runtime-log artifacts; only context-eligible ones make it in.
artifacts_in_context = [
art for art in artifacts_raw if art.eligible_for_context
]
header_budget = _chars_for(token_budget, _HEADER_WEIGHT)
convo_budget = _chars_for(token_budget, _RECENT_CONVO_WEIGHT)
artifacts_budget = _chars_for(token_budget, _ARTIFACTS_WEIGHT)
todos_budget = _chars_for(token_budget, _TODOS_WEIGHT)
# Header: split between space + project summary roughly evenly.
space_summary = _truncate(
"", # space-level summary not authored yet in this milestone
header_budget // 2,
)
project_summary = _truncate(project_summary_raw, header_budget // 2)
# Recent conversation, fit newest-first.
trimmed_recent = self._fit_conversation_to_budget(
recent_conv_raw, convo_budget
)
# Facts: take top-confidence, char-trim by artifacts/todos budget.
relevant_facts = self._top_facts(facts_raw, todos_budget)
relevant_artifacts = self._top_artifacts(
artifacts_in_context, artifacts_budget
)
bundle = AgentContextBundle(
space_name=space.name if space is not None else space_id,
space_summary=space_summary,
project_name=project.name if project is not None else project_id,
project_summary=project_summary,
recent_conversation=trimmed_recent,
relevant_facts=relevant_facts,
relevant_artifacts=relevant_artifacts,
open_todos=[], # todos pipeline not in M3; reserved for follow-up
current_run_instruction=current_user_prompt.strip(),
)
return bundle
# ----- Section selectors -----
@staticmethod
def _fit_conversation_to_budget(
events: list[ConversationEvent], char_budget: int
) -> list[ConversationEvent]:
if char_budget <= 0 or not events:
return []
# Walk newest-first, keep events until budget exhausted, then flip
# back so the prompt reads oldest-first.
framing_overhead = 16 # "Role: " etc. framing per event in the prompt
truncation_marker = "\n... [truncated to fit context budget]"
kept: list[ConversationEvent] = []
used = 0
for event in reversed(events):
cost = len(event.content) + framing_overhead
remaining = char_budget - used
if used + cost <= char_budget:
kept.append(event)
used += cost
continue
if kept:
# Budget exhausted; older events are dropped.
break
# The single newest event is larger than the entire section
# budget. Truncate it instead of injecting it whole, otherwise
# one oversized final_result (HTML report, CSV dump) blows past
# the prompt token budget the caller asked us to honor.
allowed_content = (
remaining - framing_overhead - len(truncation_marker)
)
if allowed_content <= 0:
return []
truncated = ConversationEvent(
event_id=event.event_id,
run_id=event.run_id,
timestamp=event.timestamp,
role=event.role,
content=event.content[:allowed_content] + truncation_marker,
source=event.source,
visibility=event.visibility,
hash=event.hash,
)
kept.append(truncated)
break
kept.reverse()
return kept
@staticmethod
def _top_facts(
facts: list[MemoryFact], char_budget: int
) -> list[MemoryFact]:
if char_budget <= 0 or not facts:
return []
framing = 4 # "- " prefix + newline
marker = "... [truncated]"
ranked = sorted(facts, key=lambda f: f.confidence, reverse=True)
kept: list[MemoryFact] = []
used = 0
for fact in ranked:
cost = len(fact.text) + framing
remaining = char_budget - used
if used + cost <= char_budget:
kept.append(fact)
used += cost
continue
if kept:
break
# The top-ranked fact alone exceeds the budget. Truncate the text
# in a copy rather than injecting the whole thing -- future
# Memory Toolkit writers can produce arbitrarily long fact bodies.
allowed = remaining - framing - len(marker)
if allowed <= 0:
return []
from dataclasses import replace as _replace
kept.append(_replace(fact, text=fact.text[:allowed] + marker))
break
return kept
@staticmethod
def _top_artifacts(
artifacts: list[MemoryArtifact], char_budget: int
) -> list[MemoryArtifact]:
if char_budget <= 0 or not artifacts:
return []
framing = 8 # "- " + " (kind)" overhead
marker = "... [truncated]"
# Most recent first; created_at is ISO string so lex sort works.
ranked = sorted(artifacts, key=lambda a: a.created_at, reverse=True)
kept: list[MemoryArtifact] = []
used = 0
for art in ranked:
cost = len(art.path) + len(art.kind) + framing
remaining = char_budget - used
if used + cost <= char_budget:
kept.append(art)
used += cost
continue
if kept:
break
# Single oversized artifact path -- truncate the path so the
# bundle never blows past the section budget even on pathological
# inputs.
allowed = remaining - framing - len(art.kind) - len(marker)
if allowed <= 0:
return []
from dataclasses import replace as _replace
kept.append(_replace(art, path=art.path[:allowed] + marker))
break
return kept