mycode-sdk 0.7.6__tar.gz → 0.8.1__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.
- {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/PKG-INFO +1 -1
- {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/pyproject.toml +1 -1
- {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/src/mycode/agent.py +34 -52
- mycode_sdk-0.8.1/src/mycode/compact.py +120 -0
- {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/src/mycode/providers/anthropic_like.py +4 -4
- {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/src/mycode/providers/openai_chat.py +16 -34
- {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/src/mycode/providers/openai_responses.py +1 -1
- {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/src/mycode/session.py +8 -138
- {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/src/mycode/utils.py +4 -6
- {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/.gitignore +0 -0
- {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/LICENSE +0 -0
- {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/README.md +0 -0
- {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/src/mycode/__init__.py +0 -0
- {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/src/mycode/hooks.py +0 -0
- {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/src/mycode/messages.py +0 -0
- {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/src/mycode/models.py +0 -0
- {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/src/mycode/models_catalog.json +0 -0
- {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/src/mycode/providers/__init__.py +0 -0
- {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/src/mycode/providers/base.py +0 -0
- {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/src/mycode/providers/gemini.py +0 -0
- {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/src/mycode/py.typed +0 -0
- {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/src/mycode/tools.py +0 -0
|
@@ -18,6 +18,13 @@ from pathlib import Path
|
|
|
18
18
|
from typing import Any, cast
|
|
19
19
|
from uuid import uuid4
|
|
20
20
|
|
|
21
|
+
from mycode.compact import (
|
|
22
|
+
COMPACT_SUMMARY_PROMPT,
|
|
23
|
+
DEFAULT_COMPACT_THRESHOLD,
|
|
24
|
+
apply_compact_replay,
|
|
25
|
+
build_compact_event,
|
|
26
|
+
should_compact,
|
|
27
|
+
)
|
|
21
28
|
from mycode.hooks import Hooks, ToolHookContext
|
|
22
29
|
from mycode.messages import (
|
|
23
30
|
ConversationMessage,
|
|
@@ -29,14 +36,7 @@ from mycode.messages import (
|
|
|
29
36
|
from mycode.models import infer_provider_from_model, resolve_model_metadata
|
|
30
37
|
from mycode.providers import get_provider_adapter
|
|
31
38
|
from mycode.providers.base import ProviderAdapter, ProviderRequest, ProviderStreamEvent
|
|
32
|
-
from mycode.session import
|
|
33
|
-
COMPACT_SUMMARY_PROMPT,
|
|
34
|
-
DEFAULT_COMPACT_THRESHOLD,
|
|
35
|
-
SessionStore,
|
|
36
|
-
apply_compact,
|
|
37
|
-
build_compact_event,
|
|
38
|
-
should_compact,
|
|
39
|
-
)
|
|
39
|
+
from mycode.session import SessionStore
|
|
40
40
|
from mycode.tools import ToolContext, ToolExecutionResult, ToolExecutor, ToolSpec
|
|
41
41
|
|
|
42
42
|
logger = logging.getLogger(__name__)
|
|
@@ -104,6 +104,7 @@ class Agent:
|
|
|
104
104
|
self.session_dir = session_dir
|
|
105
105
|
self.session_id = (session_id or "").strip() or uuid4().hex
|
|
106
106
|
self._store: SessionStore | None = SessionStore(data_dir=session_dir) if session_dir is not None else None
|
|
107
|
+
self.transcript_path: str | None = str(self._store.messages_path(self.session_id)) if self._store else None
|
|
107
108
|
|
|
108
109
|
self.api_key = api_key
|
|
109
110
|
self.api_base = api_base
|
|
@@ -451,32 +452,29 @@ class Agent:
|
|
|
451
452
|
await self._store.append_message(self.session_id, message)
|
|
452
453
|
|
|
453
454
|
self._cancel_event.clear()
|
|
454
|
-
supports_image_input = self.supports_image_input
|
|
455
|
-
supports_pdf_input = self.supports_pdf_input
|
|
456
|
-
self.tool_ctx.supports_image_input = supports_image_input
|
|
457
455
|
|
|
458
456
|
if isinstance(user_input, str):
|
|
459
|
-
user_message = user_text_message(user_input)
|
|
457
|
+
user_message: ConversationMessage = user_text_message(user_input)
|
|
460
458
|
else:
|
|
461
|
-
|
|
462
|
-
"
|
|
459
|
+
if (user_input.get("role") or "user") != "user":
|
|
460
|
+
yield Event("error", {"message": "user input must be a user message"})
|
|
461
|
+
return
|
|
462
|
+
user_message = {
|
|
463
|
+
"role": "user",
|
|
463
464
|
"content": [dict(b) for b in user_input.get("content") or [] if isinstance(b, dict)],
|
|
464
465
|
}
|
|
465
466
|
raw_meta = user_input.get("meta")
|
|
466
467
|
if isinstance(raw_meta, dict):
|
|
467
468
|
user_message["meta"] = {str(k): v for k, v in raw_meta.items()}
|
|
468
469
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
if not supports_image_input and any(
|
|
474
|
-
isinstance(block, dict) and block.get("type") == "image" for block in user_message.get("content") or []
|
|
470
|
+
content_blocks = user_message.get("content") or []
|
|
471
|
+
if not self.supports_image_input and any(
|
|
472
|
+
isinstance(block, dict) and block.get("type") == "image" for block in content_blocks
|
|
475
473
|
):
|
|
476
474
|
yield Event("error", {"message": "current model does not support image input"})
|
|
477
475
|
return
|
|
478
|
-
if not supports_pdf_input and any(
|
|
479
|
-
isinstance(block, dict) and block.get("type") == "document" for block in
|
|
476
|
+
if not self.supports_pdf_input and any(
|
|
477
|
+
isinstance(block, dict) and block.get("type") == "document" for block in content_blocks
|
|
480
478
|
):
|
|
481
479
|
yield Event("error", {"message": "current model does not support PDF input"})
|
|
482
480
|
return
|
|
@@ -500,15 +498,15 @@ class Agent:
|
|
|
500
498
|
provider=self.provider,
|
|
501
499
|
model=self.model,
|
|
502
500
|
session_id=self.session_id,
|
|
503
|
-
messages=self.messages,
|
|
501
|
+
messages=apply_compact_replay(self.messages, transcript_path=self.transcript_path),
|
|
504
502
|
system=self.system,
|
|
505
503
|
tools=self.tools.definitions,
|
|
506
504
|
max_tokens=self.max_tokens,
|
|
507
505
|
api_key=self.api_key,
|
|
508
506
|
api_base=self.api_base,
|
|
509
507
|
reasoning_effort=self.reasoning_effort,
|
|
510
|
-
supports_image_input=supports_image_input,
|
|
511
|
-
supports_pdf_input=supports_pdf_input,
|
|
508
|
+
supports_image_input=self.supports_image_input,
|
|
509
|
+
supports_pdf_input=self.supports_pdf_input,
|
|
512
510
|
)
|
|
513
511
|
|
|
514
512
|
try:
|
|
@@ -631,11 +629,14 @@ class Agent:
|
|
|
631
629
|
return
|
|
632
630
|
if should_compact(total_tokens, self.context_window, self.compact_threshold):
|
|
633
631
|
try:
|
|
634
|
-
|
|
635
|
-
|
|
632
|
+
await self._compact(adapter, persist)
|
|
633
|
+
yield Event("compact", {})
|
|
636
634
|
except asyncio.CancelledError:
|
|
637
|
-
|
|
635
|
+
yield Event("error", {"message": "cancelled"})
|
|
636
|
+
return
|
|
638
637
|
except Exception:
|
|
638
|
+
# Best-effort: transient failures retry next threshold check;
|
|
639
|
+
# persistent ones surface from phase 1 of the next turn.
|
|
639
640
|
logger.warning(
|
|
640
641
|
"Context compaction failed, continuing without compaction",
|
|
641
642
|
exc_info=True,
|
|
@@ -684,15 +685,12 @@ class Agent:
|
|
|
684
685
|
self,
|
|
685
686
|
adapter: ProviderAdapter,
|
|
686
687
|
persist: PersistCallback,
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
) -> AsyncIterator[Event]:
|
|
690
|
-
"""Generate a conversation summary and replace in-memory messages."""
|
|
691
|
-
|
|
692
|
-
compacted_count = len(self.messages)
|
|
688
|
+
) -> None:
|
|
689
|
+
"""Ask the provider for a summary, persist the compact event, append it."""
|
|
693
690
|
|
|
694
|
-
|
|
695
|
-
|
|
691
|
+
compact_messages = apply_compact_replay(self.messages, transcript_path=self.transcript_path) + [
|
|
692
|
+
user_text_message(COMPACT_SUMMARY_PROMPT)
|
|
693
|
+
]
|
|
696
694
|
request = ProviderRequest(
|
|
697
695
|
provider=self.provider,
|
|
698
696
|
model=self.model,
|
|
@@ -726,24 +724,8 @@ class Agent:
|
|
|
726
724
|
summary_text,
|
|
727
725
|
provider=self.provider,
|
|
728
726
|
model=self.model,
|
|
729
|
-
compacted_count=compacted_count,
|
|
730
727
|
total_tokens=summary_total_tokens,
|
|
731
728
|
)
|
|
732
729
|
|
|
733
|
-
# Persist the compact event (append-only — original messages stay in JSONL).
|
|
734
730
|
await persist(compact_event)
|
|
735
|
-
|
|
736
731
|
self.messages.append(compact_event)
|
|
737
|
-
self.messages = apply_compact(
|
|
738
|
-
self.messages,
|
|
739
|
-
transcript_path=str(self._store.messages_path(self.session_id)) if self._store else None,
|
|
740
|
-
continue_now=continue_now,
|
|
741
|
-
)
|
|
742
|
-
|
|
743
|
-
yield Event(
|
|
744
|
-
"compact",
|
|
745
|
-
{
|
|
746
|
-
"message": f"Context compacted ({compacted_count} messages → summary)",
|
|
747
|
-
"compacted_count": compacted_count,
|
|
748
|
-
},
|
|
749
|
-
)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Context compaction: summarize past history when nearing the context window."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from mycode.messages import ConversationMessage, build_message, text_block
|
|
8
|
+
|
|
9
|
+
DEFAULT_COMPACT_THRESHOLD = 0.8
|
|
10
|
+
|
|
11
|
+
COMPACT_SUMMARY_PROMPT = """\
|
|
12
|
+
Summarize this conversation to create a continuation document. \
|
|
13
|
+
This summary will replace the full conversation history, so it must \
|
|
14
|
+
capture everything needed to continue the work seamlessly.
|
|
15
|
+
|
|
16
|
+
Include:
|
|
17
|
+
|
|
18
|
+
1. **Task and Intent**: Describe the user's overall goal — what is being \
|
|
19
|
+
built, fixed, or investigated, and why.
|
|
20
|
+
2. **Decisions and Constraints**: List the decisions made, constraints \
|
|
21
|
+
discovered, and approaches chosen or rejected, with the reasoning behind \
|
|
22
|
+
each.
|
|
23
|
+
3. **User Requests**: Every distinct request or instruction the user gave, \
|
|
24
|
+
in chronological order. Preserve the user's original wording for ambiguous \
|
|
25
|
+
or nuanced requests.
|
|
26
|
+
4. **Files and Changes**: Enumerate every file read, modified, or created \
|
|
27
|
+
— paths, what changed, and any code snippets the next turn will need to \
|
|
28
|
+
reason about, quoted verbatim.
|
|
29
|
+
5. **Errors and Fixes**: List errors encountered with the original message \
|
|
30
|
+
verbatim, the cause if known, and the resolution — or that it remains open.
|
|
31
|
+
6. **Current State**: What is verified working, what is known broken, what \
|
|
32
|
+
is in progress.
|
|
33
|
+
7. **Next Step**: The next step to take, with a direct quote from the most \
|
|
34
|
+
recent conversation showing where the work left off.
|
|
35
|
+
|
|
36
|
+
Rules:
|
|
37
|
+
- Be specific: reproduce file paths, function names, error messages, and \
|
|
38
|
+
other identifiers verbatim — never paraphrase them.
|
|
39
|
+
- Do not add suggestions or opinions — only summarize what happened.
|
|
40
|
+
- Keep it concise but complete.\
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
CONTINUATION_HEADER = "This session is being continued from a previous conversation that was compacted to fit the context window. The summary below covers the earlier portion of the conversation."
|
|
44
|
+
|
|
45
|
+
TRANSCRIPT_HINT = "For verbatim details not captured in this summary (exact code snippets, error messages, or earlier output), read the original conversation log at: {path}"
|
|
46
|
+
|
|
47
|
+
CONTINUATION_FOOTER = 'Resume directly from where the work left off. Do not acknowledge this summary, do not recap, and do not preface with "I\'ll continue" or similar.'
|
|
48
|
+
|
|
49
|
+
COMPACT_ACK = "Acknowledged."
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def should_compact(
|
|
53
|
+
last_total_tokens: int | None,
|
|
54
|
+
context_window: int | None,
|
|
55
|
+
threshold: float,
|
|
56
|
+
) -> bool:
|
|
57
|
+
"""True when ``last_total_tokens`` ≥ ``context_window × threshold``.
|
|
58
|
+
|
|
59
|
+
The ``(1 - threshold)`` headroom is reserved for the compact LLM call
|
|
60
|
+
itself (see docs/sessions.md).
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
if not last_total_tokens or not context_window or threshold <= 0:
|
|
64
|
+
return False
|
|
65
|
+
return last_total_tokens >= context_window * threshold
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_compact_event(
|
|
69
|
+
summary_text: str,
|
|
70
|
+
*,
|
|
71
|
+
provider: str,
|
|
72
|
+
model: str,
|
|
73
|
+
total_tokens: int | None = None,
|
|
74
|
+
) -> ConversationMessage:
|
|
75
|
+
meta: dict[str, Any] = {"provider": provider, "model": model}
|
|
76
|
+
if total_tokens is not None:
|
|
77
|
+
meta["total_tokens"] = total_tokens
|
|
78
|
+
return build_message("compact", [text_block(summary_text)], meta=meta)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def apply_compact_replay(
|
|
82
|
+
messages: list[ConversationMessage],
|
|
83
|
+
*,
|
|
84
|
+
transcript_path: str | None = None,
|
|
85
|
+
) -> list[ConversationMessage]:
|
|
86
|
+
"""Replace pre-compact history with a summary continuation.
|
|
87
|
+
|
|
88
|
+
Returns ``messages`` unchanged when no compact event is present.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
last_compact = -1
|
|
92
|
+
for i, message in enumerate(messages):
|
|
93
|
+
if message.get("role") == "compact":
|
|
94
|
+
last_compact = i
|
|
95
|
+
|
|
96
|
+
if last_compact < 0:
|
|
97
|
+
return messages
|
|
98
|
+
|
|
99
|
+
summary_text = ""
|
|
100
|
+
for block in messages[last_compact].get("content") or []:
|
|
101
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
102
|
+
summary_text = str(block.get("text") or "")
|
|
103
|
+
break
|
|
104
|
+
|
|
105
|
+
tail = [m for m in messages[last_compact + 1 :] if m.get("role") != "compact"]
|
|
106
|
+
# No tail or assistant-led tail = mid-loop; resume directly. A user-led
|
|
107
|
+
# tail needs an "Acknowledged." assistant turn to keep role alternation.
|
|
108
|
+
continue_now = not tail or tail[0].get("role") == "assistant"
|
|
109
|
+
|
|
110
|
+
parts = [CONTINUATION_HEADER, summary_text]
|
|
111
|
+
if transcript_path:
|
|
112
|
+
parts.append(TRANSCRIPT_HINT.format(path=transcript_path))
|
|
113
|
+
if continue_now:
|
|
114
|
+
parts.append(CONTINUATION_FOOTER)
|
|
115
|
+
|
|
116
|
+
projected = [build_message("user", [text_block("\n\n".join(parts))])]
|
|
117
|
+
if not continue_now:
|
|
118
|
+
projected.append(build_message("assistant", [text_block(COMPACT_ACK)]))
|
|
119
|
+
projected.extend(tail)
|
|
120
|
+
return projected
|
|
@@ -214,11 +214,11 @@ class AnthropicLikeAdapter(ProviderAdapter):
|
|
|
214
214
|
)
|
|
215
215
|
continue
|
|
216
216
|
|
|
217
|
-
|
|
217
|
+
message_native_meta: dict[str, Any] = {}
|
|
218
218
|
if stop_sequence := getattr(message, "stop_sequence", None):
|
|
219
|
-
|
|
219
|
+
message_native_meta["stop_sequence"] = stop_sequence
|
|
220
220
|
if service_tier := getattr(message, "service_tier", None):
|
|
221
|
-
|
|
221
|
+
message_native_meta["service_tier"] = service_tier
|
|
222
222
|
|
|
223
223
|
# No `total_tokens` field — compute it from input + cache + output parts.
|
|
224
224
|
raw_usage = dump_model(getattr(message, "usage", None)) or {}
|
|
@@ -237,7 +237,7 @@ class AnthropicLikeAdapter(ProviderAdapter):
|
|
|
237
237
|
provider_message_id=getattr(message, "id", None),
|
|
238
238
|
stop_reason=getattr(message, "stop_reason", None),
|
|
239
239
|
total_tokens=total_tokens,
|
|
240
|
-
native_meta=
|
|
240
|
+
native_meta=message_native_meta,
|
|
241
241
|
)
|
|
242
242
|
|
|
243
243
|
def _serialize_tool(self, tool: dict[str, Any]) -> dict[str, Any]:
|
|
@@ -119,7 +119,7 @@ class OpenAIChatAdapter(ProviderAdapter):
|
|
|
119
119
|
state = tool_calls[index]
|
|
120
120
|
raw_arguments = state.arguments_text
|
|
121
121
|
parsed_arguments = parse_tool_arguments(raw_arguments)
|
|
122
|
-
if
|
|
122
|
+
if parsed_arguments is None:
|
|
123
123
|
tool_input = {}
|
|
124
124
|
meta = {"native": {"raw_arguments": raw_arguments}}
|
|
125
125
|
else:
|
|
@@ -301,41 +301,23 @@ class OpenAIChatAdapter(ProviderAdapter):
|
|
|
301
301
|
# We check both the delta root and model_extra to cover both patterns.
|
|
302
302
|
# Known fields: reasoning, reasoning_content, reasoning_details.
|
|
303
303
|
for source in (delta, getattr(delta, "model_extra", None) or {}):
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
reasoning = getattr(source, "reasoning", None)
|
|
314
|
-
has_reasoning_content = hasattr(source, "reasoning_content")
|
|
315
|
-
reasoning_content = getattr(source, "reasoning_content", None)
|
|
316
|
-
has_reasoning_details = hasattr(source, "reasoning_details")
|
|
317
|
-
reasoning_details = getattr(source, "reasoning_details", None)
|
|
318
|
-
|
|
319
|
-
if has_reasoning:
|
|
320
|
-
return (
|
|
321
|
-
reasoning if isinstance(reasoning, str) else "",
|
|
322
|
-
{"reasoning_field": "reasoning"},
|
|
323
|
-
)
|
|
304
|
+
for field in ("reasoning", "reasoning_content", "reasoning_details"):
|
|
305
|
+
if isinstance(source, dict):
|
|
306
|
+
if field not in source:
|
|
307
|
+
continue
|
|
308
|
+
value = source[field]
|
|
309
|
+
elif hasattr(source, field):
|
|
310
|
+
value = getattr(source, field, None)
|
|
311
|
+
else:
|
|
312
|
+
continue
|
|
324
313
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
314
|
+
if field == "reasoning_details":
|
|
315
|
+
if not isinstance(value, list):
|
|
316
|
+
continue
|
|
317
|
+
text = "".join(str(item.get("text") or "") for item in value if isinstance(item, dict))
|
|
318
|
+
return text, {"reasoning_field": "reasoning_details", "reasoning_details": value}
|
|
330
319
|
|
|
331
|
-
|
|
332
|
-
reasoning_text = "".join(
|
|
333
|
-
str(item.get("text") or "") for item in reasoning_details if isinstance(item, dict)
|
|
334
|
-
)
|
|
335
|
-
return reasoning_text, {
|
|
336
|
-
"reasoning_field": "reasoning_details",
|
|
337
|
-
"reasoning_details": reasoning_details,
|
|
338
|
-
}
|
|
320
|
+
return (value if isinstance(value, str) else "", {"reasoning_field": field})
|
|
339
321
|
|
|
340
322
|
return "", {}
|
|
341
323
|
|
|
@@ -339,7 +339,7 @@ class OpenAIResponsesAdapter(ProviderAdapter):
|
|
|
339
339
|
if item_type == "function_call":
|
|
340
340
|
raw_arguments = getattr(item, "arguments", "") or ""
|
|
341
341
|
parsed_arguments = parse_tool_arguments(raw_arguments)
|
|
342
|
-
if
|
|
342
|
+
if parsed_arguments is None:
|
|
343
343
|
tool_input = {}
|
|
344
344
|
raw_args_entry: dict[str, Any] = {"raw_arguments": raw_arguments}
|
|
345
345
|
else:
|
|
@@ -16,61 +16,20 @@ import shutil
|
|
|
16
16
|
from dataclasses import asdict, dataclass
|
|
17
17
|
from datetime import UTC, datetime
|
|
18
18
|
from pathlib import Path
|
|
19
|
-
from typing import
|
|
19
|
+
from typing import TypedDict, cast
|
|
20
20
|
|
|
21
|
-
from mycode.messages import ConversationMessage,
|
|
21
|
+
from mycode.messages import ConversationMessage, flatten_message_text
|
|
22
22
|
|
|
23
23
|
# ---------------------------------------------------------------------
|
|
24
|
-
# Session format
|
|
24
|
+
# Session format defaults
|
|
25
25
|
# ---------------------------------------------------------------------
|
|
26
26
|
|
|
27
|
-
MESSAGE_FORMAT_VERSION =
|
|
28
|
-
DEFAULT_COMPACT_THRESHOLD = 0.8
|
|
27
|
+
MESSAGE_FORMAT_VERSION = 7
|
|
29
28
|
DEFAULT_SESSION_TITLE = "New chat"
|
|
30
29
|
|
|
31
|
-
COMPACT_SUMMARY_PROMPT = """\
|
|
32
|
-
Summarize this conversation to create a continuation document. \
|
|
33
|
-
This summary will replace the full conversation history, so it must \
|
|
34
|
-
capture everything needed to continue the work seamlessly.
|
|
35
|
-
|
|
36
|
-
Include:
|
|
37
|
-
|
|
38
|
-
1. **Task and Intent**: Describe the user's overall goal — what is being \
|
|
39
|
-
built, fixed, or investigated, and why.
|
|
40
|
-
2. **Decisions and Constraints**: List the decisions made, constraints \
|
|
41
|
-
discovered, and approaches chosen or rejected, with the reasoning behind \
|
|
42
|
-
each.
|
|
43
|
-
3. **User Requests**: Every distinct request or instruction the user gave, \
|
|
44
|
-
in chronological order. Preserve the user's original wording for ambiguous \
|
|
45
|
-
or nuanced requests.
|
|
46
|
-
4. **Files and Changes**: Enumerate every file read, modified, or created \
|
|
47
|
-
— paths, what changed, and any code snippets the next turn will need to \
|
|
48
|
-
reason about, quoted verbatim.
|
|
49
|
-
5. **Errors and Fixes**: List errors encountered with the original message \
|
|
50
|
-
verbatim, the cause if known, and the resolution — or that it remains open.
|
|
51
|
-
6. **Current State**: What is verified working, what is known broken, what \
|
|
52
|
-
is in progress.
|
|
53
|
-
7. **Next Step**: The next step to take, with a direct quote from the most \
|
|
54
|
-
recent conversation showing where the work left off.
|
|
55
|
-
|
|
56
|
-
Rules:
|
|
57
|
-
- Be specific: reproduce file paths, function names, error messages, and \
|
|
58
|
-
other identifiers verbatim — never paraphrase them.
|
|
59
|
-
- Do not add suggestions or opinions — only summarize what happened.
|
|
60
|
-
- Keep it concise but complete.\
|
|
61
|
-
"""
|
|
62
|
-
|
|
63
|
-
_CONTINUATION_HEADER = "This session is being continued from a previous conversation that was compacted to fit the context window. The summary below covers the earlier portion of the conversation."
|
|
64
|
-
|
|
65
|
-
_TRANSCRIPT_HINT = "For verbatim details not captured in this summary (exact code snippets, error messages, or earlier output), read the original conversation log at: {path}"
|
|
66
|
-
|
|
67
|
-
_CONTINUATION_FOOTER = 'Resume directly from where the work left off. Do not acknowledge this summary, do not recap, and do not preface with "I\'ll continue" or similar.'
|
|
68
|
-
|
|
69
|
-
_COMPACT_ACK = "Acknowledged."
|
|
70
|
-
|
|
71
30
|
|
|
72
31
|
# ---------------------------------------------------------------------
|
|
73
|
-
#
|
|
32
|
+
# Rewind session events
|
|
74
33
|
# ---------------------------------------------------------------------
|
|
75
34
|
|
|
76
35
|
|
|
@@ -78,90 +37,6 @@ def _now() -> str:
|
|
|
78
37
|
return datetime.now(UTC).isoformat()
|
|
79
38
|
|
|
80
39
|
|
|
81
|
-
def should_compact(
|
|
82
|
-
last_total_tokens: int | None,
|
|
83
|
-
context_window: int | None,
|
|
84
|
-
threshold: float,
|
|
85
|
-
) -> bool:
|
|
86
|
-
"""True when the latest call's `total_tokens` ≥ `context_window × threshold`.
|
|
87
|
-
|
|
88
|
-
`total_tokens` already covers the next API call's prompt floor, so it is
|
|
89
|
-
the right input here. The `(1 - threshold)` headroom is reserved for the
|
|
90
|
-
compact LLM call itself (see docs/sessions.md).
|
|
91
|
-
"""
|
|
92
|
-
|
|
93
|
-
if not last_total_tokens or not context_window or threshold <= 0:
|
|
94
|
-
return False
|
|
95
|
-
return last_total_tokens >= context_window * threshold
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def build_compact_event(
|
|
99
|
-
summary_text: str,
|
|
100
|
-
*,
|
|
101
|
-
provider: str,
|
|
102
|
-
model: str,
|
|
103
|
-
compacted_count: int,
|
|
104
|
-
total_tokens: int | None = None,
|
|
105
|
-
) -> ConversationMessage:
|
|
106
|
-
"""Build the compact event stored in session JSONL."""
|
|
107
|
-
|
|
108
|
-
meta: dict[str, Any] = {
|
|
109
|
-
"provider": provider,
|
|
110
|
-
"model": model,
|
|
111
|
-
"compacted_count": compacted_count,
|
|
112
|
-
}
|
|
113
|
-
if total_tokens is not None:
|
|
114
|
-
meta["total_tokens"] = total_tokens
|
|
115
|
-
return build_message("compact", [text_block(summary_text)], meta=meta)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def apply_compact(
|
|
119
|
-
messages: list[ConversationMessage],
|
|
120
|
-
*,
|
|
121
|
-
transcript_path: str | None = None,
|
|
122
|
-
continue_now: bool | None = None,
|
|
123
|
-
) -> list[ConversationMessage]:
|
|
124
|
-
"""Replace the latest compact event with a synthetic summary view.
|
|
125
|
-
|
|
126
|
-
``continue_now`` omits the ack and leaves a user instruction last so the
|
|
127
|
-
agent loop can immediately request the next assistant response.
|
|
128
|
-
"""
|
|
129
|
-
|
|
130
|
-
# Only the newest compact event matters. Older history before it is no
|
|
131
|
-
# longer visible once the summary replaces that earlier conversation.
|
|
132
|
-
last_compact_index: int | None = None
|
|
133
|
-
for index, message in enumerate(messages):
|
|
134
|
-
if message.get("role") == "compact":
|
|
135
|
-
last_compact_index = index
|
|
136
|
-
|
|
137
|
-
if last_compact_index is None:
|
|
138
|
-
return messages
|
|
139
|
-
|
|
140
|
-
summary_text = ""
|
|
141
|
-
for block in messages[last_compact_index].get("content") or []:
|
|
142
|
-
if isinstance(block, dict) and block.get("type") == "text":
|
|
143
|
-
summary_text = str(block.get("text") or "")
|
|
144
|
-
break
|
|
145
|
-
|
|
146
|
-
tail = messages[last_compact_index + 1 :]
|
|
147
|
-
if continue_now is None:
|
|
148
|
-
# During live tool-loop compaction the next persisted message is the
|
|
149
|
-
# assistant continuation. Waiting compaction has no tail yet.
|
|
150
|
-
continue_now = bool(tail and tail[0].get("role") == "assistant")
|
|
151
|
-
|
|
152
|
-
parts = [_CONTINUATION_HEADER, summary_text]
|
|
153
|
-
if transcript_path:
|
|
154
|
-
parts.append(_TRANSCRIPT_HINT.format(path=transcript_path))
|
|
155
|
-
if continue_now:
|
|
156
|
-
parts.append(_CONTINUATION_FOOTER)
|
|
157
|
-
|
|
158
|
-
result = [build_message("user", [text_block("\n\n".join(parts))], meta={"synthetic": True})]
|
|
159
|
-
if not continue_now:
|
|
160
|
-
result.append(build_message("assistant", [text_block(_COMPACT_ACK)], meta={"synthetic": True}))
|
|
161
|
-
result.extend(tail)
|
|
162
|
-
return result
|
|
163
|
-
|
|
164
|
-
|
|
165
40
|
def build_rewind_event(rewind_to: int) -> ConversationMessage:
|
|
166
41
|
"""Build a rewind marker to append to session JSONL."""
|
|
167
42
|
|
|
@@ -341,16 +216,11 @@ class SessionStore:
|
|
|
341
216
|
except FileNotFoundError:
|
|
342
217
|
pass
|
|
343
218
|
|
|
344
|
-
#
|
|
345
|
-
#
|
|
346
|
-
# 2) rewind truncates that visible list by message index
|
|
219
|
+
# Visible state = raw JSONL minus rewound tails. `compact` markers
|
|
220
|
+
# stay inline; the agent substitutes them when calling the provider.
|
|
347
221
|
# Orphan tool_use blocks (e.g. left open by a server crash) are
|
|
348
222
|
# closed by the provider adapter at replay time, not here.
|
|
349
|
-
visible_messages =
|
|
350
|
-
raw_messages,
|
|
351
|
-
transcript_path=str(self.messages_path(session_id)),
|
|
352
|
-
)
|
|
353
|
-
visible_messages = apply_rewind(visible_messages)
|
|
223
|
+
visible_messages = apply_rewind(raw_messages)
|
|
354
224
|
|
|
355
225
|
return {"session": self._summary(session_id, meta), "messages": visible_messages}
|
|
356
226
|
|
|
@@ -21,10 +21,10 @@ def omit_none(d: dict[str, Any]) -> dict[str, Any]:
|
|
|
21
21
|
return {k: v for k, v in d.items() if v is not None}
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
def parse_tool_arguments(raw: str | None) -> dict[str, Any] |
|
|
24
|
+
def parse_tool_arguments(raw: str | None) -> dict[str, Any] | None:
|
|
25
25
|
"""Parse a JSON tool-arguments string.
|
|
26
26
|
|
|
27
|
-
Returns the parsed dict, or
|
|
27
|
+
Returns the parsed dict, or None when the input is missing/invalid/non-object.
|
|
28
28
|
Empty / None input is treated as an empty argument set.
|
|
29
29
|
"""
|
|
30
30
|
if not raw or not raw.strip():
|
|
@@ -32,7 +32,5 @@ def parse_tool_arguments(raw: str | None) -> dict[str, Any] | str:
|
|
|
32
32
|
try:
|
|
33
33
|
parsed = json.loads(raw)
|
|
34
34
|
except json.JSONDecodeError:
|
|
35
|
-
return
|
|
36
|
-
if
|
|
37
|
-
return "error: tool arguments must decode to a JSON object"
|
|
38
|
-
return parsed
|
|
35
|
+
return None
|
|
36
|
+
return parsed if isinstance(parsed, dict) else None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|