alpiecode 8.0.6__tar.gz → 8.0.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. {alpiecode-8.0.6 → alpiecode-8.0.8}/PKG-INFO +1 -1
  2. {alpiecode-8.0.6 → alpiecode-8.0.8}/pyproject.toml +1 -1
  3. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/alpiecode.egg-info/PKG-INFO +1 -1
  4. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/alpiecode.egg-info/SOURCES.txt +2 -1
  5. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/__init__.py +1 -2
  6. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/agent.py +74 -5
  7. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/cli.py +0 -49
  8. alpiecode-8.0.8/src/codeagent/compaction.py +311 -0
  9. alpiecode-8.0.8/src/codeagent/context.py +369 -0
  10. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/discovery.py +91 -0
  11. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/executor.py +76 -59
  12. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/extension/alpiecode.vsix +0 -0
  13. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/guardrails.py +66 -0
  14. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/orchestrator.py +61 -21
  15. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/progress.py +10 -2
  16. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/prompt.py +9 -0
  17. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/rephraser.py +13 -1
  18. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/session.py +1 -0
  19. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/tools.py +1 -1
  20. alpiecode-8.0.8/test/test_phase1.py +205 -0
  21. alpiecode-8.0.6/src/codeagent/compaction.py +0 -167
  22. alpiecode-8.0.6/src/codeagent/context.py +0 -106
  23. {alpiecode-8.0.6 → alpiecode-8.0.8}/README.md +0 -0
  24. {alpiecode-8.0.6 → alpiecode-8.0.8}/setup.cfg +0 -0
  25. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/alpiecode/__init__.py +0 -0
  26. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/alpiecode.egg-info/dependency_links.txt +0 -0
  27. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/alpiecode.egg-info/entry_points.txt +0 -0
  28. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/alpiecode.egg-info/requires.txt +0 -0
  29. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/alpiecode.egg-info/top_level.txt +0 -0
  30. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/backends/__init__.py +0 -0
  31. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/backends/base.py +0 -0
  32. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/backends/local_backend.py +0 -0
  33. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/backends/openai_backend.py +0 -0
  34. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/cache.py +0 -0
  35. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/client.py +0 -0
  36. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/config.py +0 -0
  37. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/doctor.py +0 -0
  38. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/git_ops.py +0 -0
  39. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/github.py +0 -0
  40. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/guardian.py +0 -0
  41. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/ipython_ext.py +0 -0
  42. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/local_model.py +0 -0
  43. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/media.py +0 -0
  44. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/memory.py +0 -0
  45. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/server.py +0 -0
  46. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/updater.py +0 -0
  47. {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/vscode_installer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alpiecode
3
- Version: 8.0.6
3
+ Version: 8.0.8
4
4
  Summary: AlpieCode — Autonomous AI Coding Agent CLI powered by local 169Pi GGUF VLM
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: huggingface_hub>=0.20.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "alpiecode"
7
- version = "8.0.6"
7
+ version = "8.0.8"
8
8
  description = "AlpieCode — Autonomous AI Coding Agent CLI powered by local 169Pi GGUF VLM"
9
9
  requires-python = ">=3.9"
10
10
  dependencies = [
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alpiecode
3
- Version: 8.0.6
3
+ Version: 8.0.8
4
4
  Summary: AlpieCode — Autonomous AI Coding Agent CLI powered by local 169Pi GGUF VLM
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: huggingface_hub>=0.20.0
@@ -39,4 +39,5 @@ src/codeagent/backends/__init__.py
39
39
  src/codeagent/backends/base.py
40
40
  src/codeagent/backends/local_backend.py
41
41
  src/codeagent/backends/openai_backend.py
42
- src/codeagent/extension/alpiecode.vsix
42
+ src/codeagent/extension/alpiecode.vsix
43
+ test/test_phase1.py
@@ -16,8 +16,7 @@ Supports:
16
16
  from pathlib import Path
17
17
  from typing import Optional, Any, Dict
18
18
 
19
- __version__ = "8.0.4"
20
-
19
+ __version__ = "8.0.8"
21
20
 
22
21
  def run(task: str, workdir: str = ".", reasoning_level: str = "thinking", **kwargs):
23
22
  """Run an autonomous coding task programmatically."""
@@ -11,6 +11,7 @@ Delegates agent orchestration to AgentOrchestrator and handles Rich terminal UI
11
11
 
12
12
  import json
13
13
  import subprocess
14
+ import sys
14
15
  from pathlib import Path
15
16
  from typing import Any, Dict, List, Optional
16
17
 
@@ -249,9 +250,43 @@ def run_agent(
249
250
 
250
251
  current_turn = 0
251
252
  last_discovery = {}
253
+ has_streamed_tokens = False
254
+ has_printed_anything = False
252
255
 
253
256
  for event in event_stream:
254
- if event.type == "discovery" and verbose:
257
+ if event.type == "token" and verbose:
258
+ delta = event.data.get("delta", "")
259
+ if delta:
260
+ if not has_streamed_tokens:
261
+ sys.stdout.write("\n")
262
+ has_streamed_tokens = True
263
+ sys.stdout.write(delta)
264
+ sys.stdout.flush()
265
+ has_printed_anything = True
266
+
267
+ elif event.type == "thinking_start" and verbose:
268
+ if debug:
269
+ if HAS_RICH:
270
+ console.print("\n[dim italic]Thinking...[/dim italic]", style="dim")
271
+ else:
272
+ sys.stdout.write("\nThinking...\n")
273
+ sys.stdout.flush()
274
+
275
+ elif event.type == "thinking_delta" and verbose and debug:
276
+ delta = event.data.get("delta", "")
277
+ if delta:
278
+ sys.stdout.write(delta)
279
+ sys.stdout.flush()
280
+
281
+ elif event.type == "thinking_end" and verbose and debug:
282
+ duration = event.data.get("duration", 0)
283
+ if HAS_RICH:
284
+ console.print(f" [dim]({duration}s)[/dim]\n")
285
+ else:
286
+ sys.stdout.write(f" ({duration}s)\n")
287
+ sys.stdout.flush()
288
+
289
+ elif event.type == "discovery" and verbose:
255
290
  last_discovery = event.data
256
291
  if debug and HAS_RICH:
257
292
  console.print(Panel(
@@ -335,7 +370,12 @@ def run_agent(
335
370
  _print_reasoning(event.data["content"])
336
371
 
337
372
  elif event.type == "tool_call" and verbose:
373
+ if has_streamed_tokens:
374
+ sys.stdout.write("\n")
375
+ sys.stdout.flush()
376
+ has_streamed_tokens = False
338
377
  _print_tool_call(event.data["turn"], event.data["name"], event.data["arguments"])
378
+ has_printed_anything = True
339
379
 
340
380
  elif event.type == "tool_result":
341
381
  if verbose:
@@ -343,7 +383,13 @@ def run_agent(
343
383
  _checkpoint(workdir, f"checkpoint: turn {event.data['turn']}")
344
384
 
345
385
  elif event.type == "message" and verbose:
346
- _print_assistant_message(event.data["content"])
386
+ if has_streamed_tokens:
387
+ sys.stdout.write("\n")
388
+ sys.stdout.flush()
389
+ has_streamed_tokens = False
390
+ else:
391
+ _print_assistant_message(event.data["content"])
392
+ has_printed_anything = True
347
393
  _checkpoint(workdir, "checkpoint: response")
348
394
 
349
395
  elif event.type == "fallback" and verbose:
@@ -393,7 +439,13 @@ def run_agent(
393
439
  print(f"\n🛑 Safety ceiling ({event.data['ceiling']}) reached.")
394
440
 
395
441
  elif event.type == "done":
396
- summary = event.data.get("summary", "")
442
+ if has_streamed_tokens:
443
+ sys.stdout.write("\n\n")
444
+ sys.stdout.flush()
445
+ has_streamed_tokens = False
446
+ elif not has_printed_anything and event.data.get("summary"):
447
+ _print_assistant_message(event.data["summary"])
448
+ has_printed_anything = True
397
449
  _checkpoint(workdir, "checkpoint: done")
398
450
  if debug and HAS_RICH:
399
451
  console.rule("[bold green]✅ Complete[/bold green]")
@@ -461,8 +513,16 @@ def run_chat(workdir: Path, cfg: Config, verbose: bool = True) -> None:
461
513
  console.print("Goodbye! 👋")
462
514
  break
463
515
 
516
+ has_streamed_chat = False
464
517
  for event in orchestrator.run_task(session, user_input, cfg):
465
- if event.type == "turn_start" and debug:
518
+ if event.type == "token":
519
+ delta = event.data.get("delta", "")
520
+ if delta:
521
+ sys.stdout.write(delta)
522
+ sys.stdout.flush()
523
+ has_streamed_chat = True
524
+
525
+ elif event.type == "turn_start" and debug:
466
526
  if HAS_RICH:
467
527
  console.print(f"[dim]── Step {event.data['turn']} ──[/dim]")
468
528
  else:
@@ -480,7 +540,11 @@ def run_chat(workdir: Path, cfg: Config, verbose: bool = True) -> None:
480
540
  _checkpoint(workdir, f"checkpoint: chat turn {event.data['turn']}")
481
541
 
482
542
  elif event.type == "message":
483
- _print_assistant_message(event.data["content"])
543
+ if has_streamed_chat:
544
+ sys.stdout.write("\n")
545
+ has_streamed_chat = False
546
+ else:
547
+ _print_assistant_message(event.data["content"])
484
548
  _checkpoint(workdir, "checkpoint: done")
485
549
 
486
550
  elif event.type == "error":
@@ -490,4 +554,9 @@ def run_chat(workdir: Path, cfg: Config, verbose: bool = True) -> None:
490
554
  print(f"❌ Model error: {event.data['error']}")
491
555
 
492
556
  elif event.type == "done":
557
+ if has_streamed_chat:
558
+ sys.stdout.write("\n")
559
+ has_streamed_chat = False
560
+ elif event.data.get("summary"):
561
+ _print_assistant_message(event.data["summary"])
493
562
  break
@@ -219,10 +219,6 @@ def main():
219
219
  undo_last_session(Path(args.workdir).resolve())
220
220
  return
221
221
 
222
- elif args.command == "doctor":
223
- from .doctor import run_doctor
224
- sys.exit(run_doctor())
225
-
226
222
  elif args.command == "explain":
227
223
  target = args.target
228
224
  target_path = Path(args.workdir) / target if not Path(target).is_absolute() else Path(target)
@@ -254,51 +250,6 @@ def main():
254
250
  debug=getattr(args, "debug", False),
255
251
  )
256
252
 
257
- elif args.command == "diff":
258
- workdir = Path(args.workdir).resolve()
259
- # Show git diff since the first checkpoint
260
- result = subprocess.run(
261
- ["git", "log", "--oneline", "--all"],
262
- cwd=workdir, capture_output=True, text=True
263
- )
264
- if result.returncode != 0:
265
- print("Not a git repository or no commits found.")
266
- return
267
-
268
- # Find the start checkpoint
269
- log_lines = result.stdout.strip().splitlines()
270
- start_sha = None
271
- for line in reversed(log_lines):
272
- if "checkpoint: start" in line:
273
- start_sha = line.split()[0]
274
- break
275
-
276
- if not start_sha:
277
- print("No AlpieCode checkpoint found. Run a task first.")
278
- return
279
-
280
- diff_result = subprocess.run(
281
- ["git", "diff", start_sha, "HEAD", "--stat"],
282
- cwd=workdir, capture_output=True, text=True
283
- )
284
- print(f"Changes since AlpieCode started (from {start_sha}):\n")
285
- print(diff_result.stdout)
286
-
287
- # Also show the full diff
288
- full_diff = subprocess.run(
289
- ["git", "diff", start_sha, "HEAD"],
290
- cwd=workdir, capture_output=True, text=True
291
- )
292
- if full_diff.stdout:
293
- try:
294
- from rich.console import Console
295
- from rich.syntax import Syntax
296
- console = Console()
297
- syntax = Syntax(full_diff.stdout, "diff", theme="monokai")
298
- console.print(syntax)
299
- except ImportError:
300
- print(full_diff.stdout)
301
-
302
253
 
303
254
  if __name__ == "__main__":
304
255
  main()
@@ -0,0 +1,311 @@
1
+ """
2
+ Context compaction for AlpieCode.
3
+
4
+ When the conversation history approaches the model's context window limit,
5
+ this module summarizes older turns to free up space while preserving
6
+ the essential information needed for the agent to continue working.
7
+
8
+ Strategy:
9
+ - Keep system prompt and last N turns intact
10
+ - Summarize older tool calls and results into compact descriptions
11
+ - Preserve all user messages verbatim
12
+ - Track approximate token count using a simple heuristic (4 chars ≈ 1 token)
13
+ - Generate structured conversation summaries and extract relevant history
14
+ for the decoupled ContextManager build_context() pipeline.
15
+ """
16
+
17
+ import json
18
+ from typing import Any, Dict, List, Optional
19
+
20
+ # Our model's context window
21
+ MAX_CONTEXT_TOKENS = 262_144
22
+ # Start compacting when we hit this percentage of the context window
23
+ COMPACT_THRESHOLD = 0.70
24
+ # Number of recent turns to always keep intact
25
+ KEEP_RECENT_TURNS = 12
26
+ # Approximate chars per token (rough heuristic)
27
+ CHARS_PER_TOKEN = 4
28
+
29
+
30
+ def estimate_tokens(messages: List[dict]) -> int:
31
+ """Estimate token count from a list of messages."""
32
+ total_chars = 0
33
+ for msg in messages:
34
+ if isinstance(msg, dict):
35
+ content = msg.get("content") or ""
36
+ if isinstance(content, str):
37
+ total_chars += len(content)
38
+ elif isinstance(content, list):
39
+ total_chars += sum(len(str(c)) for c in content)
40
+ # Account for tool call arguments
41
+ tool_calls = msg.get("tool_calls", [])
42
+ if tool_calls:
43
+ for tc in tool_calls:
44
+ if isinstance(tc, dict):
45
+ fn = tc.get("function", {})
46
+ total_chars += len(str(fn.get("arguments", "")))
47
+ total_chars += len(str(fn.get("name", "")))
48
+ return total_chars // CHARS_PER_TOKEN
49
+
50
+
51
+ def needs_compaction(messages: List[dict], max_tokens: int = MAX_CONTEXT_TOKENS) -> bool:
52
+ """Check if the conversation needs compaction."""
53
+ tokens = estimate_tokens(messages)
54
+ return tokens > (max_tokens * COMPACT_THRESHOLD)
55
+
56
+
57
+ def _summarize_tool_result(tool_name: str, content: str) -> str:
58
+ """Create a compact summary of a tool result."""
59
+ # NEVER truncate the execution plan — it's critical architectural context
60
+ if tool_name == "update_plan":
61
+ return content
62
+
63
+ if len(content) <= 300:
64
+ return content
65
+
66
+ if tool_name == "bash":
67
+ try:
68
+ data = json.loads(content)
69
+ stdout = data.get("stdout", "")
70
+ stderr = data.get("stderr", "")
71
+ exit_code = data.get("exit_code", -1)
72
+ summary = f"exit_code={exit_code}"
73
+ if stdout:
74
+ summary += f", stdout({len(stdout)} chars): {stdout[:150]}..."
75
+ if stderr:
76
+ summary += f", stderr: {stderr[:100]}..."
77
+ return summary
78
+ except json.JSONDecodeError:
79
+ pass
80
+
81
+ if tool_name in ("read_file", "list_files"):
82
+ lines = content.splitlines()
83
+ if len(lines) > 20:
84
+ return "\n".join(lines[:10]) + f"\n... ({len(lines) - 20} lines omitted) ...\n" + "\n".join(lines[-10:])
85
+
86
+ # Generic truncation
87
+ return content[:250] + f"... ({len(content)} chars total)"
88
+
89
+
90
+ def extract_conversation_summary(
91
+ messages: List[dict],
92
+ metadata: Optional[List[dict]] = None,
93
+ ) -> str:
94
+ """
95
+ Extract a concise, structured markdown summary of historical turns.
96
+
97
+ Extracts:
98
+ - Initial user task / goal
99
+ - Key files created, edited, or inspected
100
+ - Commands executed and their success/failure
101
+ - Execution plan status
102
+ - Crucial findings or errors encountered
103
+ """
104
+ if not messages:
105
+ return ""
106
+
107
+ initial_task = ""
108
+ files_touched = set()
109
+ commands_run = []
110
+ latest_plan = ""
111
+ key_events = []
112
+
113
+ for i, msg in enumerate(messages):
114
+ role = msg.get("role", "")
115
+ content = msg.get("content") or ""
116
+
117
+ # Extract initial goal from first non-system user message
118
+ if role == "user" and not initial_task:
119
+ if isinstance(content, str) and content.strip():
120
+ lines = content.strip().splitlines()
121
+ initial_task = lines[0][:150]
122
+ if len(lines[0]) > 150:
123
+ initial_task += "..."
124
+
125
+ # Assistant tool calls
126
+ if role == "assistant" and msg.get("tool_calls"):
127
+ for tc in msg.get("tool_calls", []):
128
+ tc_dict = tc if isinstance(tc, dict) else {}
129
+ fn = tc_dict.get("function", {})
130
+ name = fn.get("name", "")
131
+ args_raw = fn.get("arguments", "{}")
132
+ try:
133
+ args = json.loads(args_raw) if isinstance(args_raw, str) else args_raw
134
+ except Exception:
135
+ args = {}
136
+
137
+ if name in ("write_file", "patch_file", "read_file"):
138
+ p = args.get("path") or args.get("file_path")
139
+ if p:
140
+ files_touched.add(f"{name}:{p}")
141
+ elif name == "bash":
142
+ cmd = args.get("command", "")
143
+ if cmd:
144
+ commands_run.append(cmd[:80])
145
+
146
+ # Tool outputs
147
+ if role == "tool":
148
+ content_str = str(content)
149
+ if "[Plan updated]" in content_str or "Plan Status:" in content_str or "## Implementation Plan" in content_str:
150
+ latest_plan = content_str[-500:] # Keep latest plan snapshot
151
+ elif "exit_code" in content_str and '"exit_code": 0' not in content_str:
152
+ # Capture error indication
153
+ key_events.append("Command execution returned an error (addressed in subsequent turns)")
154
+
155
+ summary_lines = []
156
+ if initial_task:
157
+ summary_lines.append(f"- **Initial Goal**: {initial_task}")
158
+ if files_touched:
159
+ files_preview = ", ".join(list(files_touched)[:6])
160
+ if len(files_touched) > 6:
161
+ files_preview += f" (+{len(files_touched) - 6} more)"
162
+ summary_lines.append(f"- **Files Touched**: {files_preview}")
163
+ if commands_run:
164
+ recent_cmds = ", ".join(commands_run[-4:])
165
+ summary_lines.append(f"- **Recent Commands Executed**: {recent_cmds}")
166
+ if latest_plan:
167
+ summary_lines.append(f"- **Execution Plan Snapshot**:\n {latest_plan.strip()}")
168
+ if key_events:
169
+ summary_lines.append(f"- **Notes**: {key_events[-1]}")
170
+
171
+ if not summary_lines:
172
+ return ""
173
+
174
+ return "### Conversation Progress Summary (Prior Turns):\n" + "\n".join(summary_lines)
175
+
176
+
177
+ def select_relevant_history(
178
+ messages: List[dict],
179
+ metadata: Optional[List[dict]] = None,
180
+ current_query: str = "",
181
+ token_budget: int = 2048,
182
+ ) -> str:
183
+ """
184
+ Select high-value historical anchors from distant history (e.g. plan updates,
185
+ key decisions, important command outcomes) formatted as concise contextual notes.
186
+ """
187
+ if not messages:
188
+ return ""
189
+
190
+ high_value_notes = []
191
+ seen_plans = set()
192
+
193
+ # Search backwards for high-importance messages
194
+ for i in range(len(messages) - 1, -1, -1):
195
+ msg = messages[i]
196
+ role = msg.get("role", "")
197
+ content = str(msg.get("content") or "")
198
+
199
+ # High priority: update_plan outputs
200
+ if role == "tool" and ("[Plan updated]" in content or "Plan Status:" in content):
201
+ if "plan" not in seen_plans:
202
+ seen_plans.add("plan")
203
+ lines = [l.strip() for l in content.splitlines() if l.strip() and not l.startswith("```")]
204
+ snippet = "\n".join(lines[:8])
205
+ high_value_notes.append(f"- **Active Plan Anchor**:\n{snippet}")
206
+
207
+ # High priority: stall advice or system corrections
208
+ if role == "user" and ("[STALL DETECTED]" in content or "[SYSTEM]" in content):
209
+ high_value_notes.append(f"- **System Intervention**: {content[:200]}")
210
+
211
+ # If we have enough context, stop
212
+ total_len = sum(len(n) for n in high_value_notes)
213
+ if (total_len // CHARS_PER_TOKEN) >= token_budget:
214
+ break
215
+
216
+ if not high_value_notes:
217
+ return ""
218
+
219
+ return "### Relevant Historical Anchors:\n" + "\n".join(reversed(high_value_notes))
220
+
221
+
222
+ def compact_messages(messages: List[dict]) -> List[dict]:
223
+ """
224
+ Compact a message list by summarizing older turns.
225
+
226
+ Preserves:
227
+ - System prompt (index 0)
228
+ - All user messages (verbatim)
229
+ - Last KEEP_RECENT_TURNS messages (verbatim)
230
+
231
+ Summarizes:
232
+ - Older tool results (truncated)
233
+ - Older assistant reasoning (removed)
234
+ """
235
+ if len(messages) <= KEEP_RECENT_TURNS + 2:
236
+ return messages
237
+
238
+ # Always keep system prompt
239
+ system = messages[0] if messages and messages[0].get("role") == "system" else None
240
+
241
+ # Split into old and recent
242
+ cutoff = len(messages) - KEEP_RECENT_TURNS
243
+
244
+ # ── Tool-Call Pairing Guard ──
245
+ # If cutoff lands on a 'tool' message, move it backwards to include
246
+ # the preceding 'assistant' message that owns the tool_call_id.
247
+ # This prevents OpenAI API 400 errors ("missing tool result for tool_call_id").
248
+ min_cutoff = 1 if system else 0
249
+ while cutoff > min_cutoff and messages[cutoff].get("role") == "tool":
250
+ cutoff -= 1
251
+
252
+ old_messages = messages[1:cutoff] if system else messages[:cutoff]
253
+ recent_messages = messages[cutoff:]
254
+
255
+ # Build a compacted summary of old messages
256
+ compacted_old = []
257
+
258
+ for msg in old_messages:
259
+ role = msg.get("role", "")
260
+
261
+ if role == "user":
262
+ # Keep user messages verbatim
263
+ compacted_old.append(msg)
264
+
265
+ elif role == "assistant":
266
+ # Compact assistant messages: keep tool calls but remove reasoning
267
+ compact_msg = {"role": "assistant"}
268
+ if msg.get("content"):
269
+ # Truncate long assistant content
270
+ content = msg["content"]
271
+ if len(content) > 200:
272
+ compact_msg["content"] = content[:200] + "..."
273
+ else:
274
+ compact_msg["content"] = content
275
+ else:
276
+ compact_msg["content"] = None
277
+
278
+ if msg.get("tool_calls"):
279
+ compact_msg["tool_calls"] = msg["tool_calls"]
280
+ compacted_old.append(compact_msg)
281
+
282
+ elif role == "tool":
283
+ # Summarize tool results
284
+ tool_call_id = msg.get("tool_call_id", "")
285
+ content = msg.get("content", "")
286
+
287
+ # Try to find the tool name from the preceding assistant message
288
+ tool_name = "unknown"
289
+ for prev in reversed(compacted_old):
290
+ if prev.get("tool_calls"):
291
+ for tc in prev["tool_calls"]:
292
+ tc_dict = tc if isinstance(tc, dict) else {}
293
+ if tc_dict.get("id") == tool_call_id:
294
+ tool_name = tc_dict.get("function", {}).get("name", "unknown")
295
+ break
296
+ break
297
+
298
+ compacted_old.append({
299
+ "role": "tool",
300
+ "tool_call_id": tool_call_id,
301
+ "content": _summarize_tool_result(tool_name, content),
302
+ })
303
+
304
+ # Rebuild message list
305
+ result = []
306
+ if system:
307
+ result.append(system)
308
+ result.extend(compacted_old)
309
+ result.extend(recent_messages)
310
+
311
+ return result