mycode-sdk 0.8.0__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.8.0 → mycode_sdk-0.8.1}/PKG-INFO +1 -1
  2. {mycode_sdk-0.8.0 → mycode_sdk-0.8.1}/pyproject.toml +1 -1
  3. {mycode_sdk-0.8.0 → mycode_sdk-0.8.1}/src/mycode/agent.py +30 -75
  4. mycode_sdk-0.8.1/src/mycode/compact.py +120 -0
  5. {mycode_sdk-0.8.0 → mycode_sdk-0.8.1}/src/mycode/providers/anthropic_like.py +4 -4
  6. {mycode_sdk-0.8.0 → mycode_sdk-0.8.1}/src/mycode/providers/openai_chat.py +16 -34
  7. {mycode_sdk-0.8.0 → mycode_sdk-0.8.1}/src/mycode/providers/openai_responses.py +1 -1
  8. {mycode_sdk-0.8.0 → mycode_sdk-0.8.1}/src/mycode/session.py +4 -80
  9. {mycode_sdk-0.8.0 → mycode_sdk-0.8.1}/src/mycode/utils.py +4 -6
  10. {mycode_sdk-0.8.0 → mycode_sdk-0.8.1}/.gitignore +0 -0
  11. {mycode_sdk-0.8.0 → mycode_sdk-0.8.1}/LICENSE +0 -0
  12. {mycode_sdk-0.8.0 → mycode_sdk-0.8.1}/README.md +0 -0
  13. {mycode_sdk-0.8.0 → mycode_sdk-0.8.1}/src/mycode/__init__.py +0 -0
  14. {mycode_sdk-0.8.0 → mycode_sdk-0.8.1}/src/mycode/hooks.py +0 -0
  15. {mycode_sdk-0.8.0 → mycode_sdk-0.8.1}/src/mycode/messages.py +0 -0
  16. {mycode_sdk-0.8.0 → mycode_sdk-0.8.1}/src/mycode/models.py +0 -0
  17. {mycode_sdk-0.8.0 → mycode_sdk-0.8.1}/src/mycode/models_catalog.json +0 -0
  18. {mycode_sdk-0.8.0 → mycode_sdk-0.8.1}/src/mycode/providers/__init__.py +0 -0
  19. {mycode_sdk-0.8.0 → mycode_sdk-0.8.1}/src/mycode/providers/base.py +0 -0
  20. {mycode_sdk-0.8.0 → mycode_sdk-0.8.1}/src/mycode/providers/gemini.py +0 -0
  21. {mycode_sdk-0.8.0 → mycode_sdk-0.8.1}/src/mycode/py.typed +0 -0
  22. {mycode_sdk-0.8.0 → 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.8.0
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.8.0"
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,29 +18,25 @@ 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,
24
31
  build_message,
25
32
  flatten_message_text,
26
- text_block,
27
33
  tool_result_block,
28
34
  user_text_message,
29
35
  )
30
36
  from mycode.models import infer_provider_from_model, resolve_model_metadata
31
37
  from mycode.providers import get_provider_adapter
32
38
  from mycode.providers.base import ProviderAdapter, ProviderRequest, ProviderStreamEvent
33
- from mycode.session import (
34
- COMPACT_ACK,
35
- COMPACT_SUMMARY_PROMPT,
36
- CONTINUATION_FOOTER,
37
- CONTINUATION_HEADER,
38
- DEFAULT_COMPACT_THRESHOLD,
39
- TRANSCRIPT_HINT,
40
- SessionStore,
41
- build_compact_event,
42
- should_compact,
43
- )
39
+ from mycode.session import SessionStore
44
40
  from mycode.tools import ToolContext, ToolExecutionResult, ToolExecutor, ToolSpec
45
41
 
46
42
  logger = logging.getLogger(__name__)
@@ -108,6 +104,7 @@ class Agent:
108
104
  self.session_dir = session_dir
109
105
  self.session_id = (session_id or "").strip() or uuid4().hex
110
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
111
108
 
112
109
  self.api_key = api_key
113
110
  self.api_base = api_base
@@ -455,32 +452,29 @@ class Agent:
455
452
  await self._store.append_message(self.session_id, message)
456
453
 
457
454
  self._cancel_event.clear()
458
- supports_image_input = self.supports_image_input
459
- supports_pdf_input = self.supports_pdf_input
460
- self.tool_ctx.supports_image_input = supports_image_input
461
455
 
462
456
  if isinstance(user_input, str):
463
- user_message = user_text_message(user_input)
457
+ user_message: ConversationMessage = user_text_message(user_input)
464
458
  else:
465
- user_message: ConversationMessage = {
466
- "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",
467
464
  "content": [dict(b) for b in user_input.get("content") or [] if isinstance(b, dict)],
468
465
  }
469
466
  raw_meta = user_input.get("meta")
470
467
  if isinstance(raw_meta, dict):
471
468
  user_message["meta"] = {str(k): v for k, v in raw_meta.items()}
472
469
 
