Conversation
…on (#1607) Add `AnchoredSummary` typed schema replacing free-form prose compaction summaries when `[memory] structured_summaries = true` (off by default). - New `AnchoredSummary` struct (5 fields: session_intent, files_modified, decisions_made, open_questions, next_steps) with JSON serialization, Markdown rendering, and per-field length validation - `try_summarize_structured()` on `Agent` calls `chat_typed_erased` for structured output; falls back to prose on any failure or invalid fields - Chunked path: per-chunk summaries stay prose, final consolidation uses structured output (IMP-01) - `summarize_messages_with_budget()` also uses structured path (IMP-02) - `is_complete()` requires only `session_intent` + `next_steps` (IMP-03) - `validate()` enforces per-field length limits: session_intent <= 2000 chars, Vec entries <= 500 chars, Vec length <= 50 entries - `cap_summary()` applied to all structured return sites (matches prose path) - Debug dump writes `{N}_anchored-summary.json` with section completeness - `AnchoredSummary` parse attempt on summary load for legacy detection
There was a problem hiding this comment.
Pull request overview
Implements opt-in “structured anchored summarization” for hard context compaction by introducing a typed AnchoredSummary schema and wiring it through config, agent state/builder, compaction logic, and debug dumps.
Changes:
- Add
zeph_memory::AnchoredSummary(serde + JsonSchema) with completeness checks, Markdown rendering, and output validation. - Add a
[memory] structured_summariesconfig flag and plumb it through runner → agent builder → agent state. - Update compaction to attempt
chat_typed_erased::<AnchoredSummary>()(single-pass + chunked consolidation) with prose fallback, plus debug dumping of structured results.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/runner.rs | Wires structured_summaries from config into the agent builder. |
| crates/zeph-memory/src/lib.rs | Exposes the new anchored summary module and re-exports AnchoredSummary. |
| crates/zeph-memory/src/anchored_summary.rs | Defines the schema, completeness/validation helpers, Markdown rendering, and unit tests. |
| crates/zeph-core/src/debug_dump/mod.rs | Adds JSON debug dump output for structured summaries with completeness metrics. |
| crates/zeph-core/src/agent/state/mod.rs | Adds structured_summaries flag to MemoryState. |
| crates/zeph-core/src/agent/mod.rs | Initializes structured_summaries default to false. |
| crates/zeph-core/src/agent/context/tests.rs | Updates helper state builder to include the new flag. |
| crates/zeph-core/src/agent/context/summarization.rs | Implements structured prompt + chat_typed_erased path, fallback behavior, and structured debug dumping. |
| crates/zeph-core/src/agent/builder.rs | Adds with_structured_summaries() builder method. |
| crates/zeph-core/config/default.toml | Documents the new config option (commented, off by default). |
| crates/zeph-config/src/root.rs | Sets default config value for structured_summaries. |
| crates/zeph-config/src/memory.rs | Adds structured_summaries field to MemoryConfig with serde default + docs. |
| crates/zeph-config/config/default.toml | Documents the new config option (commented, off by default). |
| CHANGELOG.md | Documents the feature additions for memory/core/config/debug dump. |
| /// Serialize to JSON for storage in `summaries.content`. | ||
| /// | ||
| /// # Panics | ||
| /// | ||
| /// Panics if serialization fails. Since all fields are `String`/`Vec<String>`, | ||
| /// serialization is infallible in practice. | ||
| #[must_use] | ||
| pub fn to_json(&self) -> String { | ||
| serde_json::to_string(self).expect("AnchoredSummary serialization is infallible") | ||
| } |
There was a problem hiding this comment.
AnchoredSummary::to_json() uses expect(...), which can panic in non-test code. The project review policy discourages unwrap/expect outside tests; this should return a Result (or otherwise propagate/handle the serde_json::Error) so callers can fall back gracefully instead of crashing.
| Ok(Ok(anchored)) if anchored.is_complete() => { | ||
| if let Some(ref d) = self.debug_state.debug_dumper { | ||
| d.dump_anchored_summary(&anchored, false, &self.metrics.token_counter); | ||
| } | ||
| return Ok(super::cap_summary(anchored.to_markdown(), 16_000)); | ||
| } | ||
| Ok(Ok(anchored)) => { | ||
| tracing::warn!( | ||
| "chunked consolidation: structured summary incomplete, falling back to prose" | ||
| ); | ||
| if let Some(ref d) = self.debug_state.debug_dumper { | ||
| d.dump_anchored_summary(&anchored, true, &self.metrics.token_counter); |
There was a problem hiding this comment.
The structured consolidation path for chunked compaction returns anchored.to_markdown() when anchored.is_complete(), but it never runs AnchoredSummary::validate(). This bypasses the length/entry caps intended to prevent bloated or adversarial LLM output (and can also create very large debug dump payloads). Run validate() (and treat validation failure the same as other structured failures) before dumping/returning the anchored summary.
| Ok(Ok(anchored)) if anchored.is_complete() => { | |
| if let Some(ref d) = self.debug_state.debug_dumper { | |
| d.dump_anchored_summary(&anchored, false, &self.metrics.token_counter); | |
| } | |
| return Ok(super::cap_summary(anchored.to_markdown(), 16_000)); | |
| } | |
| Ok(Ok(anchored)) => { | |
| tracing::warn!( | |
| "chunked consolidation: structured summary incomplete, falling back to prose" | |
| ); | |
| if let Some(ref d) = self.debug_state.debug_dumper { | |
| d.dump_anchored_summary(&anchored, true, &self.metrics.token_counter); | |
| Ok(Ok(anchored)) => { | |
| // Always validate before dumping or returning structured summaries. | |
| match anchored.validate() { | |
| Err(e) => { | |
| tracing::warn!( | |
| error = %e, | |
| "chunked consolidation: structured summary failed validation, falling back to prose" | |
| ); | |
| if let Some(ref d) = self.debug_state.debug_dumper { | |
| d.dump_anchored_summary( | |
| &anchored, | |
| true, | |
| &self.metrics.token_counter, | |
| ); | |
| } | |
| } | |
| Ok(()) if anchored.is_complete() => { | |
| if let Some(ref d) = self.debug_state.debug_dumper { | |
| d.dump_anchored_summary( | |
| &anchored, | |
| false, | |
| &self.metrics.token_counter, | |
| ); | |
| } | |
| return Ok(super::cap_summary(anchored.to_markdown(), 16_000)); | |
| } | |
| Ok(()) => { | |
| tracing::warn!( | |
| "chunked consolidation: structured summary incomplete, falling back to prose" | |
| ); | |
| if let Some(ref d) = self.debug_state.debug_dumper { | |
| d.dump_anchored_summary( | |
| &anchored, | |
| true, | |
| &self.metrics.token_counter, | |
| ); | |
| } | |
| } |
| format!( | ||
| "<analysis>\n\ | ||
| You are compacting a conversation into a structured summary for self-consumption.\n\ | ||
| This summary replaces the original messages in your context window.\n\ | ||
| Every field MUST be populated — empty fields mean lost information.\n\ | ||
| </analysis>\n\ | ||
| {guidelines_section}\n\ | ||
| Produce a JSON object with exactly these 5 fields:\n\ | ||
| - session_intent: string — what the user is trying to accomplish\n\ | ||
| - files_modified: string[] — file paths, function names, structs touched\n\ | ||
| - decisions_made: string[] — each entry: \"Decision: X — Reason: Y\"\n\ | ||
| - open_questions: string[] — unresolved questions or blockers\n\ | ||
| - next_steps: string[] — concrete next actions\n\ |
There was a problem hiding this comment.
build_anchored_summary_prompt() tells the LLM that “Every field MUST be populated — empty fields mean lost information.” But the implementation explicitly allows some sections to be empty (and empty can be the correct answer for pure discussion sessions). This instruction risks incentivizing hallucinated file paths/decisions just to avoid empty arrays. Adjust the prompt to allow empty arrays only when truly none exist (and explicitly instruct the model not to invent entries).
| // Structured path: attempt AnchoredSummary when enabled, fall back to prose on failure. | ||
| if self.memory_state.structured_summaries { | ||
| match self.try_summarize_structured(messages, guidelines).await { | ||
| Ok(anchored) => { | ||
| if let Some(ref d) = self.debug_state.debug_dumper { | ||
| d.dump_anchored_summary(&anchored, false, &self.metrics.token_counter); | ||
| } | ||
| return Ok(super::cap_summary(anchored.to_markdown(), 16_000)); | ||
| } |
There was a problem hiding this comment.
The structured path returns cap_summary(anchored.to_markdown(), 16_000), so the value persisted via store_session_summary(cid, &summary) remains Markdown/prose. This doesn’t match the PR description’s claim that structured summaries are stored as JSON in summaries.content for later per-section retrieval. If JSON storage is a requirement, the code needs to persist the serialized AnchoredSummary (and optionally also persist/render Markdown separately for context injection).
|
|
||
| - feat(memory): add `AnchoredSummary` struct with structured 5-section schema (session_intent, files_modified, decisions_made, open_questions, next_steps) for context compaction; replaces free-form prose when `[memory] structured_summaries = true` (issue #1607) | ||
| - feat(core): structured summarization path in context compaction — applies `chat_typed_erased::<AnchoredSummary>()` for both single-pass and chunked consolidation; falls back to prose on any LLM or validation failure (issue #1607) | ||
| - feat(core): `DebugDumper::dump_anchored_summary()` writes `{N}_anchored-summary.json` with section completeness metrics, total_items, token_estimate, and fallback flag when `--debug-dump` is active (issue #1607) |
There was a problem hiding this comment.
The changelog says debug dumps are written to {N}_anchored-summary.json, but the implementation uses "{id:04}-anchored-summary.json" (hyphen, zero-padded). Update the filename description to match the actual output so users can find the file reliably.
| - feat(core): `DebugDumper::dump_anchored_summary()` writes `{N}_anchored-summary.json` with section completeness metrics, total_items, token_estimate, and fallback flag when `--debug-dump` is active (issue #1607) | |
| - feat(core): `DebugDumper::dump_anchored_summary()` writes `{id:04}-anchored-summary.json` (e.g., `0001-anchored-summary.json`) with section completeness metrics, total_items, token_estimate, and fallback flag when `--debug-dump` is active (issue #1607) |
Summary
Implemented structured anchored summarization for context compression (research issue #1607). Replaces unstructured prose summaries with a typed
AnchoredSummaryschema containing 5 mandatory sections: session_intent, files_modified, decisions_made, open_questions, next_steps.Problem: During hard compaction, critical facts (file paths, decisions, next steps) silently disappear when the LLM summarizes to free-form prose without enforcing that specific categories are preserved.
Solution: Use
chat_typed_erased<AnchoredSummary>()to request structured JSON output matching a strict schema. Mandatory fields ensure the LLM cannot skip critical categories. Falls back gracefully to prose on any failure.Architecture & Design
AnchoredSummarywith JsonSchema derive for structured output viachat_typed_erasedsummaries.contentTEXT column — zero migrations, full backward compatibility[memory] structured_summaries = true/false(default: false)chat_typed_erasedcallsession_intentandnext_stepsare truly mandatory; others are soft expectationsImplementation Details
Files modified:
crates/zeph-memory/src/anchored_summary.rs(new) — schema + validationcrates/zeph-memory/src/lib.rs— module exportcrates/zeph-config/src/memory.rs— config fieldcrates/zeph-core/src/agent/state/mod.rs— MemoryState fieldcrates/zeph-core/src/agent/builder.rs— wiringcrates/zeph-core/src/agent/context/summarization.rs— structured logic + both pathscrates/zeph-core/config/default.toml— default configcrates/zeph-config/config/default.toml— default configcrates/zeph-core/src/debug_dump/mod.rs— structured summary JSON dumpsCHANGELOG.md— documented changesValidation & Testing
Architect Phase
Critic Phase (Mandatory)
is_complete()relaxed to only require session_intent + next_steps ✓ ImplementedDeveloper Phase
Validators Phase (Parallel)
compact_context_with_budget()focus_pinned extraction noted (will file separate issue)Code Review Phase (Mandatory)
cap_summary()added to all 3 structured return sites (resolves SEC-SS-02 asymmetry)validate()method added with per-field length limits (resolves SEC-SS-01 validation)Security
SEC-SS-01 (Field validation): Fixed via
validate()methodsession_intent≤ 2000 charsSEC-SS-02 (Context injection): Fixed via
cap_summary(output, 16_000)SQL: All inserts are parameterized (no injection risk)
JSON: Deserialization safe, errors trigger prose fallback
Fallback: Graceful degradation to prose on any error
No interaction with sensitive security features (redaction/sanitization are in different code paths)
Performance
Backward Compatibility
structured_summaries = false)serde_json::from_str::<AnchoredSummary>()fallback to prose if not validKnown Deferred Items (Acceptable for MVP)
compact_context_with_budget()focus_pinned extraction (will file separate issue)Next Steps
compact_context_with_budget()focus_pinned bugTest Results
Test coverage:
Closes
Closes #1607
Reviewers & Contributors