mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-07-17 13:18:39 +00:00
Co-authored-by: Douglas Lai <115660088+Douglasymlai@users.noreply.github.com> Co-authored-by: Douglas <douglas.ym.lai@gmail.com>
382 lines
14 KiB
Python
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
|