473
- if user_message.get("role") != "user":
474
- yield Event("error", {"message": "user input must be a user message"})
475
- return
476
-
477
- if not supports_image_input and any(
478
- 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
479
473
  ):
480
474
  yield Event("error", {"message": "current model does not support image input"})
481
475
  return
482
- if not supports_pdf_input and any(
483
- 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
484
478
  ):
485
479
  yield Event("error", {"message": "current model does not support PDF input"})
486
480
  return
@@ -504,15 +498,15 @@ class Agent:
504
498
  provider=self.provider,
505
499
  model=self.model,
506
500
  session_id=self.session_id,
507
- messages=self._project_for_provider(self.messages),
501
+ messages=apply_compact_replay(self.messages, transcript_path=self.transcript_path),
508
502
  system=self.system,
509
503
  tools=self.tools.definitions,
510
504
  max_tokens=self.max_tokens,
511
505
  api_key=self.api_key,
512
506
  api_base=self.api_base,
513
507
  reasoning_effort=self.reasoning_effort,
514
- supports_image_input=supports_image_input,
515
- supports_pdf_input=supports_pdf_input,
508
+ supports_image_input=self.supports_image_input,
509
+ supports_pdf_input=self.supports_pdf_input,
516
510
  )
517
511
 
518
512
  try:
@@ -635,8 +629,8 @@ class Agent:
635
629
  return
636
630
  if should_compact(total_tokens, self.context_window, self.compact_threshold):
637
631
  try:
638
- async for event in self._compact(adapter, persist):
639
- yield event
632
+ await self._compact(adapter, persist)
633
+ yield Event("compact", {})
640
634
  except asyncio.CancelledError:
641
635
  yield Event("error", {"message": "cancelled"})
642
636
  return
@@ -687,52 +681,16 @@ class Agent:
687
681
  # Context compaction
688
682
  # ------------------------------------------------------------------
689
683
 
690
- def _project_for_provider(
691
- self,
692
- messages: list[ConversationMessage],
693
- ) -> list[ConversationMessage]:
694
- """Replace pre-compact history with a summary continuation."""
695
-
696
- last_compact = -1
697
- for i, message in enumerate(messages):
698
- if message.get("role") == "compact":
699
- last_compact = i
700
-
701
- if last_compact < 0:
702
- return messages
703
-
704
- summary_text = ""
705
- for block in messages[last_compact].get("content") or []:
706
- if isinstance(block, dict) and block.get("type") == "text":
707
- summary_text = str(block.get("text") or "")
708
- break
709
-
710
- tail = [m for m in messages[last_compact + 1 :] if m.get("role") != "compact"]
711
- # No tail or assistant-led tail = mid-loop; append a resume instruction
712
- # and skip the ack. A user-led tail needs the ack to keep alternation.
713
- continue_now = not tail or tail[0].get("role") == "assistant"
714
-
715
- parts = [CONTINUATION_HEADER, summary_text]
716
- if self._store and self.session_id:
717
- parts.append(TRANSCRIPT_HINT.format(path=self._store.messages_path(self.session_id)))
718
- if continue_now:
719
- parts.append(CONTINUATION_FOOTER)
720
-
721
- projected = [build_message("user", [text_block("\n\n".join(parts))])]
722
- if not continue_now:
723
- projected.append(build_message("assistant", [text_block(COMPACT_ACK)]))
724
- projected.extend(tail)
725
- return projected
726
-
727
684
  async def _compact(
728
685
  self,
729
686
  adapter: ProviderAdapter,
730
687
  persist: PersistCallback,
731
- ) -> AsyncIterator[Event]:
732
- """Generate a conversation summary and append a compact marker."""
688
+ ) -> None:
689
+ """Ask the provider for a summary, persist the compact event, append it."""
733
690
 
734
- # Ask the same provider for a summary — no tools, just text generation.
735
- compact_messages = self._project_for_provider(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
+ ]
736
694
  request = ProviderRequest(
737
695
  provider=self.provider,
738
696
  model=self.model,
@@ -769,8 +727,5 @@ class Agent:
769
727
  total_tokens=summary_total_tokens,
770
728
  )
771
729
 
772
- # Persist the compact event (append-only — original messages stay in JSONL).
773
730
  await persist(compact_event)
774
731
  self.messages.append(compact_event)
775
-
776
- yield Event("compact", {})
@@ -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
27
  MESSAGE_FORMAT_VERSION = 7
28
- DEFAULT_COMPACT_THRESHOLD = 0.8
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,41 +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
- total_tokens: int | None = None,
104
- ) -> ConversationMessage:
105
- """Build the compact event stored in session JSONL."""
106
-
107
- meta: dict[str, Any] = {
108
- "provider": provider,
109
- "model": model,
110
- }
111
- if total_tokens is not None:
112
- meta["total_tokens"] = total_tokens
113
- return build_message("compact", [text_block(summary_text)], meta=meta)
114
-
115
-
116
40
  def build_rewind_event(rewind_to: int) -> ConversationMessage:
117
41
  """Build a rewind marker to append to session JSONL."""
118
42
 
@@ -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