From 8f295f21bfd0f8a3e9c099c8321b7308fe9ec2cd Mon Sep 17 00:00:00 2001 From: rica-v3 Date: Mon, 27 Jul 2026 15:29:16 +0900 Subject: [PATCH 1/2] prompt_context.py: compact request event history --- models.py | 2 + runtime/base_runtime.py | 49 ++- runtime/research_runtime.py | 4 + shared/config.py | 40 +++ shared/models.py | 9 + shared/prompt_context.py | 141 +++++++++ templates/scaffold/team_runtime.yaml | 5 + tests/test_prompt_context.py | 389 ++++++++++++++++++++++++ workflows/orchestration/team_service.py | 3 + workflows/roles/research.py | 33 +- 10 files changed, 671 insertions(+), 4 deletions(-) create mode 100644 shared/prompt_context.py create mode 100644 tests/test_prompt_context.py diff --git a/models.py b/models.py index 37df4fb..45f4cab 100644 --- a/models.py +++ b/models.py @@ -15,6 +15,7 @@ INTERNAL_TEAM_AGENTS, MessageEnvelope, ModelRateCard, + PromptContextRuntimeConfig, ReplyRoute, RequestEvent, RequestRecord, @@ -40,6 +41,7 @@ "INTERNAL_TEAM_AGENTS", "MessageEnvelope", "ModelRateCard", + "PromptContextRuntimeConfig", "ReplyRoute", "RequestEvent", "RequestRecord", diff --git a/runtime/base_runtime.py b/runtime/base_runtime.py index c26a57d..bdb0670 100644 --- a/runtime/base_runtime.py +++ b/runtime/base_runtime.py @@ -32,11 +32,17 @@ from teams_runtime.runtime.session_manager import RoleSessionManager from teams_runtime.shared.models import ( MessageEnvelope, + PromptContextRuntimeConfig, RequestRecord, RoleResult, RoleRuntimeConfig, TelemetryRuntimeConfig, ) +from teams_runtime.shared.prompt_context import ( + PromptRequestProjection, + project_request_record_for_prompt, + render_prompt_event_history_notice, +) from teams_runtime.workflows.roles import render_role_prompt_spec from teams_runtime.workflows.roles.planner import normalize_planner_proposals @@ -266,6 +272,7 @@ def __init__( agent_root: Path | None = None, session_identity: str | None = None, telemetry_config: TelemetryRuntimeConfig | None = None, + prompt_context_config: PromptContextRuntimeConfig | None = None, ): self.paths = paths self.role = role @@ -283,6 +290,7 @@ def __init__( self.sprint_id: self.session_manager, } self.telemetry_recorder = ModelTelemetryRecorder(paths, self.runtime_identity, telemetry_config) + self.prompt_context_config = prompt_context_config or PromptContextRuntimeConfig() self.codex_runner = CodexRunner( runtime_config, role=role, @@ -291,6 +299,31 @@ def __init__( self.runtime_config = runtime_config self._run_lock = threading.Lock() + def _project_request_for_prompt( + self, + request_record: RequestRecord, + *, + purpose: str, + ) -> PromptRequestProjection: + projection = project_request_record_for_prompt( + request_record, + self.prompt_context_config, + ) + if projection.compacted: + LOGGER.info( + "[%s] prompt_context_compacted request_id=%s purpose=%s total_events=%s included_events=%s " + "omitted_events=%s recent_events=%s max_events=%s", + self.role, + str(request_record.get("request_id") or "unknown"), + purpose, + projection.total_events, + projection.included_events, + projection.omitted_events, + projection.recent_events, + projection.max_events, + ) + return projection + def _resolve_request_sprint_id( self, envelope: MessageEnvelope, @@ -643,6 +676,11 @@ def _build_role_result_repair_prompt( *, current_sprint_id: str, ) -> str: + request_projection = self._project_request_for_prompt( + request_record, + purpose="contract_repair", + ) + event_history_notice = render_prompt_event_history_notice(request_projection) team_workspace_hint = "./workspace/teams_generated" if self.paths.workspace_root.name == "teams_generated" else "./workspace" role_specific_rules, extra_fields = render_role_prompt_spec(self.role, team_workspace_hint) contract_block = render_role_result_contract( @@ -674,8 +712,9 @@ def _build_role_result_repair_prompt( If validation errors mention copied placeholder or scaffold text, do not reuse any wording from the shape block. Write concrete Korean summary and workflow reason text from the actual request state, or return `failed` with a concrete Korean reason. {role_specific_rules} +{event_history_notice} Current request: -{json.dumps(request_record, ensure_ascii=False, indent=2)} +{json.dumps(request_projection.request_record, ensure_ascii=False, indent=2)} """ def _should_retry_with_bypass(self, payload: dict[str, Any]) -> bool: @@ -734,6 +773,11 @@ def _build_prompt( *, current_sprint_id: str | None = None, ) -> str: + request_projection = self._project_request_for_prompt( + request_record, + purpose="role_task", + ) + event_history_notice = render_prompt_event_history_notice(request_projection) resolved_sprint_id = str(current_sprint_id or "").strip() or self._resolve_request_sprint_id( envelope, request_record, @@ -774,8 +818,9 @@ def _build_prompt( When you claim a file change or validation result, leave enough evidence in `summary`, `insights`, or `proposals` for orchestrator to verify what you actually checked. {role_specific_rules} +{event_history_notice} Current request: -{json.dumps(request_record, ensure_ascii=False, indent=2)} +{json.dumps(request_projection.request_record, ensure_ascii=False, indent=2)} Incoming envelope: {json.dumps(envelope.to_dict(), ensure_ascii=False, indent=2)} diff --git a/runtime/research_runtime.py b/runtime/research_runtime.py index 6024cc9..20ce7ce 100644 --- a/runtime/research_runtime.py +++ b/runtime/research_runtime.py @@ -10,6 +10,7 @@ from teams_runtime.shared.paths import RuntimePaths from teams_runtime.shared.models import ( MessageEnvelope, + PromptContextRuntimeConfig, RequestRecord, ResearchRuntimeConfig, RoleResult, @@ -93,6 +94,7 @@ def __init__( agent_root: Path | None = None, session_identity: str | None = None, telemetry_config: TelemetryRuntimeConfig | None = None, + prompt_context_config: PromptContextRuntimeConfig | None = None, ): super().__init__( paths=paths, @@ -102,6 +104,7 @@ def __init__( agent_root=agent_root, session_identity=session_identity, telemetry_config=telemetry_config, + prompt_context_config=prompt_context_config, ) self.research_defaults = research_defaults @@ -536,6 +539,7 @@ def _run_research_decision( envelope, request_record, local_sources_checked=local_sources_checked, + prompt_context_config=self.prompt_context_config, ) output, resolved_session_id = run_with_optional_telemetry( self.codex_runner, diff --git a/shared/config.py b/shared/config.py index 116a4b0..bddbee6 100644 --- a/shared/config.py +++ b/shared/config.py @@ -14,6 +14,7 @@ ActionConfig, DiscordAgentsConfig, ModelRateCard, + PromptContextRuntimeConfig, ResearchRuntimeConfig, RoleAgentConfig, RoleRuntimeConfig, @@ -134,6 +135,43 @@ def _normalize_research_defaults(value: Any) -> ResearchRuntimeConfig: ) +def _normalize_positive_integer(value: Any, *, field_name: str, default: int) -> int: + if value is None: + return default + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise ValueError(f"{field_name} must be a positive integer.") + return value + + +def _normalize_prompt_context_config(value: Any) -> PromptContextRuntimeConfig: + if value in (None, {}): + return PromptContextRuntimeConfig() + if not isinstance(value, dict): + raise ValueError("team_runtime.yaml prompt_context must be a mapping.") + enabled = value.get("enabled", True) + if not isinstance(enabled, bool): + raise ValueError("team_runtime.yaml prompt_context.enabled must be a boolean.") + recent_events = _normalize_positive_integer( + value.get("recent_events"), + field_name="team_runtime.yaml prompt_context.recent_events", + default=8, + ) + max_events = _normalize_positive_integer( + value.get("max_events"), + field_name="team_runtime.yaml prompt_context.max_events", + default=16, + ) + if max_events < recent_events: + raise ValueError( + "team_runtime.yaml prompt_context.max_events must be greater than or equal to recent_events." + ) + return PromptContextRuntimeConfig( + enabled=enabled, + recent_events=recent_events, + max_events=max_events, + ) + + def _normalize_non_negative_rate(value: Any, *, field_name: str) -> float: try: normalized = float(value) @@ -456,6 +494,7 @@ def load_team_runtime_config(workspace_root: str | Path) -> TeamRuntimeConfig: if raw_research_defaults not in (None, {}) and not isinstance(raw_research_defaults, dict): raise ValueError("team_runtime.yaml research_defaults must be a mapping.") research_defaults = _normalize_research_defaults(raw_research_defaults) + prompt_context = _normalize_prompt_context_config(payload.get("prompt_context")) telemetry = _normalize_telemetry_config(payload.get("telemetry")) actions: dict[str, ActionConfig] = {} @@ -508,6 +547,7 @@ def load_team_runtime_config(workspace_root: str | Path) -> TeamRuntimeConfig: allowed_guild_ids=tuple(str(item).strip() for item in allowed_guild_ids if str(item).strip()), role_defaults=role_defaults, research_defaults=research_defaults, + prompt_context=prompt_context, telemetry=telemetry, actions=actions, ) diff --git a/shared/models.py b/shared/models.py index 5cfcc2f..c5b4596 100644 --- a/shared/models.py +++ b/shared/models.py @@ -251,6 +251,13 @@ class TelemetryRuntimeConfig: rate_cards: dict[str, ModelRateCard] = field(default_factory=dict) +@dataclass(slots=True, frozen=True) +class PromptContextRuntimeConfig: + enabled: bool = True + recent_events: int = 8 + max_events: int = 16 + + @dataclass(slots=True, frozen=True) class ActionConfig: name: str @@ -277,6 +284,7 @@ class TeamRuntimeConfig: allowed_guild_ids: tuple[str, ...] = () role_defaults: dict[str, RoleRuntimeConfig] = field(default_factory=dict) research_defaults: ResearchRuntimeConfig = field(default_factory=ResearchRuntimeConfig) + prompt_context: PromptContextRuntimeConfig = field(default_factory=PromptContextRuntimeConfig) telemetry: TelemetryRuntimeConfig = field(default_factory=TelemetryRuntimeConfig) actions: dict[str, ActionConfig] = field(default_factory=dict) @@ -344,6 +352,7 @@ def from_dict(cls, payload: dict[str, Any]) -> "RoleSessionState": "GoalState", "INTERNAL_TEAM_AGENTS", "MessageEnvelope", + "PromptContextRuntimeConfig", "ReplyRoute", "RequestEvent", "RequestRecord", diff --git a/shared/prompt_context.py b/shared/prompt_context.py new file mode 100644 index 0000000..9e0a4be --- /dev/null +++ b/shared/prompt_context.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any + +from teams_runtime.shared.models import PromptContextRuntimeConfig, RequestRecord + + +PROMPT_EVENT_SELECTION_POLICY = "recent_tail_plus_latest_role_evidence" + + +@dataclass(slots=True, frozen=True) +class PromptRequestProjection: + request_record: RequestRecord + total_events: int + included_events: int + omitted_events: int + recent_events: int + max_events: int + canonical_request: str + + @property + def compacted(self) -> bool: + return self.omitted_events > 0 + + def notice(self) -> dict[str, Any]: + if not self.compacted: + return {} + return { + "compacted": True, + "total_events": self.total_events, + "included_events": self.included_events, + "omitted_events": self.omitted_events, + "recent_events": self.recent_events, + "max_events": self.max_events, + "selection": PROMPT_EVENT_SELECTION_POLICY, + "canonical_request": self.canonical_request, + } + + +def _role_evidence_identity(event: Any) -> str: + if not isinstance(event, dict): + return "" + payload = event.get("payload") if isinstance(event.get("payload"), dict) else {} + payload_role = str(payload.get("role") or "").strip().lower() + payload_status = str(payload.get("status") or "").strip().lower() + event_types = { + str(event.get(field_name) or "").strip().lower() + for field_name in ("type", "event_type") + } + if "role_report" not in event_types and not (payload_role and payload_status): + return "" + return payload_role or str(event.get("actor") or "").strip().lower() + + +def _canonical_request_path(request_record: RequestRecord) -> str: + request_id = str(request_record.get("request_id") or "").strip() + if not request_id: + return "" + return f"./.teams_runtime/requests/{request_id}.json" + + +def project_request_record_for_prompt( + request_record: RequestRecord, + config: PromptContextRuntimeConfig | None = None, +) -> PromptRequestProjection: + resolved_config = config or PromptContextRuntimeConfig() + projected_record: RequestRecord = dict(request_record) + raw_events = request_record.get("events") + if not isinstance(raw_events, list): + return PromptRequestProjection( + request_record=projected_record, + total_events=0, + included_events=0, + omitted_events=0, + recent_events=resolved_config.recent_events, + max_events=resolved_config.max_events, + canonical_request=_canonical_request_path(request_record), + ) + + total_events = len(raw_events) + if not resolved_config.enabled or total_events <= resolved_config.max_events: + return PromptRequestProjection( + request_record=projected_record, + total_events=total_events, + included_events=total_events, + omitted_events=0, + recent_events=resolved_config.recent_events, + max_events=resolved_config.max_events, + canonical_request=_canonical_request_path(request_record), + ) + + tail_start = total_events - resolved_config.recent_events + selected_indices = set(range(tail_start, total_events)) + represented_roles = { + role + for role in (_role_evidence_identity(raw_events[index]) for index in selected_indices) + if role + } + + for index in range(tail_start - 1, -1, -1): + if len(selected_indices) >= resolved_config.max_events: + break + role = _role_evidence_identity(raw_events[index]) + if not role or role in represented_roles: + continue + selected_indices.add(index) + represented_roles.add(role) + + selected_events = [raw_events[index] for index in sorted(selected_indices)] + projected_record["events"] = selected_events # type: ignore[typeddict-item] + included_events = len(selected_events) + return PromptRequestProjection( + request_record=projected_record, + total_events=total_events, + included_events=included_events, + omitted_events=total_events - included_events, + recent_events=resolved_config.recent_events, + max_events=resolved_config.max_events, + canonical_request=_canonical_request_path(request_record), + ) + + +def render_prompt_event_history_notice(projection: PromptRequestProjection) -> str: + if not projection.compacted: + return "" + return f"""Current request event-history projection: +{json.dumps(projection.notice(), ensure_ascii=False, indent=2)} +The `events` array below contains complete selected events, not summaries. +Omitted events still exist in the canonical request and must not be treated as events that never happened. +Open `canonical_request` only when the current decision requires evidence missing from the selected events. +""" + + +__all__ = [ + "PROMPT_EVENT_SELECTION_POLICY", + "PromptRequestProjection", + "project_request_record_for_prompt", + "render_prompt_event_history_notice", +] diff --git a/templates/scaffold/team_runtime.yaml b/templates/scaffold/team_runtime.yaml index f14ebe9..02db2db 100644 --- a/templates/scaffold/team_runtime.yaml +++ b/templates/scaffold/team_runtime.yaml @@ -50,6 +50,11 @@ research_defaults: cleanup: false reasoning_level: "Standard" +prompt_context: + enabled: true + recent_events: 8 + max_events: 16 + telemetry: enabled: true rate_cards: {} diff --git a/tests/test_prompt_context.py b/tests/test_prompt_context.py new file mode 100644 index 0000000..227d69e --- /dev/null +++ b/tests/test_prompt_context.py @@ -0,0 +1,389 @@ +from __future__ import annotations + +import copy +import tempfile +import unittest +from pathlib import Path + +import yaml + +from teams_runtime.core.template import scaffold_workspace +from teams_runtime.runtime.base_runtime import RoleAgentRuntime +from teams_runtime.shared.config import load_team_runtime_config +from teams_runtime.shared.models import ( + MessageEnvelope, + PromptContextRuntimeConfig, + RoleRuntimeConfig, +) +from teams_runtime.shared.paths import RuntimePaths +from teams_runtime.shared.prompt_context import ( + project_request_record_for_prompt, + render_prompt_event_history_notice, +) +from teams_runtime.workflows.orchestration.team_service import TeamService +from teams_runtime.workflows.roles.research import build_research_decision_prompt + + +def _role_report(timestamp: str, role: str, summary: str) -> dict[str, object]: + return { + "timestamp": timestamp, + "type": "role_report", + "actor": role, + "payload": { + "role": role, + "status": "completed", + "summary": summary, + }, + } + + +def _envelope(request_id: str = "request-123") -> MessageEnvelope: + return MessageEnvelope( + request_id=request_id, + sender="orchestrator", + target="planner", + intent="plan", + urgency="normal", + scope="Compact the request prompt.", + body="ENVELOPE-CONTEXT-MARKER", + ) + + +class PromptContextProjectionTests(unittest.TestCase): + def test_projection_backfills_latest_missing_role_evidence(self): + events = [ + {"timestamp": "T01", "type": "created", "actor": "orchestrator", "summary": "Request created."}, + _role_report("T02", "research", "Research completed."), + {"timestamp": "T03", "type": "delegated", "actor": "orchestrator", "summary": "To planner."}, + _role_report("T04", "planner", "Initial plan drafted."), + _role_report("T05", "designer", "Design constraints recorded."), + _role_report("T06", "planner", "Final plan completed."), + {"timestamp": "T07", "type": "retried", "actor": "orchestrator", "summary": "Retried."}, + _role_report("T08", "developer", "Implementation completed."), + _role_report("T09", "architect", "Implementation reviewed."), + {"timestamp": "T10", "type": "delegated", "actor": "orchestrator", "summary": "To QA."}, + _role_report("T11", "qa", "A regression remains."), + {"timestamp": "T12", "type": "resumed", "actor": "orchestrator", "summary": "Resumed."}, + ] + request_record = { + "request_id": "request-123", + "status": "delegated", + "artifacts": ["shared_workspace/spec.md"], + "result": { + "role": "qa", + "status": "blocked", + "summary": "CURRENT-RESULT-MARKER", + }, + "events": events, + } + original = copy.deepcopy(request_record) + + projection = project_request_record_for_prompt( + request_record, + PromptContextRuntimeConfig(recent_events=4, max_events=7), + ) + + self.assertEqual( + [event["timestamp"] for event in projection.request_record["events"]], + ["T05", "T06", "T08", "T09", "T10", "T11", "T12"], + ) + self.assertEqual(projection.request_record["events"][0], events[4]) + self.assertEqual(projection.total_events, 12) + self.assertEqual(projection.included_events, 7) + self.assertEqual(projection.omitted_events, 5) + self.assertEqual( + projection.notice(), + { + "compacted": True, + "total_events": 12, + "included_events": 7, + "omitted_events": 5, + "recent_events": 4, + "max_events": 7, + "selection": "recent_tail_plus_latest_role_evidence", + "canonical_request": "./.teams_runtime/requests/request-123.json", + }, + ) + self.assertEqual(request_record, original) + + def test_disabled_and_under_limit_histories_remain_complete(self): + request_record = { + "request_id": "request-disabled", + "events": [ + _role_report(f"T{index:02d}", "planner", f"report-{index}") + for index in range(20) + ], + } + + disabled = project_request_record_for_prompt( + request_record, + PromptContextRuntimeConfig(enabled=False, recent_events=2, max_events=3), + ) + under_limit = project_request_record_for_prompt( + {"request_id": "request-short", "events": request_record["events"][:3]}, + PromptContextRuntimeConfig(recent_events=2, max_events=3), + ) + + self.assertEqual(disabled.request_record["events"], request_record["events"]) + self.assertFalse(disabled.compacted) + self.assertEqual(render_prompt_event_history_notice(disabled), "") + self.assertEqual(under_limit.request_record["events"], request_record["events"][:3]) + self.assertFalse(under_limit.compacted) + + def test_recent_only_and_malformed_histories_are_fail_safe(self): + events: list[object] = [ + _role_report("T01", "research", "old research"), + _role_report("T02", "planner", "old planner"), + {"timestamp": "T03", "type": "created"}, + "MALFORMED-RECENT-EVENT", + {"timestamp": "T05", "type": "resumed", "summary": "LATEST-EVENT"}, + ] + recent_only = project_request_record_for_prompt( + {"request_id": "request-recent", "events": events}, # type: ignore[typeddict-item] + PromptContextRuntimeConfig(recent_events=2, max_events=2), + ) + malformed_history = project_request_record_for_prompt( + {"request_id": "request-malformed", "events": "not-a-list"}, # type: ignore[typeddict-item] + PromptContextRuntimeConfig(recent_events=2, max_events=2), + ) + + self.assertEqual( + recent_only.request_record["events"], + ["MALFORMED-RECENT-EVENT", {"timestamp": "T05", "type": "resumed", "summary": "LATEST-EVENT"}], + ) + self.assertEqual(malformed_history.request_record["events"], "not-a-list") + self.assertFalse(malformed_history.compacted) + + def test_backfill_skips_roles_in_tail_and_accepts_legacy_evidence_shapes(self): + events = [ + {"timestamp": "T01", "type": "created"}, + _role_report("T02", "research", "Research evidence."), + _role_report("T03", "planner", "Stale planner evidence."), + { + "timestamp": "T04", + "type": "legacy", + "event_type": "role_report", + "actor": "architect", + "payload": {"summary": "Legacy architect evidence."}, + }, + { + "timestamp": "T05", + "type": "commit_inspected", + "actor": "orchestrator", + "payload": { + "role": "version_controller", + "status": "completed", + "summary": "Version-control evidence.", + }, + }, + _role_report("T06", "planner", "Planner is already represented in the tail."), + {"timestamp": "T07", "type": "resumed"}, + ] + + projection = project_request_record_for_prompt( + {"request_id": "request-evidence-shapes", "events": events}, + PromptContextRuntimeConfig(recent_events=2, max_events=5), + ) + + self.assertEqual( + [event["timestamp"] for event in projection.request_record["events"]], + ["T02", "T04", "T05", "T06", "T07"], + ) + self.assertNotIn(events[2], projection.request_record["events"]) + + def test_normal_repair_and_research_prompts_share_projection(self): + request_record = { + "request_id": "request-prompt", + "scope": "Compact the request prompt.", + "body": "", + "artifacts": ["ARTIFACT-MARKER"], + "params": {"workflow": {"phase": "implementation", "step": "developer_build"}}, + "result": { + "role": "planner", + "status": "completed", + "summary": "CURRENT-RESULT-MARKER", + }, + "events": [ + {"timestamp": "T01", "type": "created", "summary": "OMITTED-EVENT-MARKER"}, + _role_report("T02", "research", "RESEARCH-EVIDENCE-MARKER"), + _role_report("T03", "planner", "OLD-PLANNER-MARKER"), + {"timestamp": "T04", "type": "delegated", "summary": "RECENT-EVENT-ONE"}, + {"timestamp": "T05", "type": "resumed", "summary": "RECENT-EVENT-TWO"}, + ], + } + config = PromptContextRuntimeConfig(recent_events=2, max_events=3) + envelope = _envelope("request-prompt") + + with tempfile.TemporaryDirectory() as tmpdir: + runtime = RoleAgentRuntime( + paths=RuntimePaths.from_root(tmpdir), + role="planner", + sprint_id="sprint-a", + runtime_config=RoleRuntimeConfig(), + prompt_context_config=config, + ) + normal_prompt = runtime._build_prompt(envelope, request_record) + repair_prompt = runtime._build_role_result_repair_prompt( + request_record, + { + "request_id": "request-prompt", + "role": "planner", + "status": "failed", + "summary": "Invalid result.", + }, + ["missing_summary"], + current_sprint_id="sprint-a", + ) + + research_prompt = build_research_decision_prompt( + envelope, + request_record, + local_sources_checked=["request.scope"], + prompt_context_config=config, + ) + + for prompt in (normal_prompt, repair_prompt, research_prompt): + self.assertIn('"compacted": true', prompt) + self.assertIn("OLD-PLANNER-MARKER", prompt) + self.assertIn("RECENT-EVENT-ONE", prompt) + self.assertIn("RECENT-EVENT-TWO", prompt) + self.assertNotIn("OMITTED-EVENT-MARKER", prompt) + self.assertNotIn("RESEARCH-EVIDENCE-MARKER", prompt) + self.assertIn("CURRENT-RESULT-MARKER", prompt) + self.assertIn("ARTIFACT-MARKER", prompt) + + self.assertIn("ENVELOPE-CONTEXT-MARKER", normal_prompt) + self.assertIn("ENVELOPE-CONTEXT-MARKER", research_prompt) + + def test_large_history_prompt_is_bounded_and_materially_smaller(self): + large_text = "payload-context-" * 80 + roles = ("orchestrator", "research", "planner", "designer", "architect", "developer", "qa") + events = [ + { + **_role_report(f"T{index:03d}", roles[index % len(roles)], f"{index}-{large_text}"), + "sequence": index, + } + for index in range(100) + ] + request_record = { + "request_id": "request-large", + "scope": "Large history", + "body": "", + "artifacts": [], + "result": { + "role": "qa", + "status": "completed", + "summary": "CURRENT-LARGE-RESULT", + }, + "events": events, + } + envelope = _envelope("request-large") + + with tempfile.TemporaryDirectory() as tmpdir: + paths = RuntimePaths.from_root(tmpdir) + compact_runtime = RoleAgentRuntime( + paths=paths, + role="qa", + sprint_id="sprint-a", + runtime_config=RoleRuntimeConfig(), + prompt_context_config=PromptContextRuntimeConfig(), + ) + full_runtime = RoleAgentRuntime( + paths=paths, + role="qa", + sprint_id="sprint-a", + runtime_config=RoleRuntimeConfig(), + prompt_context_config=PromptContextRuntimeConfig(enabled=False), + ) + + compact_prompt = compact_runtime._build_prompt(envelope, request_record) + full_prompt = full_runtime._build_prompt(envelope, request_record) + + projection = project_request_record_for_prompt( + request_record, + PromptContextRuntimeConfig(), + ) + self.assertLessEqual(projection.included_events, 16) + self.assertLessEqual(len(compact_prompt), len(full_prompt) * 0.30) + self.assertIn("CURRENT-LARGE-RESULT", compact_prompt) + + +class PromptContextConfigTests(unittest.TestCase): + def test_config_defaults_custom_values_and_scaffold(self): + with tempfile.TemporaryDirectory() as tmpdir: + scaffold_workspace(tmpdir) + config_path = Path(tmpdir) / "team_runtime.yaml" + scaffold_payload = yaml.safe_load(config_path.read_text(encoding="utf-8")) + + default_config = load_team_runtime_config(tmpdir) + self.assertEqual(default_config.prompt_context, PromptContextRuntimeConfig()) + self.assertEqual( + scaffold_payload["prompt_context"], + {"enabled": True, "recent_events": 8, "max_events": 16}, + ) + + scaffold_payload.pop("prompt_context") + config_path.write_text(yaml.safe_dump(scaffold_payload, sort_keys=False), encoding="utf-8") + missing_config = load_team_runtime_config(tmpdir) + self.assertEqual(missing_config.prompt_context, PromptContextRuntimeConfig()) + + scaffold_payload["prompt_context"] = { + "enabled": False, + "recent_events": 4, + "max_events": 7, + } + config_path.write_text(yaml.safe_dump(scaffold_payload, sort_keys=False), encoding="utf-8") + custom_config = load_team_runtime_config(tmpdir) + self.assertEqual( + custom_config.prompt_context, + PromptContextRuntimeConfig(enabled=False, recent_events=4, max_events=7), + ) + + def test_config_rejects_invalid_prompt_context_values(self): + invalid_values = [ + ("not-a-mapping", "must be a mapping"), + ({"enabled": "yes"}, "enabled must be a boolean"), + ({"recent_events": True}, "recent_events must be a positive integer"), + ({"recent_events": 0}, "recent_events must be a positive integer"), + ({"max_events": 0}, "max_events must be a positive integer"), + ({"recent_events": 8, "max_events": 7}, "greater than or equal"), + ] + with tempfile.TemporaryDirectory() as tmpdir: + scaffold_workspace(tmpdir) + config_path = Path(tmpdir) / "team_runtime.yaml" + base_payload = yaml.safe_load(config_path.read_text(encoding="utf-8")) + + for prompt_context, error_pattern in invalid_values: + with self.subTest(prompt_context=prompt_context): + payload = dict(base_payload) + payload["prompt_context"] = prompt_context + config_path.write_text( + yaml.safe_dump(payload, sort_keys=False), + encoding="utf-8", + ) + with self.assertRaisesRegex(ValueError, error_pattern): + load_team_runtime_config(tmpdir) + + def test_team_service_propagates_config_to_all_model_facing_role_runtimes(self): + with tempfile.TemporaryDirectory() as tmpdir: + scaffold_workspace(tmpdir) + config_path = Path(tmpdir) / "team_runtime.yaml" + payload = yaml.safe_load(config_path.read_text(encoding="utf-8")) + payload["prompt_context"] = { + "enabled": True, + "recent_events": 3, + "max_events": 6, + } + config_path.write_text(yaml.safe_dump(payload, sort_keys=False), encoding="utf-8") + expected = PromptContextRuntimeConfig(recent_events=3, max_events=6) + + service = TeamService(tmpdir, "planner", enable_discord_client=False) + research_runtime = service._runtime_for_role("research", service.runtime_config.sprint_id) + + self.assertEqual(service.role_runtime.prompt_context_config, expected) + self.assertEqual(research_runtime.prompt_context_config, expected) + self.assertEqual(service.version_controller_runtime.prompt_context_config, expected) + + +if __name__ == "__main__": + unittest.main() diff --git a/workflows/orchestration/team_service.py b/workflows/orchestration/team_service.py index 008d9b2..e687cac 100644 --- a/workflows/orchestration/team_service.py +++ b/workflows/orchestration/team_service.py @@ -992,6 +992,7 @@ def __init__( agent_root=self.paths.internal_agent_root("version_controller"), session_identity=self._local_runtime_session_identity("version_controller"), telemetry_config=self.runtime_config.telemetry, + prompt_context_config=self.runtime_config.prompt_context, ) self._purge_request_scoped_role_output_files() self._role_runtime_cache: dict[tuple[str, str, str], RoleAgentRuntime] = { @@ -2944,6 +2945,7 @@ def _build_role_runtime( research_defaults=self.runtime_config.research_defaults, session_identity=session_identity, telemetry_config=self.runtime_config.telemetry, + prompt_context_config=self.runtime_config.prompt_context, ) return RoleAgentRuntime( paths=self.paths, @@ -2952,6 +2954,7 @@ def _build_role_runtime( runtime_config=self.runtime_config.role_defaults[role], session_identity=session_identity, telemetry_config=self.runtime_config.telemetry, + prompt_context_config=self.runtime_config.prompt_context, ) def _runtime_for_role(self, role: str, sprint_id: str) -> RoleAgentRuntime: diff --git a/workflows/roles/research.py b/workflows/roles/research.py index 8217c04..498ae48 100644 --- a/workflows/roles/research.py +++ b/workflows/roles/research.py @@ -1,11 +1,22 @@ from __future__ import annotations import json +import logging import re from typing import Any -from teams_runtime.shared.models import MessageEnvelope, RequestRecord +from teams_runtime.shared.models import ( + MessageEnvelope, + PromptContextRuntimeConfig, + RequestRecord, +) +from teams_runtime.shared.prompt_context import ( + project_request_record_for_prompt, + render_prompt_event_history_notice, +) + +LOGGER = logging.getLogger(__name__) RESEARCH_REASON_CODE_NEEDED_EXTERNAL_GROUNDING = "needed_external_grounding" RESEARCH_REASON_CODE_NOT_NEEDED_LOCAL_EVIDENCE = "not_needed_local_evidence" @@ -607,7 +618,24 @@ def build_research_decision_prompt( request_record: RequestRecord, *, local_sources_checked: list[str], + prompt_context_config: PromptContextRuntimeConfig | None = None, ) -> str: + request_projection = project_request_record_for_prompt( + request_record, + prompt_context_config, + ) + if request_projection.compacted: + LOGGER.info( + "[research] prompt_context_compacted request_id=%s purpose=research_decision total_events=%s " + "included_events=%s omitted_events=%s recent_events=%s max_events=%s", + str(request_record.get("request_id") or "unknown"), + request_projection.total_events, + request_projection.included_events, + request_projection.omitted_events, + request_projection.recent_events, + request_projection.max_events, + ) + event_history_notice = render_prompt_event_history_notice(request_projection) params = dict(request_record.get("params") or {}) if isinstance(request_record.get("params"), dict) else {} public_targeted = str(params.get("user_requested_role") or "").strip().lower() == "research" closeout_requirements = closeout_original_requirements_from_request(request_record) @@ -689,8 +717,9 @@ def build_research_decision_prompt( "Local sources already checked:", *[f"- {item}" for item in local_sources_checked], "", + event_history_notice, "Current request:", - json.dumps(request_record, ensure_ascii=False, indent=2), + json.dumps(request_projection.request_record, ensure_ascii=False, indent=2), "", "Incoming envelope:", json.dumps(envelope.to_dict(), ensure_ascii=False, indent=2), From df4048ffb873967988b034398025b38354fda805 Mon Sep 17 00:00:00 2001 From: rica-v3 Date: Mon, 27 Jul 2026 15:29:27 +0900 Subject: [PATCH 2/2] configuration_guide.md: document prompt context compaction --- docs/configuration_guide.md | 76 +++++++++++++++++++++++++++++++++++++ docs/implementation.md | 3 ++ docs/operations_guide.md | 8 ++-- docs/specification.md | 19 ++++++++++ docs/telemetry.md | 14 +++++++ 5 files changed, 117 insertions(+), 3 deletions(-) diff --git a/docs/configuration_guide.md b/docs/configuration_guide.md index 8d8218d..fc3735a 100644 --- a/docs/configuration_guide.md +++ b/docs/configuration_guide.md @@ -238,6 +238,82 @@ Public service runtimes still use role-name identities such as `planner`, so the Many `backlog_id` and `request_id` values may exist while one configured sprint session scope remains active. +### `prompt_context` + +Controls how much persisted request event history is copied into model prompts: + +```yaml +prompt_context: + enabled: true + recent_events: 8 + max_events: 16 +``` + +Defaults: + +- `enabled: true` +- `recent_events: 8` +- `max_events: 16` + +Both event limits must be positive integers, and `max_events` must be greater than or equal to `recent_events`. Role services load these values at startup, so restart them after changing this section. There is no CLI mutation command for this policy. + +Compaction changes only the prompt projection. The canonical request JSON under `.teams_runtime/requests/` retains every event, and the current request metadata, result, artifacts, and selected event payloads are not truncated or summarized. + +#### Compaction And Backfill + +**Compaction** is executed only when `enabled` is `true` and the request contains more than `max_events` events. The runtime: + +1. Includes the last `recent_events` entries by list position, regardless of event shape. +2. Treats roles represented in that recent tail as already covered. +3. Scans older events from newest to oldest. +4. Backfills the newest evidence for each role not already represented, stopping at `max_events`. +5. Restores the selected events to their original chronological order. + +**Backfill** means using remaining capacity to retain older role evidence that would otherwise disappear behind the recent-event boundary. An older event qualifies as role evidence when either `type` or legacy `event_type` is `role_report`, or when its `payload` contains non-empty `role` and `status` fields. The role identity comes from `payload.role`, falling back to the event `actor`. + +Backfill is not a summary, database repair, or write to history. It copies complete existing event objects into the prompt. Repeated reports for one role do not consume multiple backfill slots; the newest qualifying older report wins. If `max_events` equals `recent_events`, no backfill capacity exists and the projection is recent-only. + +#### Worked Example + +With `recent_events: 4` and `max_events: 7`, suppose the persisted request has this abbreviated event list: + +```json +[ + {"timestamp": "T01", "type": "created", "actor": "orchestrator"}, + {"timestamp": "T02", "type": "role_report", "payload": {"role": "research", "status": "completed"}}, + {"timestamp": "T03", "type": "delegated", "actor": "orchestrator"}, + {"timestamp": "T04", "type": "role_report", "payload": {"role": "planner", "status": "completed", "summary": "initial plan"}}, + {"timestamp": "T05", "type": "role_report", "payload": {"role": "designer", "status": "completed"}}, + {"timestamp": "T06", "type": "role_report", "payload": {"role": "planner", "status": "completed", "summary": "final plan"}}, + {"timestamp": "T07", "type": "retried", "actor": "orchestrator"}, + {"timestamp": "T08", "type": "role_report", "payload": {"role": "developer", "status": "completed"}}, + {"timestamp": "T09", "type": "role_report", "payload": {"role": "architect", "status": "completed"}}, + {"timestamp": "T10", "type": "delegated", "actor": "orchestrator"}, + {"timestamp": "T11", "type": "role_report", "payload": {"role": "qa", "status": "blocked"}}, + {"timestamp": "T12", "type": "resumed", "actor": "orchestrator"} +] +``` + +The recent tail is `T09` through `T12`, representing `architect` and `qa`. Three slots remain. Scanning backward selects `T08` for `developer`, `T06` for `planner`, and `T05` for `designer`. `T04` is skipped because the newer planner report already represents that role. Capacity is then full, so older `research` evidence at `T02` is not selected. + +The prompt receives: + +```json +[ + {"timestamp": "T05", "type": "role_report", "payload": {"role": "designer", "status": "completed"}}, + {"timestamp": "T06", "type": "role_report", "payload": {"role": "planner", "status": "completed", "summary": "final plan"}}, + {"timestamp": "T08", "type": "role_report", "payload": {"role": "developer", "status": "completed"}}, + {"timestamp": "T09", "type": "role_report", "payload": {"role": "architect", "status": "completed"}}, + {"timestamp": "T10", "type": "delegated", "actor": "orchestrator"}, + {"timestamp": "T11", "type": "role_report", "payload": {"role": "qa", "status": "blocked"}}, + {"timestamp": "T12", "type": "resumed", "actor": "orchestrator"} +] +``` + +The adjacent prompt notice reports `total_events: 12`, `included_events: 7`, `omitted_events: 5`, the selection policy, and the canonical request path. A role may open that canonical file when its current decision needs omitted evidence. + +For immediate rollback, set `enabled: false` and restart role services. This restores full event-history inclusion in prompts without changing persisted request data. + ## Changing `sprint.id` To rotate the configured sprint session scope: diff --git a/docs/implementation.md b/docs/implementation.md index 3ae5860..bf4fc7f 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -136,6 +136,8 @@ Current ownership notes: - canonical shared contracts: role/session config dataclasses plus typed request/backlog/sprint/workflow/result shapes - `teams_runtime/shared/config.py` - canonical runtime and Discord config loading, validation, placeholder-ID guardrails, and runtime config mutation helpers +- `teams_runtime/shared/prompt_context.py` + - canonical pure request-event projection, role-evidence backfill selection, and compacted-history prompt notice rendering - `teams_runtime/shared/paths.py` - canonical `RuntimePaths` workspace, runtime state, log, role, shared workspace, sprint artifact, and archive path contract - `teams_runtime/shared/persistence.py` @@ -275,6 +277,7 @@ Current ownership notes: - Shared contracts now belong in `shared/models.py`. - Canonical report/progress formatting helpers, backlog item construction, backlog markdown rendering, and current-sprint markdown rendering belong in `shared/formatting.py`; `core/reports.py` remains compatibility-only and `core/sprints.py` re-exports moved formatting helpers. - Canonical runtime and Discord config loading/updating belongs in `shared/config.py`; `core/config.py` remains compatibility-only. +- Canonical model-facing request event-history projection and backfill selection belongs in `shared/prompt_context.py`; request persistence remains unchanged. - Canonical workspace/runtime path helpers belong in `shared/paths.py`; `core/paths.py` remains compatibility-only. - Canonical shared JSON/JSONL persistence, ID/fingerprint generation, and KST timestamp helpers belong in `shared/persistence.py`; `core/persistence.py` remains compatibility-only. - Canonical request/backlog/sprint/goal file IO, event helpers, planner-review request predicates/lookups/record assembly, internal sprint request predicates/iteration, blocked-backlog review candidate normalization/rendering, non-actionable backlog classification/drop/repair, backlog status/blocker/todo-state helpers, sprint selected-backlog view derivation, backlog kind/acceptance normalization, goal lifecycle reports, and backlog status-report context helpers belong in `workflows/state/request_store.py`, `workflows/state/backlog_store.py`, `workflows/state/sprint_store.py`, and `workflows/state/goal_store.py`; `core/*_store.py` remains compatibility-only. diff --git a/docs/operations_guide.md b/docs/operations_guide.md index 042e616..94b4813 100644 --- a/docs/operations_guide.md +++ b/docs/operations_guide.md @@ -279,17 +279,19 @@ params: {"_teams_kind":"delegate"} ### 2. Persisted request record -The target role also receives the full persisted request record through its prompt context. +The target role receives a prompt projection of the persisted request record. Request metadata, current status, reply route, artifacts, and the most recent role result remain available. Event history is included in full while it is within the configured limit; longer histories use the bounded `prompt_context` compaction policy. That record contains: - request metadata - current status - reply route -- event history +- full or compacted event history - most recent role result -So later roles can see earlier role output even though the relay message itself stays small. +Compaction always keeps the configured recent tail, then uses remaining capacity to backfill the newest older evidence for roles missing from that tail. Here, backfill means selecting complete historical event objects; it does not summarize or modify them. An adjacent notice gives the total, included, and omitted counts plus the canonical `.teams_runtime/requests/.json` path. The persisted file remains complete and can be inspected when omitted evidence is required. + +This keeps normal later-role prompts bounded while preserving a path to the complete audit history. See [`configuration_guide.md`](./configuration_guide.md#prompt_context) for the exact algorithm and worked input/output example. ## Request Examples diff --git a/docs/specification.md b/docs/specification.md index f610af6..5101a7c 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -151,8 +151,27 @@ Supported sections: - `mentions` - `allowed_guild_ids` - `role_defaults` +- `prompt_context` + - `enabled` + - `recent_events` + - `max_events` +- `telemetry` + - `enabled` + - `rate_cards` - `actions` +### Prompt event-history projection + +- persisted request records remain the canonical, complete audit history +- model-facing normal role, role-result repair, research-decision, and version-controller prompts share one event projection policy +- when enabled and event count exceeds `max_events`, the projection always includes the final `recent_events` entries +- remaining capacity is backfilled by scanning older events newest-first and selecting the newest evidence for each role not represented in the recent tail +- qualifying evidence is a `role_report` in `type` or `event_type`, or an event whose payload contains non-empty `role` and `status`; identity uses `payload.role` then `actor` +- selected events retain their complete payload and original chronological order +- prompt compaction never mutates the persisted request record +- compacted prompts include counts, policy name, and canonical request path so omitted history remains discoverable +- disabling `prompt_context` restores full event-history prompt inclusion after role services restart + ## Backlog And Sprint Model Normal change and enhancement requests are backlog-first. diff --git a/docs/telemetry.md b/docs/telemetry.md index c8b93d9..1422280 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -447,6 +447,20 @@ Use one representative sprint or at least 24 hours of normal traffic before chan Do not compare only total role cost. Normalize by request count, todo count, and logical-call count so a frequently used inexpensive role is not confused with an inefficient role. +### Measuring Prompt Compaction + +Use comparable requests with long event histories before and after enabling `prompt_context`. Keep the provider, model, reasoning level, request shape, and workflow path stable. Compare: + +- `prompt_chars` and native input tokens per logical call +- cached-input tokens and cached-input ratio +- total input tokens per completed request or todo +- contract-repair and retry counts +- failures, QA outcomes, and p95 latency + +The expected result is lower prompt size and input-token usage in later workflow stages without a higher repair, retry, failure, or reopen rate. Session reuse can change cached-input behavior independently, so do not attribute every cached-token change to compaction. Telemetry stores prompt size and usage totals, not prompt content or the selected event list; use the content-free `prompt_context_compacted` runtime log for total/included/omitted counts. + +For a controlled rollback comparison, set `prompt_context.enabled: false`, restart the same role services, and repeat the same request shape. The canonical persisted event history is identical in either mode. + ## Operational Validation After restarting role services and completing a model-backed request: