mycode-sdk 0.8.9__tar.gz → 0.9.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.8.9 → mycode_sdk-0.9.0}/PKG-INFO +32 -4
  2. {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/README.md +29 -3
  3. {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/pyproject.toml +3 -1
  4. {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/src/mycode/__init__.py +6 -5
  5. {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/src/mycode/agent.py +26 -7
  6. mycode_sdk-0.9.0/src/mycode/attachments.py +175 -0
  7. {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/src/mycode/models_catalog.json +146 -328
  8. {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/src/mycode/providers/anthropic_like.py +21 -20
  9. {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/src/mycode/providers/base.py +21 -17
  10. {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/src/mycode/providers/gemini.py +1 -0
  11. {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/src/mycode/providers/openai_chat.py +46 -44
  12. {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/src/mycode/providers/openai_responses.py +129 -98
  13. {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/src/mycode/session.py +51 -15
  14. {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/src/mycode/tools.py +368 -449
  15. {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/src/mycode/utils.py +10 -0
  16. {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/.gitignore +0 -0
  17. {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/LICENSE +0 -0
  18. {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/src/mycode/compact.py +0 -0
  19. {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/src/mycode/hooks.py +0 -0
  20. {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/src/mycode/messages.py +0 -0
  21. {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/src/mycode/models.py +0 -0
  22. {mycode_sdk-0.8.9 → mycode_sdk-0.9.0}/src/mycode/providers/__init__.py +0 -0
  23. {mycode_sdk-0.8.9 → mycode_sdk-0.9.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.8.9
3
+ Version: 0.9.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
@@ -20,7 +20,9 @@ Classifier: Topic :: Software Development
20
20
  Requires-Python: >=3.12
21
21
  Requires-Dist: anthropic>=0.102.0
22
22
  Requires-Dist: google-genai>=2.3.0
23
+ Requires-Dist: griffelib>=2.0.2
23
24
  Requires-Dist: openai>=2.36.0
25
+ Requires-Dist: pydantic>=2.13.4
24
26
  Description-Content-Type: text/markdown
25
27
 
26
28
  # mycode-sdk
@@ -78,6 +80,28 @@ agent.run("What is 2 + 2?")
78
80
  agent.run("Now multiply that by 10.") # remembers the earlier answer
79
81
  ```
80
82
 
83
+ ## Attachments
84
+
85
+ Pass `attachments` to `achat()` or `run()` to add files alongside the prompt:
86
+
87
+ ```python
88
+ from mycode import Attachment
89
+
90
+ agent.run("Describe these.", attachments=["diagram.png", "report.pdf", "notes.txt"])
91
+
92
+ # Or build them explicitly — raw bytes and inline text never touch the disk:
93
+ agent.run(
94
+ "Review.",
95
+ attachments=[
96
+ Attachment.path("diagram.png"),
97
+ Attachment.bytes(png_data, media_type="image/png"),
98
+ Attachment.text("TODO: ship it", name="note.md"),
99
+ ],
100
+ )
101
+ ```
102
+
103
+ Images support `image/png`, `image/jpeg`, `image/gif`, `image/webp`; documents support `application/pdf`. Sending an image or PDF to a model that lacks that capability yields an `error` event; a bad path or unsupported type raises `ValueError` before the model is called.
104
+
81
105
  ## Saving sessions
82
106
 
83
107
  Pass `session_dir` to persist the conversation to disk. Each session lives in a subdirectory named by `session_id`:
@@ -105,7 +129,7 @@ Four tools for reading, writing, editing files, and running shell commands. Opt
105
129
 
106
130
  ## Custom tools
107
131
 
108
- Decorate any function with `@tool`. Parameter type hints become the JSON schema sent to the provider; the docstring becomes the description:
132
+ Decorate a typed function with `@tool`:
109
133
 
110
134
  ```python
111
135
  from mycode import Agent, tool
@@ -113,7 +137,11 @@ from mycode import Agent, tool
113
137
 
114
138
  @tool
115
139
  def greet(name: str) -> str:
116
- """Return a friendly greeting."""
140
+ """Return a friendly greeting.
141
+
142
+ Args:
143
+ name: Person name.
144
+ """
117
145
 
118
146
  return f"hello, {name}"
119
147
 
@@ -125,7 +153,7 @@ agent = Agent(
125
153
  )
126
154
  ```
127
155
 
128
- To call a built-in tool from inside your own, type the first parameter as `ToolContext`:
156
+ To call a built-in tool from inside your own tool, type the first parameter as `ToolContext`:
129
157
 
130
158
  ```python
131
159
  from mycode import ToolContext, tool
@@ -53,6 +53,28 @@ agent.run("What is 2 + 2?")
53
53
  agent.run("Now multiply that by 10.") # remembers the earlier answer
54
54
  ```
55
55
 
56
+ ## Attachments
57
+
58
+ Pass `attachments` to `achat()` or `run()` to add files alongside the prompt:
59
+
60
+ ```python
61
+ from mycode import Attachment
62
+
63
+ agent.run("Describe these.", attachments=["diagram.png", "report.pdf", "notes.txt"])
64
+
65
+ # Or build them explicitly — raw bytes and inline text never touch the disk:
66
+ agent.run(
67
+ "Review.",
68
+ attachments=[
69
+ Attachment.path("diagram.png"),
70
+ Attachment.bytes(png_data, media_type="image/png"),
71
+ Attachment.text("TODO: ship it", name="note.md"),
72
+ ],
73
+ )
74
+ ```
75
+
76
+ Images support `image/png`, `image/jpeg`, `image/gif`, `image/webp`; documents support `application/pdf`. Sending an image or PDF to a model that lacks that capability yields an `error` event; a bad path or unsupported type raises `ValueError` before the model is called.
77
+
56
78
  ## Saving sessions
57
79
 
58
80
  Pass `session_dir` to persist the conversation to disk. Each session lives in a subdirectory named by `session_id`:
@@ -80,7 +102,7 @@ Four tools for reading, writing, editing files, and running shell commands. Opt
80
102
 
81
103
  ## Custom tools
82
104
 
83
- Decorate any function with `@tool`. Parameter type hints become the JSON schema sent to the provider; the docstring becomes the description:
105
+ Decorate a typed function with `@tool`:
84
106
 
85
107
  ```python
86
108
  from mycode import Agent, tool
@@ -88,7 +110,11 @@ from mycode import Agent, tool
88
110
 
89
111
  @tool
90
112
  def greet(name: str) -> str:
91
- """Return a friendly greeting."""
113
+ """Return a friendly greeting.
114
+
115
+ Args:
116
+ name: Person name.
117
+ """
92
118
 
93
119
  return f"hello, {name}"
94
120
 
@@ -100,7 +126,7 @@ agent = Agent(
100
126
  )
101
127
  ```
102
128
 
103
- To call a built-in tool from inside your own, type the first parameter as `ToolContext`:
129
+ To call a built-in tool from inside your own tool, type the first parameter as `ToolContext`:
104
130
 
105
131
  ```python
106
132
  from mycode import ToolContext, tool
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "mycode-sdk"
7
- version = "0.8.9"
7
+ version = "0.9.0"
8
8
  description = "Lightweight Python SDK for building AI agents."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -25,7 +25,9 @@ keywords = ["agent", "llm", "anthropic", "openai", "gemini", "sdk"]
25
25
  dependencies = [
26
26
  "anthropic>=0.102.0",
27
27
  "google-genai>=2.3.0",
28
+ "griffelib>=2.0.2",
28
29
  "openai>=2.36.0",
30
+ "pydantic>=2.13.4",
29
31
  ]
30
32
 
31
33
  [project.urls]
@@ -10,6 +10,7 @@ silently exposing file system and shell access.
10
10
  from importlib import metadata
11
11
 
12
12
  from mycode.agent import Agent, Event, PersistCallback, RunResult
13
+ from mycode.attachments import Attachment
13
14
  from mycode.hooks import AfterToolHook, BeforeToolHook, HookResult, Hooks, ToolHookContext
14
15
  from mycode.messages import (
15
16
  ContentBlock,
@@ -27,25 +28,25 @@ from mycode.messages import (
27
28
  )
28
29
  from mycode.session import SessionStore
29
30
  from mycode.tools import (
30
- DEFAULT_TOOL_SPECS,
31
31
  ToolContext,
32
32
  ToolExecutionResult,
33
33
  ToolExecutor,
34
34
  ToolSpec,
35
+ bash_tool,
35
36
  cancel_all_tools,
37
+ edit_tool,
38
+ read_tool,
36
39
  tool,
40
+ write_tool,
37
41
  )
38
42
 
39
- # The package metadata in mycode/pyproject.toml is the single version source.
40
43
  __version__ = metadata.version("mycode-sdk")
41
44
 
42
- read_tool, write_tool, edit_tool, bash_tool = DEFAULT_TOOL_SPECS
43
-
44
45
  __all__ = [
45
46
  "Agent",
47
+ "Attachment",
46
48
  "ContentBlock",
47
49
  "ConversationMessage",
48
- "DEFAULT_TOOL_SPECS",
49
50
  "Event",
50
51
  "AfterToolHook",
51
52
  "BeforeToolHook",
@@ -18,10 +18,10 @@ from pathlib import Path
18
18
  from typing import Any, cast
19
19
  from uuid import uuid4
20
20
 
21
+ from mycode.attachments import AttachmentLike, build_attachment_blocks
21
22
  from mycode.compact import (
22
23
  COMPACT_SUMMARY_PROMPT,
23
24
  DEFAULT_COMPACT_THRESHOLD,
24
- apply_compact_replay,
25
25
  build_compact_event,
26
26
  should_compact,
27
27
  )
@@ -79,6 +79,7 @@ class Agent:
79
79
  messages: list[ConversationMessage] | None = None,
80
80
  max_turns: int | None = None,
81
81
  max_tokens: int | None = None,
82
+ temperature: float = 1.0,
82
83
  context_window: int | None = None,
83
84
  compact_threshold: float | None = None,
84
85
  reasoning_effort: str | None = None,
@@ -111,6 +112,16 @@ class Agent:
111
112
  self.api_key = api_key
112
113
  self.api_base = api_base
113
114
  self.max_turns = max_turns
115
+ if not 0 <= temperature <= 1:
116
+ raise ValueError("temperature must be between 0 and 1")
117
+ if (
118
+ provider in {"anthropic", "moonshotai", "minimax"}
119
+ and reasoning_effort
120
+ and reasoning_effort != "none"
121
+ and temperature != 1.0
122
+ ):
123
+ raise ValueError(f"{provider} does not support custom temperature when thinking is enabled")
124
+ self.temperature = float(temperature)
114
125
  self.compact_threshold = compact_threshold if compact_threshold is not None else DEFAULT_COMPACT_THRESHOLD
115
126
  self.reasoning_effort = reasoning_effort
116
127
 
@@ -431,6 +442,7 @@ class Agent:
431
442
  self,
432
443
  user_input: str | ConversationMessage,
433
444
  *,
445
+ attachments: Sequence[AttachmentLike] = (),
434
446
  on_persist: PersistCallback | None = None,
435
447
  ) -> AsyncIterator[Event]:
436
448
  """Run the full agent loop for one user message."""
@@ -462,6 +474,10 @@ class Agent:
462
474
  if isinstance(raw_meta, dict):
463
475
  user_message["meta"] = {str(k): v for k, v in raw_meta.items()}
464
476
 
477
+ if attachments:
478
+ blocks = await asyncio.to_thread(build_attachment_blocks, attachments, cwd=self.cwd)
479
+ user_message["content"] = list(user_message.get("content") or []) + blocks
480
+
465
481
  content_blocks = user_message.get("content") or []
466
482
  if not self.supports_image_input and any(
467
483
  isinstance(block, dict) and block.get("type") == "image" for block in content_blocks
@@ -495,15 +511,17 @@ class Agent:
495
511
  provider=self.provider,
496
512
  model=self.model,
497
513
  session_id=self.session_id,
498
- messages=apply_compact_replay(self.messages, transcript_path=self.transcript_path),
514
+ messages=self.messages,
499
515
  system=self.system,
500
516
  tools=self.tools.definitions,
501
517
  max_tokens=self.max_tokens,
518
+ temperature=self.temperature,
502
519
  api_key=self.api_key,
503
520
  api_base=self.api_base,
504
521
  reasoning_effort=self.reasoning_effort,
505
522
  supports_image_input=self.supports_image_input,
506
523
  supports_pdf_input=self.supports_pdf_input,
524
+ transcript_path=self.transcript_path,
507
525
  )
508
526
 
509
527
  try:
@@ -682,6 +700,7 @@ class Agent:
682
700
  self,
683
701
  user_input: str | ConversationMessage,
684
702
  *,
703
+ attachments: Sequence[AttachmentLike] = (),
685
704
  on_persist: PersistCallback | None = None,
686
705
  ) -> RunResult:
687
706
  """Run one user turn synchronously and collect the streamed result."""
@@ -695,7 +714,7 @@ class Agent:
695
714
 
696
715
  async def collect() -> RunResult:
697
716
  result = RunResult()
698
- async for event in self.achat(user_input, on_persist=on_persist):
717
+ async for event in self.achat(user_input, attachments=attachments, on_persist=on_persist):
699
718
  result.events.append(event)
700
719
  if event.type == "text":
701
720
  result.text += str(event.data.get("delta") or "")
@@ -716,21 +735,21 @@ class Agent:
716
735
  ) -> None:
717
736
  """Ask the provider for a summary, persist the compact event, append it."""
718
737
 
719
- compact_messages = apply_compact_replay(self.messages, transcript_path=self.transcript_path) + [
720
- user_text_message(COMPACT_SUMMARY_PROMPT)
721
- ]
722
738
  request = ProviderRequest(
723
739
  provider=self.provider,
724
740
  model=self.model,
725
741
  session_id=self.session_id,
726
- messages=compact_messages,
742
+ messages=self.messages,
727
743
  system=self.system,
728
744
  tools=[],
729
745
  max_tokens=min(self.max_tokens, 8192),
746
+ temperature=self.temperature,
730
747
  api_key=self.api_key,
731
748
  api_base=self.api_base,
732
749
  supports_image_input=self.supports_image_input,
733
750
  supports_pdf_input=self.supports_pdf_input,
751
+ transcript_path=self.transcript_path,
752
+ append_messages=[user_text_message(COMPACT_SUMMARY_PROMPT)],
734
753
  )
735
754
 
736
755
  summary_message: ConversationMessage | None = None
@@ -0,0 +1,175 @@
1
+ """Attachment input for user messages.
2
+
3
+ Conversion rules in one place so the SDK, CLI, and HTTP server all produce the
4
+ same content blocks for the same input.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import html
10
+ from base64 import b64encode
11
+ from collections.abc import Sequence
12
+ from dataclasses import dataclass
13
+ from mimetypes import guess_type
14
+ from pathlib import Path
15
+ from typing import Self
16
+
17
+ from mycode.messages import ContentBlock, document_block, image_block, text_block
18
+ from mycode.utils import resolve_path
19
+
20
+ SUPPORTED_IMAGE_MIME_TYPES = frozenset({"image/png", "image/jpeg", "image/gif", "image/webp"})
21
+ SUPPORTED_DOCUMENT_MIME_TYPES = frozenset({"application/pdf"})
22
+
23
+
24
+ @dataclass(frozen=True, kw_only=True)
25
+ class Attachment:
26
+ """File path, raw bytes, or text snippet attached to a user message.
27
+
28
+ Construct via ``Attachment.path`` / ``.bytes`` / ``.text``; ``kw_only`` stops a
29
+ bare ``Attachment("notes.txt")`` from silently becoming text instead of a path.
30
+ """
31
+
32
+ source: Path | bytes | str
33
+ media_type: str | None = None
34
+ name: str | None = None
35
+
36
+ @classmethod
37
+ def path(cls, path: str | Path, *, name: str | None = None) -> Self:
38
+ """``name`` defaults to the file's basename when omitted."""
39
+
40
+ return cls(source=Path(path), name=name)
41
+
42
+ @classmethod
43
+ def bytes(cls, data: bytes, *, media_type: str, name: str | None = None) -> Self:
44
+ """``media_type`` must be a supported image type or ``application/pdf``."""
45
+
46
+ if not media_type:
47
+ raise ValueError("Attachment.bytes requires a non-empty media_type")
48
+ return cls(source=bytes(data), media_type=media_type, name=name)
49
+
50
+ @classmethod
51
+ def text(cls, data: str, *, name: str) -> Self:
52
+ """Wrapped as a named ``<file>`` snippet in the user message."""
53
+
54
+ if not name:
55
+ raise ValueError("Attachment.text requires a non-empty name")
56
+ return cls(source=data, name=name)
57
+
58
+
59
+ AttachmentLike = str | Path | Attachment
60
+
61
+
62
+ def build_attachment_blocks(
63
+ attachments: Sequence[AttachmentLike],
64
+ *,
65
+ cwd: str,
66
+ ) -> list[ContentBlock]:
67
+ """Return one content block per attachment, in input order.
68
+
69
+ ``str`` / ``Path`` items are treated as ``Attachment.path``. Raises
70
+ ``ValueError`` on a missing path, a directory, a binary file that is
71
+ neither image nor PDF, undecodable text, or an unsupported ``media_type``.
72
+ """
73
+
74
+ blocks: list[ContentBlock] = []
75
+ for item in attachments:
76
+ att = item if isinstance(item, Attachment) else Attachment.path(item)
77
+ match att.source:
78
+ case bytes() as data:
79
+ media_type = att.media_type or ""
80
+ if media_type in SUPPORTED_IMAGE_MIME_TYPES:
81
+ blocks.append(image_block(b64encode(data).decode("ascii"), mime_type=media_type, name=att.name))
82
+ elif media_type in SUPPORTED_DOCUMENT_MIME_TYPES:
83
+ blocks.append(document_block(b64encode(data).decode("ascii"), mime_type=media_type, name=att.name))
84
+ else:
85
+ supported = sorted(SUPPORTED_IMAGE_MIME_TYPES | SUPPORTED_DOCUMENT_MIME_TYPES)
86
+ raise ValueError(f"unsupported media_type {media_type!r}; want one of {supported}")
87
+ case Path() as raw:
88
+ path = Path(resolve_path(str(raw), cwd=cwd))
89
+ if not path.exists():
90
+ raise ValueError(f"attachment not found: {raw}")
91
+ if path.is_dir():
92
+ raise ValueError(f"attachment is a directory: {raw}")
93
+ display = att.name or path.name
94
+ if mime := detect_image_mime_type(path):
95
+ blocks.append(
96
+ image_block(b64encode(path.read_bytes()).decode("ascii"), mime_type=mime, name=display)
97
+ )
98
+ elif mime := detect_document_mime_type(path):
99
+ blocks.append(
100
+ document_block(b64encode(path.read_bytes()).decode("ascii"), mime_type=mime, name=display)
101
+ )
102
+ else:
103
+ try:
104
+ text = path.read_text(encoding="utf-8")
105
+ except UnicodeDecodeError as exc:
106
+ raise ValueError(f"unsupported attachment {raw}: not image, PDF, or UTF-8 text") from exc
107
+ blocks.append(_wrap_file(text, display))
108
+ case str() as text:
109
+ blocks.append(_wrap_file(text, att.name or ""))
110
+ return blocks
111
+
112
+
113
+ def unsupported_attachment_block(
114
+ *,
115
+ name: str,
116
+ mime_type: str,
117
+ kind: str,
118
+ path: str | None = None,
119
+ ) -> ContentBlock:
120
+ """Text-block stand-in for an image/PDF block a model can't ingest.
121
+
122
+ Pass ``path`` (e.g. the original ``@path`` typed by the user) to keep it on
123
+ ``meta.path`` for UI labels; omit it when replaying history where the
124
+ on-disk path is no longer authoritative.
125
+ """
126
+
127
+ label = "image input" if kind == "image" else "PDF input"
128
+ safe_name = html.escape(name, quote=True)
129
+ safe_mime = html.escape(mime_type, quote=True)
130
+ body = f"Current model does not support {label}."
131
+ meta: dict[str, object] = {"attachment": True}
132
+ if path is not None:
133
+ meta["path"] = path
134
+ return text_block(
135
+ f'<file name="{safe_name}" media_type="{safe_mime}" kind="{kind}">{body}</file>',
136
+ meta=meta,
137
+ )
138
+
139
+
140
+ def detect_image_mime_type(path: Path) -> str | None:
141
+ """Sniff PNG/JPEG/GIF/WebP magic bytes; fall back to the filename extension."""
142
+
143
+ try:
144
+ with path.open("rb") as file:
145
+ header = file.read(16)
146
+ except OSError:
147
+ return None
148
+ if header.startswith(b"\x89PNG\r\n\x1a\n"):
149
+ return "image/png"
150
+ if header.startswith(b"\xff\xd8\xff"):
151
+ return "image/jpeg"
152
+ if header.startswith((b"GIF87a", b"GIF89a")):
153
+ return "image/gif"
154
+ if header.startswith(b"RIFF") and header[8:12] == b"WEBP":
155
+ return "image/webp"
156
+ guessed, _ = guess_type(path.name)
157
+ return guessed if guessed in SUPPORTED_IMAGE_MIME_TYPES else None
158
+
159
+
160
+ def detect_document_mime_type(path: Path) -> str | None:
161
+ """Return ``application/pdf`` if the file is a PDF (by magic bytes or extension)."""
162
+
163
+ try:
164
+ with path.open("rb") as file:
165
+ if file.read(5).startswith(b"%PDF-"):
166
+ return "application/pdf"
167
+ except OSError:
168
+ pass
169
+ guessed, _ = guess_type(path.name)
170
+ return "application/pdf" if guessed == "application/pdf" else None
171
+
172
+
173
+ def _wrap_file(text: str, name: str) -> ContentBlock:
174
+ safe = html.escape(name, quote=True)
175
+ return text_block(f'<file name="{safe}">\n{text}\n</file>', meta={"attachment": True, "path": name})