Skip to content

fix: harden context compaction pipeline — Markdown summaries, read-only action history, and non-executable output rejection - #3493

Merged
Dallas98 merged 9 commits into
developfrom
fix/compaction_format
Jul 27, 2026
Merged

fix: harden context compaction pipeline — Markdown summaries, read-only action history, and non-executable output rejection#3493
Dallas98 merged 9 commits into
developfrom
fix/compaction_format

Conversation

@JasonW404

@JasonW404 JasonW404 commented Jul 25, 2026

Copy link
Copy Markdown
Member

Problem

The context compaction pipeline had multiple issues that caused models to produce degraded or incorrect outputs after context compression:

1. JSON round-trip in compression pipeline

The compression pipeline was doing a pointless JSON round-trip:

Prompt asks JSON → LLM outputs JSON → format_summary_output() converts to Markdown
→ HistoryCompressor json.loads() back to dict → renderer converts back to text

This caused:

  • HistoryCompressor regression: json.loads(summary_text) always failed on Markdown output, causing history compression to silently degrade to mechanical truncation
  • _render_summary() flat format: Dict-typed summaries rendered as key: value lines without Markdown headings
  • _render_current_action() JSON injection: Compacted action items fell back to json.dumps(item.content), injecting raw JSON that models mimicked in final answers

2. Action history in assistant role caused mimicry

Compacted action history was rendered as assistant-role messages with Step N / Called tool format. Models treated these as response templates and reproduced the same structured format instead of generating real answers.

3. Non-executable outputs returned as final answers

When the model produced non-executable structured output (e.g. Step N / Called tool, Markdown action records, action/tool_calls JSON, fenced action JSON, incomplete <code> or <RUN> protocol), the agent loop treated them as valid final answers and terminated — instead of recognizing them as format errors and continuing the loop.

4. python_interpreter code loss

String-form tool_calls.arguments were not preserved during compaction. python_interpreter tool calls degraded to empty arguments, losing the actual code.

5. Missing compression schema sections

The compression schema lacked unresolved_issues and next_steps sections, losing important forward-looking context.

Fix

1. Eliminated JSON intermediate format

The LLM now outputs Markdown directly, and all consumers work with plain text.

Before (4-step conversion):

Prompt: "Output strict JSON" → LLM: JSON → format_summary_output(): Markdown
→ HistoryCompressor: json.loads() back to dict → renderer: back to text

After (1-step):

Prompt: "Output Markdown with ## headings" → LLM: Markdown → used directly

2. Markdown heading quality gate

Replaced the old json.loads() gate with a lightweight "##" in summary_text check that rejects plain text while accepting structured Markdown.

3. Read-only action history in user role

Compacted action history is now injected as a user-role message wrapped in <completed_action_history> tags. This makes it clearly read-only context that the model should not mimic or continue.

4. InvalidActionFormatError and non-executable output rejection

Added InvalidActionFormatError to the parser. The following non-executable outputs are now caught, recorded as step errors, and the agent loop continues instead of terminating:

Pattern Example
Step N / Called tool Step 3:\nCalled tool search...
Markdown action record ## Action\n- tool: search
action/tool_calls JSON {"action": "search", ...}
Fenced action JSON ```json\n{"action": ...}\n```
Incomplete <code> / <RUN> <code>print("hello") (no closing tag)

Natural language final answers remain fully compatible.

5. String tool_calls.arguments preservation

String-form tool_calls.arguments (e.g. python_interpreter code) are now preserved verbatim during compaction instead of being dropped to empty.

6. Expanded compression schema

Added two new sections:

New Field Description Complements
unresolved_issues Problems encountered but not yet resolved key_decisions (resolved findings)
next_steps Concrete planned actions and expected outcomes pending_items (blockers/waiting)

Full schema (7 sections):

# Compact Result of History

## Task Overview
## Completed Work
## Key Decisions
## Unresolved Issues
## Pending Items
## Next Steps
## Context to Preserve

Changes

File Change
sdk/.../context/config.py Prompts request Markdown; added unresolved_issues and next_steps to schema
sdk/.../context/llm_summary.py Prompt builds Markdown section descriptions; uses _strip_code_fences()
sdk/.../context/history_compression.py summary: str; "##" in summary_text quality gate; incremental passes Markdown directly
sdk/.../context/rendering.py _render_summary() handles legacy dict; _render_current_action() renders natural language; <completed_action_history> user-role format
sdk/.../context/budget.py format_summary_output() simplified to code-fence stripping
sdk/.../core_agent.py InvalidActionFormatError; non-executable output detection; step error recording + loop continuation
test/.../test_context_helper_contracts.py Updated assertions for new format
test/.../test_context_item.py Summary fixtures: dict → string
test/.../test_history_context_runtime.py Mock response updated with all 7 sections
test/.../test_core_agent.py Tests for non-executable output rejection and loop continuation
test/.../test_guardrail_checkpoints.py Full degradation chain tests

Testing

  • Context-related tests: 69 passed
  • Renderer + parser/core tests: 145 passed
  • Guardrail + agent loop tests: 20 passed
  • py_compile: passed
  • git diff --check: passed

