mycode-sdk 0.9.4__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.4 → mycode_sdk-0.10.0}/PKG-INFO +6 -6
  2. {mycode_sdk-0.9.4 → mycode_sdk-0.10.0}/pyproject.toml +7 -7
  3. {mycode_sdk-0.9.4 → mycode_sdk-0.10.0}/src/mycode/__init__.py +2 -0
  4. {mycode_sdk-0.9.4 → mycode_sdk-0.10.0}/src/mycode/agent.py +93 -55
  5. {mycode_sdk-0.9.4 → mycode_sdk-0.10.0}/src/mycode/compact.py +19 -0
  6. {mycode_sdk-0.9.4 → mycode_sdk-0.10.0}/src/mycode/messages.py +2 -2
  7. {mycode_sdk-0.9.4 → mycode_sdk-0.10.0}/src/mycode/models.py +18 -8
  8. {mycode_sdk-0.9.4 → mycode_sdk-0.10.0}/src/mycode/models_catalog.json +142 -114
  9. {mycode_sdk-0.9.4 → mycode_sdk-0.10.0}/src/mycode/providers/anthropic_like.py +3 -2
  10. {mycode_sdk-0.9.4 → mycode_sdk-0.10.0}/src/mycode/providers/base.py +9 -4
  11. {mycode_sdk-0.9.4 → mycode_sdk-0.10.0}/src/mycode/providers/gemini.py +8 -8
  12. {mycode_sdk-0.9.4 → mycode_sdk-0.10.0}/src/mycode/providers/openai_chat.py +13 -18
  13. {mycode_sdk-0.9.4 → mycode_sdk-0.10.0}/src/mycode/providers/openai_responses.py +5 -6
  14. {mycode_sdk-0.9.4 → mycode_sdk-0.10.0}/src/mycode/session.py +14 -30
  15. {mycode_sdk-0.9.4 → mycode_sdk-0.10.0}/src/mycode/tools.py +18 -39
  16. {mycode_sdk-0.9.4 → mycode_sdk-0.10.0}/src/mycode/utils.py +0 -16
  17. {mycode_sdk-0.9.4 → mycode_sdk-0.10.0}/.gitignore +0 -0
  18. {mycode_sdk-0.9.4 → mycode_sdk-0.10.0}/LICENSE +0 -0
  19. {mycode_sdk-0.9.4 → mycode_sdk-0.10.0}/README.md +0 -0
  20. {mycode_sdk-0.9.4 → mycode_sdk-0.10.0}/src/mycode/attachments.py +0 -0
  21. {mycode_sdk-0.9.4 → mycode_sdk-0.10.0}/src/mycode/hooks.py +0 -0
  22. {mycode_sdk-0.9.4 → mycode_sdk-0.10.0}/src/mycode/providers/__init__.py +0 -0
  23. {mycode_sdk-0.9.4 → mycode_sdk-0.10.0}/src/mycode/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mycode-sdk
3
- Version: 0.9.4
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
@@ -18,11 +18,11 @@ Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Programming Language :: Python :: 3.13
19
19
  Classifier: Topic :: Software Development
20
20
  Requires-Python: >=3.12
21
- Requires-Dist: anthropic>=0.106.0
22
- Requires-Dist: google-genai>=2.8.0
23
- Requires-Dist: griffelib>=2.0.0
24
- Requires-Dist: openai>=2.41.0
25
- Requires-Dist: pydantic>=2.13.0
21
+ Requires-Dist: anthropic>=0.116.0
22
+ Requires-Dist: google-genai>=2.11.0
23
+ Requires-Dist: griffelib>=2.1.0
24
+ Requires-Dist: openai>=2.45.0
25
+ Requires-Dist: pydantic>=2.13.4
26
26
  Description-Content-Type: text/markdown
27
27
 
28
28
  # mycode-sdk
@@ -1,10 +1,10 @@
1
1
  [build-system]
2
- requires = ["hatchling"]
2
+ requires = ["hatchling>=1.31.0"]
3
3
  build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "mycode-sdk"
7
- version = "0.9.4"
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"
@@ -23,11 +23,11 @@ classifiers = [
23
23
  ]
24
24
  keywords = ["agent", "llm", "anthropic", "openai", "gemini", "sdk"]
25
25
  dependencies = [
26
- "anthropic>=0.106.0",
27
- "google-genai>=2.8.0",
28
- "griffelib>=2.0.0",
29
- "openai>=2.41.0",
30
- "pydantic>=2.13.0",
26
+ "anthropic>=0.116.0",
27
+ "google-genai>=2.11.0",
28
+ "griffelib>=2.1.0",
29
+ "openai>=2.45.0",
30
+ "pydantic>=2.13.4",
31
31
  ]
32
32
 
33
33
  [project.urls]
@@ -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
@@ -152,16 +154,10 @@ class Agent:
152
154
  # (e.g. bash spill files) live next to the session JSONL; without a
