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.
Files changed (22) hide show
  1. {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/PKG-INFO +1 -1
  2. {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/pyproject.toml +1 -1
  3. {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/src/mycode/agent.py +34 -52
  4. mycode_sdk-0.8.1/src/mycode/compact.py +120 -0
  5. {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/src/mycode/providers/anthropic_like.py +4 -4
  6. {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/src/mycode/providers/openai_chat.py +16 -34
  7. {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/src/mycode/providers/openai_responses.py +1 -1
  8. {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/src/mycode/session.py +8 -138
  9. {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/src/mycode/utils.py +4 -6
  10. {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/.gitignore +0 -0
  11. {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/LICENSE +0 -0
  12. {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/README.md +0 -0
  13. {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/src/mycode/__init__.py +0 -0
  14. {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/src/mycode/hooks.py +0 -0
  15. {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/src/mycode/messages.py +0 -0
  16. {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/src/mycode/models.py +0 -0
  17. {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/src/mycode/models_catalog.json +0 -0
  18. {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/src/mycode/providers/__init__.py +0 -0
  19. {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/src/mycode/providers/base.py +0 -0
  20. {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/src/mycode/providers/gemini.py +0 -0
  21. {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/src/mycode/py.typed +0 -0
  22. {mycode_sdk-0.7.6 → mycode_sdk-0.8.1}/src/mycode/tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mycode-sdk
3
- Version: 0.7.6
3
+ Version: 0.8.1
4
4
  Summary: Lightweight Python SDK for building AI agents.
5
5
  Project-URL: Homepage, https://github.com/legibet/mycode
6
6
  Project-URL: Repository, https://github.com/legibet/mycode
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "mycode-sdk"
7
- version = "0.7.6"
7
+ version = "0.8.1"
8
8
  description = "Lightweight Python SDK for building AI agents."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -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
- user_message: ConversationMessage = {
462
- "role": str(user_input.get("role") or "user"),
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
- if user_message.get("role") != "user":
470
- yield Event("error", {"message": "user input must be a user message"})
471
- return
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 user_message.get("content") or []
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
- async for event in self._compact(adapter, persist, continue_now=bool(tool_calls)):
635
- yield event
632
+ await self._compact(adapter, persist)
633
+ yield Event("compact", {})
636
634
  except asyncio.CancelledError:
637
- raise
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
- continue_now: bool,
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
- # Ask the same provider for a summary — no tools, just text generation.
695
- compact_messages = list(self.messages) + [user_text_message(COMPACT_SUMMARY_PROMPT)]
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
- native_meta: dict[str, Any] = {}
217
+ message_native_meta: dict[str, Any] = {}
218
218
  if stop_sequence := getattr(message, "stop_sequence", None):
219
- native_meta["stop_sequence"] = stop_sequence
219
+ message_native_meta["stop_sequence"] = stop_sequence
220
220
  if service_tier := getattr(message, "service_tier", None):
221
- native_meta["service_tier"] = service_tier
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=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 isinstance(parsed_arguments, str):
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
- if isinstance(source, dict):
305
- has_reasoning = "reasoning" in source
306
- reasoning = source.get("reasoning")
307
- has_reasoning_content = "reasoning_content" in source
308
- reasoning_content = source.get("reasoning_content")
309
- has_reasoning_details = "reasoning_details" in source
310
- reasoning_details = source.get("reasoning_details")
311
- else:
312
- has_reasoning = hasattr(source, "reasoning")
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
- if has_reasoning_content:
326
- return (
327
- reasoning_content if isinstance(reasoning_content, str) else "",
328
- {"reasoning_field": "reasoning_content"},
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
- if has_reasoning_details and isinstance(reasoning_details, list):
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 isinstance(parsed_arguments, str):
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 Any, TypedDict, cast
19
+ from typing import TypedDict, cast
20
20
 
21
- from mycode.messages import ConversationMessage, build_message, flatten_message_text, text_block
21
+ from mycode.messages import ConversationMessage, flatten_message_text
22
22
 
23
23
  # ---------------------------------------------------------------------
24
- # Session format and compacting defaults
24
+ # Session format defaults
25
25
  # ---------------------------------------------------------------------
26
26
 
27
- MESSAGE_FORMAT_VERSION = 6
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
- # Compact and rewind session events
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
- # Replay order defines the visible conversation state.
345
- # 1) compact rewrites older history into one summary view
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 = apply_compact(
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] | str:
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 an error string if the input is invalid.
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 "error: invalid JSON arguments"
36
- if not isinstance(parsed, dict):
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