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.
- {alpiecode-8.0.6 → alpiecode-8.0.8}/PKG-INFO +1 -1
- {alpiecode-8.0.6 → alpiecode-8.0.8}/pyproject.toml +1 -1
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/alpiecode.egg-info/PKG-INFO +1 -1
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/alpiecode.egg-info/SOURCES.txt +2 -1
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/__init__.py +1 -2
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/agent.py +74 -5
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/cli.py +0 -49
- alpiecode-8.0.8/src/codeagent/compaction.py +311 -0
- alpiecode-8.0.8/src/codeagent/context.py +369 -0
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/discovery.py +91 -0
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/executor.py +76 -59
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/extension/alpiecode.vsix +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/guardrails.py +66 -0
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/orchestrator.py +61 -21
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/progress.py +10 -2
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/prompt.py +9 -0
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/rephraser.py +13 -1
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/session.py +1 -0
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/tools.py +1 -1
- alpiecode-8.0.8/test/test_phase1.py +205 -0
- alpiecode-8.0.6/src/codeagent/compaction.py +0 -167
- alpiecode-8.0.6/src/codeagent/context.py +0 -106
- {alpiecode-8.0.6 → alpiecode-8.0.8}/README.md +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.8}/setup.cfg +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/alpiecode/__init__.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/alpiecode.egg-info/dependency_links.txt +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/alpiecode.egg-info/entry_points.txt +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/alpiecode.egg-info/requires.txt +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/alpiecode.egg-info/top_level.txt +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/backends/__init__.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/backends/base.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/backends/local_backend.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/backends/openai_backend.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/cache.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/client.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/config.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/doctor.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/git_ops.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/github.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/guardian.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/ipython_ext.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/local_model.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/media.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/memory.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/server.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/updater.py +0 -0
- {alpiecode-8.0.6 → alpiecode-8.0.8}/src/codeagent/vscode_installer.py +0 -0
|
@@ -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.
|
|
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 == "
|
|
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
|
-
|
|
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
|
-
|
|
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 == "
|
|
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
|
-
|
|
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
|