153
155
  # session, fall back to a tempdir scoped to ``session_id``.
154
156
  if session_dir is not None:
155
- tool_output_dir = session_dir / self.session_id / "tool-output"
157
+ self.tool_output_dir = session_dir / self.session_id / "tool-output"
156
158
  else:
157
- tool_output_dir = Path(tempfile.gettempdir()) / "mycode" / self.session_id / "tool-output"
159
+ self.tool_output_dir = Path(tempfile.gettempdir()) / "mycode" / self.session_id / "tool-output"
158
160
  self.tools = ToolExecutor(tools)
159
- self.tool_ctx = ToolContext(
160
- executor=self.tools,
161
- cwd=self.cwd,
162
- tool_output_dir=tool_output_dir,
163
- supports_image_input=False,
164
- )
165
161
 
166
162
  self.refresh_capabilities(
167
163
  max_tokens=max_tokens,
@@ -201,7 +197,6 @@ class Agent:
201
197
  self.supports_reasoning: bool | None = meta.supports_reasoning
202
198
  self.supports_image_input: bool = bool(meta.supports_image_input)
203
199
  self.supports_pdf_input: bool = bool(meta.supports_pdf_input)
204
- self.tool_ctx.supports_image_input = self.supports_image_input
205
200
 
206
201
  def cancel(self) -> None:
207
202
  """Request cancellation of the in-flight turn."""
@@ -330,11 +325,11 @@ class Agent:
330
325
  output = "\n".join([*output_parts, "error: cancelled"]) if output_parts else "error: cancelled"
331
326
  yield self._error_done(tool_id, output)
332
327
  return
333
- else:
334
- try:
335
- result = await task
336
- except Exception as exc: # pragma: no cover - defensive
337
- result = ToolExecutionResult(output=f"error: {exc}", is_error=True)
328
+
329
+ try:
330
+ result = await task
331
+ except Exception as exc: # pragma: no cover - defensive
332
+ result = ToolExecutionResult(output=f"error: {exc}", is_error=True)
338
333
 
339
334
  yield await self._finish_tool_call(tool_id, hook_ctx, result)
340
335
 
@@ -360,13 +355,11 @@ class Agent:
360
355
  *,
361
356
  emit: Callable[[str], None] | None = None,
362
357
  ) -> ToolContext:
363
- """Build a per-call ToolContext from the base context."""
364
-
365
358
  return ToolContext(
366
359
  executor=self.tools,
367
- cwd=self.tool_ctx.cwd,
368
- tool_output_dir=self.tool_ctx.tool_output_dir,
369
- supports_image_input=self.tool_ctx.supports_image_input,
360
+ cwd=self.cwd,
361
+ tool_output_dir=self.tool_output_dir,
362
+ supports_image_input=self.supports_image_input,
370
363
  tool_call_id=tool_id,
371
364
  emit=emit,
372
365
  )
@@ -402,15 +395,12 @@ class Agent:
402
395
 
403
396
  provider_stream: AsyncIterator[ProviderStreamEvent] = adapter.stream_turn(request)
404
397
 
405
- async def next_provider_event() -> ProviderStreamEvent:
406
- return await anext(provider_stream)
407
-
408
398
  try:
409
399
  while True:
410
400
  if self._cancel_event.is_set():
411
401
  raise asyncio.CancelledError
412
402
 
413
- self._provider_event_task = asyncio.create_task(next_provider_event())
403
+ self._provider_event_task = asyncio.ensure_future(anext(provider_stream))
414
404
  try:
415
405
  yield await self._provider_event_task
416
406
  except StopAsyncIteration:
@@ -467,6 +457,23 @@ class Agent:
467
457
  block["meta"] = {**meta, "duration_ms": duration_ms}
468
458
  return
469
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
+
470
477
  # ------------------------------------------------------------------
471
478
  # Public entry points
472
479
  # ------------------------------------------------------------------
@@ -481,15 +488,7 @@ class Agent:
481
488
  """Run the full agent loop for one user message."""
482
489
 
483
490
  async def persist(message: ConversationMessage) -> None:
484
- if on_persist is not None:
485
- # Callers may need to write related records before the SDK
486
- # appends this message to its own session log.
487
- await on_persist(message)
488
- if self._store is None:
489
- return
490
- if not self._store.session_exists(self.session_id):
491
- await self._store.create_session(self.session_id, cwd=self.cwd)
492
- await self._store.append_message(self.session_id, message)
491
+ await self._persist_message(message, on_persist)
493
492
 
494
493
  self._cancel_event.clear()
495
494
 
@@ -529,7 +528,10 @@ class Agent:
529
528
  adapter = get_provider_adapter(self.provider)
530
529
 
531
530
  turn_number = 0
532
- while self.max_turns is None or turn_number < self.max_turns:
531
+ while True:
532
+ if self.max_turns is not None and turn_number >= self.max_turns:
533
+ yield Event("error", {"message": "max_turns reached"})
534
+ return
533
535
  turn_number += 1