New functional tests verify the full degradation chain:

  1. Model outputs Step N / Called tool on first attempt
  2. Parser raises InvalidActionFormatError
  3. Agent loop does not terminate
  4. Enters next step
  5. Returns real semantic answer
img_v3_02140_c0a5c47a-2886-4077-b970-92e57f0ccb6g

Copilot AI review requested due to automatic review settings July 25, 2026 06:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…o context

format_summary_output() was re-serializing parsed JSON back to JSON via
json.dumps, causing the compressed summary injected into conversation
context to be raw JSON. Models then mimicked JSON syntax in their final
answers.

Now the function converts JSON dicts to Markdown with section headings
(# Compact Result of History / ## Task Overview / ## Completed Work / etc.)
so the injected summary reads as natural language.

- String values render as paragraphs, list values as bullet items
- Unknown schema keys get title-cased headings
- Non-dict JSON and parse failures fall back to plain text
- All call sites (llm_summary, step_renderer, offline) benefit automatically
- Updated test to assert Markdown output
@JasonW404
JasonW404 force-pushed the fix/compaction_format branch from fef4c76 to dc97c5f Compare July 25, 2026 07:35
The compression pipeline was doing a pointless JSON round-trip:
  Prompt asks JSON → LLM outputs JSON → format_summary_output() converts
  to Markdown → HistoryCompressor json.loads() back to dict → renderer
  converts back to text

This caused three problems:
1. HistoryCompressor silently degraded to mechanical truncation because
   json.loads() failed on Markdown output (regression from prior fix)
2. _render_summary() rendered dict summaries as flat 'key: value' lines
   without Markdown headings
3. _render_current_action() fallback dumped compact content as raw JSON,
   causing models to mimic JSON syntax in final answers

Changes:
- config.py: prompts now request Markdown with '# Compact Result of
  History' / '## Section' headings instead of JSON
- llm_summary.py: prompt builds Markdown section descriptions instead of
  json.dumps(schema); uses _strip_code_fences() instead of
  format_summary_output()
- history_compression.py: HistorySummaryCandidate.summary is now str;
  removed json.loads() gate; incremental compression passes previous
  summary as plain Markdown text
- rendering.py: _render_summary() handles legacy dict data via
  _summary_dict_to_markdown(); _render_current_action() renders compact
  actions as structured natural language instead of json.dumps()
- budget.py: simplified format_summary_output() to code-fence stripping
  only (JSON conversion removed)
- Tests updated to match new string-based summary and natural language
  action rendering
The old json.loads() gate served double duty as a quality filter —
unstructured LLM output was rejected and the compressor fell back to
mechanical truncation. After removing the JSON intermediate format,
any non-empty text was accepted as a valid summary.

Replace with a lightweight check: the summary must contain at least
one '##' section heading, ensuring the LLM produced structured
Markdown output. Plain text responses (e.g. 'lossy fallback') are
rejected and trigger the safe fallback path.
@codecov

codecov Bot commented Jul 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.90722% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
sdk/nexent/core/agents/core_agent.py 90.00% 2 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

Expand the default summary schema with two new sections to produce
more complete compressed context:

- unresolved_issues: problems, errors, or questions encountered but
  not yet resolved
- next_steps: concrete planned next actions and their expected
  outcomes

These complement the existing pending_items (blockers/waiting) and
key_decisions (resolved findings) by capturing open problems and
forward-looking plans that the agent needs to continue effectively
after compression.

Updated _SUMMARY_FIELD_HEADINGS and test mock to match.
… _strip_code_fences

Cover previously uncovered paths to satisfy codecov/patch:
- _summary_dict_to_markdown: legacy dict-typed summary rendering
- _render_current_action: compact path with tool_calls (list and dict)
- _strip_code_fences: code fence stripping utility
- _summary_dict_to_markdown: list values, empty list, None, numeric, all-empty fallback
- _render_current_action: non-standard tool_calls (string), list with non-dict elements
Update the system prompt text to mention 'unresolved issues' and
'next steps' alongside the existing fields for consistency with the
expanded schema.
…cutable outputs

- Replace Step/Called tool assistant format with read-only <completed_action_history> in user role
- Preserve string tool_calls.arguments (python_interpreter code no longer degrades to empty)
- Add InvalidActionFormatError for non-executable model outputs
- Non-executable outputs (Step N/Called tool, Markdown action record, action/tool_calls JSON,
  fenced action JSON, incomplete <code>/<RUN> protocol) now record as step error and continue
  loop instead of returning as final answer
- Natural language final answers remain compatible
- Add tests covering full degradation chain: invalid format -> parser error -> loop continues -> real answer
@JasonW404 JasonW404 changed the title fix: convert compressed JSON summary to Markdown before injecting into context fix: harden context compaction pipeline — Markdown summaries, read-only action history, and non-executable output rejection Jul 27, 2026
@Dallas98
Dallas98 merged commit 18d55e1 into develop Jul 27, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants