mycode-sdk 0.9.5__tar.gz → 0.10.0__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 (23) hide show
  1. {mycode_sdk-0.9.5 → mycode_sdk-0.10.0}/PKG-INFO +1 -1
  2. {mycode_sdk-0.9.5 → mycode_sdk-0.10.0}/pyproject.toml +1 -1
  3. {mycode_sdk-0.9.5 → mycode_sdk-0.10.0}/src/mycode/__init__.py +2 -0
  4. {mycode_sdk-0.9.5 → mycode_sdk-0.10.0}/src/mycode/agent.py +64 -14
  5. {mycode_sdk-0.9.5 → mycode_sdk-0.10.0}/src/mycode/compact.py +19 -0
  6. {mycode_sdk-0.9.5 → mycode_sdk-0.10.0}/src/mycode/messages.py +2 -2
  7. {mycode_sdk-0.9.5 → mycode_sdk-0.10.0}/src/mycode/models.py +4 -4
  8. {mycode_sdk-0.9.5 → mycode_sdk-0.10.0}/src/mycode/providers/anthropic_like.py +3 -2
  9. {mycode_sdk-0.9.5 → mycode_sdk-0.10.0}/src/mycode/providers/base.py +1 -1
  10. {mycode_sdk-0.9.5 → mycode_sdk-0.10.0}/src/mycode/providers/gemini.py +8 -8
  11. {mycode_sdk-0.9.5 → mycode_sdk-0.10.0}/src/mycode/providers/openai_chat.py +2 -2
  12. {mycode_sdk-0.9.5 → mycode_sdk-0.10.0}/src/mycode/providers/openai_responses.py +4 -5
  13. {mycode_sdk-0.9.5 → mycode_sdk-0.10.0}/src/mycode/session.py +4 -7
  14. {mycode_sdk-0.9.5 → mycode_sdk-0.10.0}/src/mycode/tools.py +8 -15
  15. {mycode_sdk-0.9.5 → mycode_sdk-0.10.0}/.gitignore +0 -0
  16. {mycode_sdk-0.9.5 → mycode_sdk-0.10.0}/LICENSE +0 -0
  17. {mycode_sdk-0.9.5 → mycode_sdk-0.10.0}/README.md +0 -0
  18. {mycode_sdk-0.9.5 → mycode_sdk-0.10.0}/src/mycode/attachments.py +0 -0
  19. {mycode_sdk-0.9.5 → mycode_sdk-0.10.0}/src/mycode/hooks.py +0 -0
  20. {mycode_sdk-0.9.5 → mycode_sdk-0.10.0}/src/mycode/models_catalog.json +0 -0
  21. {mycode_sdk-0.9.5 → mycode_sdk-0.10.0}/src/mycode/providers/__init__.py +0 -0
  22. {mycode_sdk-0.9.5 → mycode_sdk-0.10.0}/src/mycode/py.typed +0 -0
  23. {mycode_sdk-0.9.5 → mycode_sdk-0.10.0}/src/mycode/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mycode-sdk
3
- Version: 0.9.5
3
+ Version: 0.10.0
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.9.5"
7
+ version = "0.10.0"
8
8
  description = "Lightweight Python SDK for building AI agents."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -4,6 +4,7 @@ from importlib import metadata
4
4
 
5
5
  from mycode.agent import Agent, Event, PersistCallback, RunResult
6
6
  from mycode.attachments import Attachment
7
+ from mycode.compact import NothingToCompactError
7
8
  from mycode.hooks import AfterToolHook, BeforeToolHook, HookResult, Hooks, ToolHookContext