534
536
  if self._cancel_event.is_set():
535
537
  yield Event("error", {"message": "cancelled"})
@@ -632,13 +634,15 @@ class Agent:
632
634
 
633
635
  total_tokens = meta.get("total_tokens")
634
636
  if total_tokens:
635
- payload: dict[str, Any] = {
636
- "total_tokens": total_tokens,
637
- "model": meta.get("model") or self.model,
638
- "provider": meta.get("provider") or self.provider,
639
- "context_window": meta["context_window"],
640
- }
641
- yield Event("usage", payload)
637
+ yield Event(
638
+ "usage",
639
+ {
640
+ "total_tokens": total_tokens,
641
+ "model": meta.get("model") or self.model,
642
+ "provider": meta.get("provider") or self.provider,
643
+ "context_window": meta["context_window"],
644
+ },
645
+ )
642
646
 
643
647
  tool_calls = [
644
648
  block
@@ -665,11 +669,10 @@ class Agent:
665
669
  )
666
670
  )
667
671
 
668
- if self._cancel_event.is_set():
669
- tool_result_message = build_message("user", tool_results)
670
- self.messages.append(tool_result_message)
671
- await persist(tool_result_message)
672
- return
672
+ if self._cancel_event.is_set():
673
+ # Skip remaining tool calls; the results collected so
674
+ # far are still persisted below.
675
+ break
673
676
 
674
677
  tool_result_message = build_message("user", tool_results)
675
678
  self.messages.append(tool_result_message)
@@ -679,7 +682,7 @@ class Agent:
679
682
  return
680
683
  if should_compact(total_tokens, self.context_window, self.compact_threshold):
681
684
  try:
682
- await self._compact(adapter, persist)
685
+ await self._compact(adapter, on_persist)
683
686
  yield Event("compact", {})
684
687
  except asyncio.CancelledError:
685
688
  yield Event("error", {"message": "cancelled"})
@@ -693,11 +696,7 @@ class Agent:
693
696
  )
694
697
 
695
698
  if not tool_calls:
696
- break
697
-
698
- else:
699
- yield Event("error", {"message": "max_turns reached"})
700
- return
699
+ return
701
700
 
702
701
  def run(
703
702
  self,
@@ -731,12 +730,47 @@ class Agent:
731
730
  # Context compaction
732
731
  # ------------------------------------------------------------------
733
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
+
734
765
  async def _compact(
735
766
  self,
736
767
  adapter: ProviderAdapter,
737
- persist: PersistCallback,
738
- ) -> None:
739
- """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")
740
774
 
741
775
  request = self._build_request(
742
776
  tools=[],
@@ -758,6 +792,9 @@ class Agent:
758
792
  if not summary_text:
759
793
  raise ValueError("compaction produced empty summary")
760
794
 
795
+ if self._cancel_event.is_set():
796
+ raise asyncio.CancelledError
797
+
761
798
  summary_total_tokens = (summary_message.get("meta") or {}).get("total_tokens")
762
799
  compact_event = build_compact_event(
763
800
  summary_text,
@@ -766,5 +803,6 @@ class Agent:
766
803
  total_tokens=summary_total_tokens,
767
804
  )
768
805
 
769
- await persist(compact_event)
806
+ await self._persist_message(compact_event, on_persist)
770
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
 
@@ -66,16 +66,26 @@ def resolve_model_metadata(
66
66
  *,
67
67
  provider: str,
68
68
  model: str,
69
- **overrides: Any,
69
+ context_window: int | None = None,
70
+ max_output_tokens: int | None = None,
71
+ supports_reasoning: bool | None = None,
72
+ supports_image_input: bool | None = None,
73
+ supports_pdf_input: bool | None = None,
70
74
  ) -> ModelMetadata:
71
75
  """Return catalog metadata for ``(provider, model)`` with non-None overrides layered on top.
72
76
 
73
- Override keys must match :class:`ModelMetadata` fields. Missing overrides
74
- and an absent catalog entry both leave the corresponding fields at ``None``
75
- so callers can apply their own fallback defaults.
77
+ Missing overrides and an absent catalog entry both leave the corresponding
78
+ fields at ``None`` so callers can apply their own fallback defaults.
76
79
  """
77
80
 
78
81
  base = lookup_model_metadata(provider_type=provider, model=model) or ModelMetadata(provider=provider, model=model)
82
+ overrides = {
83
+ "context_window": context_window,
84
+ "max_output_tokens": max_output_tokens,
85
+ "supports_reasoning": supports_reasoning,
86
+ "supports_image_input": supports_image_input,
87
+ "supports_pdf_input": supports_pdf_input,
88
+ }
79
89
  return replace(base, **{k: v for k, v in overrides.items() if v is not None})
80
90
 
81
91