8
9
  from mycode.messages import (
9
10
  ContentBlock,
@@ -45,6 +46,7 @@ __all__ = [
45
46
  "BeforeToolHook",
46
47
  "HookResult",
47
48
  "Hooks",
49
+ "NothingToCompactError",
48
50
  "PersistCallback",
49
51
  "RunResult",
50
52
  "SessionStore",
@@ -23,7 +23,9 @@ from mycode.attachments import AttachmentLike, build_attachment_blocks
23
23
  from mycode.compact import (
24
24
  COMPACT_SUMMARY_PROMPT,
25
25
  DEFAULT_COMPACT_THRESHOLD,
26
+ NothingToCompactError,
26
27
  build_compact_event,
28
+ has_compactable_history,
27
29
  should_compact,
28
30
  )
29
31
  from mycode.hooks import Hooks, ToolHookContext
@@ -455,6 +457,23 @@ class Agent:
455
457
  block["meta"] = {**meta, "duration_ms": duration_ms}
456
458
  return
457
459
 
460
+ async def _persist_message(
461
+ self,
462
+ message: ConversationMessage,
463
+ on_persist: PersistCallback | None,
464
+ ) -> None:
465
+ """Persist one message: caller callback first, then the SDK session store."""
466
+
467
+ if on_persist is not None:
468
+ # Callers may need to write related records before the SDK
469
+ # appends this message to its own session log.
470
+ await on_persist(message)
471
+ if self._store is None:
472
+ return
473
+ if not self._store.session_exists(self.session_id):
474
+ await self._store.create_session(self.session_id, cwd=self.cwd)
475
+ await self._store.append_message(self.session_id, message)
476
+
458
477
  # ------------------------------------------------------------------
459
478
  # Public entry points
460
479
  # ------------------------------------------------------------------
@@ -469,15 +488,7 @@ class Agent:
469
488
  """Run the full agent loop for one user message."""
470
489
 
471
490
  async def persist(message: ConversationMessage) -> None:
472
- if on_persist is not None:
473
- # Callers may need to write related records before the SDK
474
- # appends this message to its own session log.
475
- await on_persist(message)
476
- if self._store is None:
477
- return
478
- if not self._store.session_exists(self.session_id):
479
- await self._store.create_session(self.session_id, cwd=self.cwd)
480
- await self._store.append_message(self.session_id, message)
491
+ await self._persist_message(message, on_persist)
481
492
 
482
493
  self._cancel_event.clear()
483
494
 
@@ -671,7 +682,7 @@ class Agent:
671
682
  return
672
683
  if should_compact(total_tokens, self.context_window, self.compact_threshold):
673
684
  try:
674
- await self._compact(adapter, persist)
685
+ await self._compact(adapter, on_persist)
675
686
  yield Event("compact", {})
676
687
  except asyncio.CancelledError:
677
688
  yield Event("error", {"message": "cancelled"})
@@ -719,12 +730,47 @@ class Agent:
719
730
  # Context compaction
720
731
  # ------------------------------------------------------------------
721
732
 
733
+ async def acompact(
734
+ self,
735
+ *,
736
+ on_persist: PersistCallback | None = None,
737
+ ) -> ConversationMessage:
738
+ """Compact the conversation now and return the persisted compact marker.
739
+
740
+ Raises :class:`NothingToCompactError` when no new context follows the
741
+ latest compact marker, and :class:`asyncio.CancelledError` when
742
+ :meth:`cancel` stops the summary request.
743
+ """
744
+
745
+ self._cancel_event.clear()
746
+ adapter = get_provider_adapter(self.provider)
747
+ return await self._compact(adapter, on_persist)
748
+
749
+ def compact(
750
+ self,
751
+ *,
752
+ on_persist: PersistCallback | None = None,
753
+ ) -> ConversationMessage:
754
+ """Compact the conversation synchronously; see :meth:`acompact`."""
755
+
756
+ try:
757
+ asyncio.get_running_loop()
758
+ except RuntimeError:
759
+ pass
760
+ else:
761
+ raise RuntimeError("Agent.compact() cannot run inside an active event loop; use Agent.acompact() instead")
762
+
763
+ return asyncio.run(self.acompact(on_persist=on_persist))
764
+
722
765
  async def _compact(
723
766
  self,
724
767
  adapter: ProviderAdapter,
725
- persist: PersistCallback,
726
- ) -> None:
727
- """Ask the provider for a summary, persist the compact event, append it."""
768
+ on_persist: PersistCallback | None,
769
+ ) -> ConversationMessage:
770
+ """Ask the provider for a summary, persist and append the compact marker."""
771
+
772
+ if not has_compactable_history(self.messages):
773
+ raise NothingToCompactError("nothing to compact")
728
774
 
729
775
  request = self._build_request(
730
776
  tools=[],
@@ -746,6 +792,9 @@ class Agent:
746
792
  if not summary_text:
747
793
  raise ValueError("compaction produced empty summary")
748
794
 
795
+ if self._cancel_event.is_set():
796
+ raise asyncio.CancelledError
797
+
749
798
  summary_total_tokens = (summary_message.get("meta") or {}).get("total_tokens")
750
799
  compact_event = build_compact_event(
751
800
  summary_text,
@@ -754,5 +803,6 @@ class Agent:
754
803
  total_tokens=summary_total_tokens,
755
804
  )
756
805
 
757
- await persist(compact_event)
806
+ await self._persist_message(compact_event, on_persist)
758
807
  self.messages.append(compact_event)
808
+ return compact_event
@@ -8,6 +8,11 @@ from mycode.messages import ConversationMessage, build_message, text_block
8
8
 
9
9
  DEFAULT_COMPACT_THRESHOLD = 0.8
10
10
 
11
+
12
+ class NothingToCompactError(ValueError):
13
+ """Raised when there is no new context to compact past the latest marker."""
14
+
15
+
11
16
  COMPACT_SUMMARY_PROMPT = """\
12
17
  Summarize this conversation to create a continuation document. \
13
18
  This summary will replace the full conversation history, so it must \
@@ -65,6 +70,20 @@ def should_compact(
65
70
  return last_total_tokens >= context_window * threshold
66
71
 
67
72
 
73
+ def has_compactable_history(messages: list[ConversationMessage]) -> bool:
74
+ """True when at least one non-empty user/assistant message follows the latest compact marker."""
75
+
76
+ last_compact = -1
77
+ for i, message in enumerate(messages):
78
+ if message.get("role") == "compact":
79
+ last_compact = i
80
+
81
+ return any(
82
+ message.get("role") in ("user", "assistant") and message.get("content")
83
+ for message in messages[last_compact + 1 :]
84
+ )
85
+
86
+
68
87
  def build_compact_event(
69
88
  summary_text: str,
70
89
  *,
@@ -165,8 +165,8 @@ def flatten_message_text(message: ConversationMessage, *, include_thinking: bool
165
165
  continue
166
166
  raw_meta = block.get("meta")
167
167
  meta = raw_meta if isinstance(raw_meta, dict) else {}
168
- # Attached file snapshots should not become session titles or history labels.
169
- if meta.get("attachment"):
168
+ # Local payload blocks should not become session titles or history labels.
169
+ if meta.get("attachment") or meta.get("skill_snapshot"):
170
170
  continue
171
171
  btype = block.get("type")
172
172
  if btype == "text" or (include_thinking and btype == "thinking"):
@@ -2,9 +2,9 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import functools
6
5
  import json
7
6
  from dataclasses import dataclass, replace
7
+ from functools import cache
8
8
  from pathlib import Path
9
9
  from typing import Any
10
10
 
@@ -30,14 +30,14 @@ class ModelMetadata:
30
30
  supports_pdf_input: bool | None = None
31
31
 
32
32
 
33
- @functools.cache
33
+ @cache
34
34
  def load_models_catalog() -> dict[str, Any] | None:
35
35
  """Load the bundled model catalog from disk once per process."""
36
36
 
37
37
  try:
38
38
  data = json.loads(_MODELS_CATALOG_PATH.read_text(encoding="utf-8"))
39
- except Exception:
40
- data = None
39
+ except (OSError, UnicodeError, json.JSONDecodeError):
40
+ return None
41
41
  return data if isinstance(data, dict) else None
42
42
 
43
43
 
@@ -111,8 +111,9 @@ class AnthropicLikeAdapter(ProviderAdapter):
111
111
  if not isinstance(content, list):
112
112
  return
113
113
 
114
- blocks: list[dict[str, Any]] = [cast(dict[str, Any], block) for block in content if isinstance(block, dict)]
115
- for block in reversed(blocks):
114
+ for block in reversed(content):
115
+ if not isinstance(block, dict):
116
+ continue
116
117
  block_type = str(block.get("type") or "")
117
118
  if block_type not in {"text", "image", "document", "tool_result"}:
118
119
  continue
@@ -88,7 +88,7 @@ def parse_tool_call_input(raw_arguments: str) -> tuple[dict[str, Any], dict[str,
88
88
  try:
89
89
  parsed = json.loads(raw_arguments)
90
90
  except json.JSONDecodeError:
91
- parsed = None
91
+ return {}, {"raw_arguments": raw_arguments}
92
92
  if isinstance(parsed, dict):
93
93
  return parsed, {}
94
94
  return {}, {"raw_arguments": raw_arguments}
@@ -84,7 +84,7 @@ class GoogleGeminiAdapter(ProviderAdapter):
84
84
  finish_message = getattr(candidate, "finish_message", None) or finish_message
85
85
 
86
86
  for part in getattr(getattr(candidate, "content", None), "parts", None) or []:
87
- for event in self._consume_part(blocks, part):
87
+ if event := self._consume_part(blocks, part):
88
88
  yield event
89
89
  except APIError as exc:
90
90
  raise ValueError(str(exc)) from exc
@@ -223,7 +223,7 @@ class GoogleGeminiAdapter(ProviderAdapter):
223
223
  return contents
224
224
 
225
225
  def _build_config(self, request: ProviderRequest) -> types.GenerateContentConfig:
226
- tools: list[types.Tool | Any] | None = None
226
+ tools: list[types.Tool] | None = None
227
227
  if request.tools:
228
228
  tools = [
229
229
  types.Tool(
@@ -262,7 +262,7 @@ class GoogleGeminiAdapter(ProviderAdapter):
262
262
  thinking_config=thinking_config,
263
263
  )
264
264
 
265
- def _consume_part(self, blocks: list[dict[str, Any]], part: Any) -> list[ProviderStreamEvent]:
265
+ def _consume_part(self, blocks: list[dict[str, Any]], part: Any) -> ProviderStreamEvent | None:
266
266
  native_part = _to_json(part) or {}
267
267
  if native_part.get("thought") is False:
268
268
  native_part.pop("thought", None)
@@ -278,12 +278,12 @@ class GoogleGeminiAdapter(ProviderAdapter):
278
278
  meta={"native": {"part": native_part}},
279
279
  )
280
280
  )
281
- return []
281
+ return None
282
282
 
283
283
  text = getattr(part, "text", None)
284
284
  if text is None or text == "":
285
285
  if not native_part.get("thought_signature"):
286
- return []
286
+ return None
287
287
 
288
288
  # Gemini may put the final thought signature into an empty-text part.
289
289
  # Keep it as a separate empty block so replay preserves the original
@@ -291,7 +291,7 @@ class GoogleGeminiAdapter(ProviderAdapter):
291
291
  part_meta = {"native": {"part": native_part}}
292
292
  is_thought = getattr(part, "thought", False)
293
293
  blocks.append(thinking_block("", meta=part_meta) if is_thought else text_block("", meta=part_meta))
294
- return []
294
+ return None
295
295
 
296
296
  is_thought = bool(getattr(part, "thought", False))
297
297
  event = ProviderStreamEvent("thinking_delta" if is_thought else "text_delta", {"text": str(text)})
@@ -310,10 +310,10 @@ class GoogleGeminiAdapter(ProviderAdapter):
310
310
  last_part["text"] = f"{last_part.get('text') or ''}{text}"
311
311
  if current_signature and not last_signature:
312
312
  last_part["thought_signature"] = current_signature
313
- return [event]
313
+ return event
314
314
 
315
315
  part_meta = {"native": {"part": native_part}}
316
316
  blocks.append(
317
317
  thinking_block(str(text), meta=part_meta) if is_thought else text_block(str(text), meta=part_meta)
318
318
  )
319
- return [event]
319
+ return event
@@ -9,7 +9,7 @@ from typing import Any, override
9
9
 
10
10
  from openai import APIError, AsyncOpenAI
11
11
 
12
- from mycode.messages import assistant_message, text_block, thinking_block, tool_use_block
12
+ from mycode.messages import ConversationMessage, assistant_message, text_block, thinking_block, tool_use_block
13
13
  from mycode.providers.base import (
14
14
  DEFAULT_REQUEST_TIMEOUT,
15
15
  ProviderAdapter,
@@ -174,7 +174,7 @@ class OpenAIChatAdapter(ProviderAdapter):
174
174
  },
175
175
  }
176
176
 
177
- def _serialize_message(self, message: dict[str, Any]) -> list[dict[str, Any]]:
177
+ def _serialize_message(self, message: ConversationMessage) -> list[dict[str, Any]]:
178
178
  """Convert one canonical message into Chat Completions wire messages."""
179
179
 
180
180
  role = str(message.get("role") or "user")
@@ -102,9 +102,8 @@ class OpenAIResponsesAdapter(ProviderAdapter):
102
102
  raise ValueError(str(exc)) from exc
103
103
 
104
104
  def _build_request_payload(self, request: ProviderRequest) -> dict[str, Any]:
105
- prepared_messages = self.prepare_messages(request)
106
105
  input_items: list[dict[str, Any]] = []
107
- for message in prepared_messages:
106
+ for message in self.prepare_messages(request):
108
107
  role = message.get("role")
109
108
  if role == "user":
110
109
  input_items.extend(self._serialize_user_message(message))
@@ -262,7 +261,7 @@ class OpenAIResponsesAdapter(ProviderAdapter):
262
261
  response: Any,
263
262
  *,
264
263
  output_items: list[Any] | None = None,
265
- ) -> dict[str, Any]:
264
+ ) -> ConversationMessage:
266
265
  raw_output = output_items if output_items is not None else (getattr(response, "output", None) or [])
267
266
  dumped_output_items = dump_model(raw_output)
268
267
  blocks: list[dict[str, Any]] = []
@@ -357,8 +356,8 @@ def _normalize_strict_schema(schema: Any) -> None:
357
356
 
358
357
  properties = schema.get("properties")
359
358
  if isinstance(properties, dict):
360
- required_names = {str(name) for name in schema.get("required", []) if isinstance(name, str)}
361
- for name, property_schema in list(properties.items()):
359
+ required_names = {name for name in schema.get("required", []) if isinstance(name, str)}
360
+ for name, property_schema in properties.items():
362
361
  _normalize_strict_schema(property_schema)
363
362
  if name not in required_names:
364
363
  properties[name] = _nullable_schema(property_schema)
@@ -139,7 +139,7 @@ class SessionStore:
139
139
  meta = _project_meta(meta)
140
140
  self.meta_path(session_id).write_text(json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8")
141
141
  index = self._read_index()
142
- index[session_id] = dict(meta)
142
+ index[session_id] = meta
143
143
  self._write_index(index)
144
144
 
145
145
  def _read_index(self) -> SessionIndex:
@@ -161,7 +161,7 @@ class SessionStore:
161
161
  continue
162
162
  meta = self._read_meta(entry.name)
163
163
  if meta is not None:
164
- index[entry.name] = dict(meta)
164
+ index[entry.name] = meta
165
165
  self._write_index(index)
166
166
  return index
167
167
 
@@ -246,9 +246,7 @@ class SessionStore:
246
246
 
247
247
  async def delete_session(self, session_id: str) -> None:
248
248
  def delete() -> None:
249
- sdir = self.session_dir(session_id)
250
- if sdir.exists():
251
- shutil.rmtree(sdir, ignore_errors=True)
249
+ shutil.rmtree(self.session_dir(session_id), ignore_errors=True)
252
250
  index = self._read_index()
253
251
  index.pop(session_id, None)
254
252
  self._write_index(index)
@@ -285,8 +283,7 @@ class SessionStore:
285
283
 
286
284
  def append() -> None:
287
285
  with self.messages_path(session_id).open("a", encoding="utf-8") as handle:
288
- handle.write(json.dumps(message, ensure_ascii=False))
289
- handle.write("\n")
286
+ handle.write(json.dumps(message, ensure_ascii=False) + "\n")
290
287
 
291
288
  meta = self._read_meta(session_id)
292
289
  if meta is None:
@@ -129,12 +129,6 @@ class ToolContext:
129
129
 
130
130
  return self.executor.execute(name, args, self)
131
131
 
132
- def track_proc(self, proc: subprocess.Popen[str]) -> None:
133
- self.executor.track_proc(proc)
134
-
135
- def untrack_proc(self, proc: subprocess.Popen[str]) -> None:
136
- self.executor.untrack_proc(proc)
137
-
138
132
 
139
133
  # ---------------------------------------------------------------------------
140
134
  # ToolExecutor
@@ -202,6 +196,9 @@ class ToolExecutor:
202
196
  # agents may run concurrently in the same process); this module-level set is
203
197
  # a shutdown-time safety net exposed as ``cancel_all_tools``.
204
198
 
199
+ _ACTIVE_PROCS: set[subprocess.Popen[str]] = set()
200
+ _ACTIVE_PROCS_LOCK = threading.Lock()
201
+
205
202
 
206
203
  def cancel_all_tools() -> None:
207
204
  """Terminate every running bash subprocess in the current process."""
@@ -213,10 +210,6 @@ def cancel_all_tools() -> None:
213
210
  _kill_proc_tree(proc)
214
211
 
215
212
 
216
- _ACTIVE_PROCS: set[subprocess.Popen[str]] = set()
217
- _ACTIVE_PROCS_LOCK = threading.Lock()
218
-
219
-
220
213
  def _kill_proc_tree(proc: subprocess.Popen[str]) -> None:
221
214
  try:
222
215
  if os.name == "posix":
@@ -479,8 +472,8 @@ def read_tool(
479
472
  ],
480
473
  )
481
474
 
482
- start_line = offset if isinstance(offset, int) and offset > 0 else 1
483
- line_limit = limit if isinstance(limit, int) and limit > 0 else DEFAULT_MAX_LINES
475
+ start_line = offset if offset is not None and offset > 0 else 1
476
+ line_limit = limit if limit is not None and limit > 0 else DEFAULT_MAX_LINES
484
477
  lines: list[str] = []
485
478
  total_lines = 0
486
479
  next_offset: int | None = None
@@ -764,7 +757,7 @@ def bash_tool(ctx: ToolContext, command: str, timeout: int | None = None) -> Too
764
757
  again by :func:`truncate_text` to the display limits.
765
758
  """
766
759
 
767
- timeout_seconds = int(timeout) if isinstance(timeout, int) and timeout > 0 else BASH_TIMEOUT_SECONDS
760
+ timeout_seconds = timeout if timeout is not None and timeout > 0 else BASH_TIMEOUT_SECONDS
768
761
 
769
762
  proc: subprocess.Popen[str] | None = None
770
763
  log_path = ctx.tool_output_dir / f"bash-{ctx.tool_call_id or 'call'}.log"
@@ -787,7 +780,7 @@ def bash_tool(ctx: ToolContext, command: str, timeout: int | None = None) -> Too
787
780
  bufsize=1,
788
781
  start_new_session=os.name == "posix",
789
782
  )
790
- ctx.track_proc(proc)
783
+ ctx.executor.track_proc(proc)
791
784
 
792
785
  stdout = cast(TextIO, proc.stdout)
793
786
  output_queue: queue.Queue[str | None] = queue.Queue()
@@ -903,7 +896,7 @@ def bash_tool(ctx: ToolContext, command: str, timeout: int | None = None) -> Too
903
896
  with suppress(Exception):
904
897
  log_file.close()
905
898
  if proc is not None:
906
- ctx.untrack_proc(proc)
899
+ ctx.executor.untrack_proc(proc)
907
900
  if proc.poll() is None:
908
901
  _kill_proc_tree(proc)
909
902
 
File without changes
File without changes
File without changes