mycode-sdk 0.8.7__tar.gz → 0.8.8__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.7 → mycode_sdk-0.8.8}/PKG-INFO +4 -4
  2. {mycode_sdk-0.8.7 → mycode_sdk-0.8.8}/pyproject.toml +4 -4
  3. {mycode_sdk-0.8.7 → mycode_sdk-0.8.8}/src/mycode/__init__.py +0 -3
  4. {mycode_sdk-0.8.7 → mycode_sdk-0.8.8}/src/mycode/agent.py +8 -20
  5. {mycode_sdk-0.8.7 → mycode_sdk-0.8.8}/src/mycode/messages.py +13 -18
  6. {mycode_sdk-0.8.7 → mycode_sdk-0.8.8}/src/mycode/models.py +1 -6
  7. {mycode_sdk-0.8.7 → mycode_sdk-0.8.8}/src/mycode/providers/anthropic_like.py +10 -17
  8. {mycode_sdk-0.8.7 → mycode_sdk-0.8.8}/src/mycode/providers/base.py +7 -9
  9. {mycode_sdk-0.8.7 → mycode_sdk-0.8.8}/src/mycode/providers/gemini.py +2 -1
  10. {mycode_sdk-0.8.7 → mycode_sdk-0.8.8}/src/mycode/providers/openai_chat.py +7 -6
  11. {mycode_sdk-0.8.7 → mycode_sdk-0.8.8}/src/mycode/providers/openai_responses.py +2 -1
  12. {mycode_sdk-0.8.7 → mycode_sdk-0.8.8}/src/mycode/session.py +6 -10
  13. {mycode_sdk-0.8.7 → mycode_sdk-0.8.8}/src/mycode/tools.py +27 -67
  14. {mycode_sdk-0.8.7 → mycode_sdk-0.8.8}/.gitignore +0 -0
  15. {mycode_sdk-0.8.7 → mycode_sdk-0.8.8}/LICENSE +0 -0
  16. {mycode_sdk-0.8.7 → mycode_sdk-0.8.8}/README.md +0 -0
  17. {mycode_sdk-0.8.7 → mycode_sdk-0.8.8}/src/mycode/compact.py +0 -0
  18. {mycode_sdk-0.8.7 → mycode_sdk-0.8.8}/src/mycode/hooks.py +0 -0
  19. {mycode_sdk-0.8.7 → mycode_sdk-0.8.8}/src/mycode/models_catalog.json +0 -0
  20. {mycode_sdk-0.8.7 → mycode_sdk-0.8.8}/src/mycode/providers/__init__.py +0 -0
  21. {mycode_sdk-0.8.7 → mycode_sdk-0.8.8}/src/mycode/py.typed +0 -0
  22. {mycode_sdk-0.8.7 → mycode_sdk-0.8.8}/src/mycode/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mycode-sdk
3
- Version: 0.8.7
3
+ Version: 0.8.8
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,9 +18,9 @@ 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.74.0
22
- Requires-Dist: google-genai>=1.68.0
23
- Requires-Dist: openai>=2.11.0
21
+ Requires-Dist: anthropic>=0.102.0
22
+ Requires-Dist: google-genai>=2.3.0
23
+ Requires-Dist: openai>=2.36.0
24
24
  Description-Content-Type: text/markdown
25
25
 
26
26
  # mycode-sdk
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "mycode-sdk"
7
- version = "0.8.7"
7
+ version = "0.8.8"
8
8
  description = "Lightweight Python SDK for building AI agents."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -23,9 +23,9 @@ classifiers = [
23
23
  ]
24
24
  keywords = ["agent", "llm", "anthropic", "openai", "gemini", "sdk"]
25
25
  dependencies = [
26
- "anthropic>=0.74.0",
27
- "google-genai>=1.68.0",
28
- "openai>=2.11.0",
26
+ "anthropic>=0.102.0",
27
+ "google-genai>=2.3.0",
28
+ "openai>=2.36.0",
29
29
  ]
30
30
 
31
31
  [project.urls]
@@ -39,9 +39,6 @@ from mycode.tools import (
39
39
  # The package metadata in mycode/pyproject.toml is the single version source.
40
40
  __version__ = metadata.version("mycode-sdk")
41
41
 
42
- # Built-in tool specs exposed as module-level constants so callers can pick
43
- # which ones to register (``tools=[read_tool, bash_tool]``) rather than
44
- # getting all four by default.
45
42
  read_tool, write_tool, edit_tool, bash_tool = DEFAULT_TOOL_SPECS
46
43
 
47
44
  __all__ = [
@@ -136,10 +136,9 @@ class Agent:
136
136
  raise ValueError(msg)
137
137
  self.messages: list[ConversationMessage] = list(messages)
138
138
 
139
- # Tool runtime: one executor per agent. ``tool_output_dir`` is where
140
- # tools can drop artifacts defaults to a session-adjacent directory
141
- # so logs (e.g. bash spill files) live next to the session JSONL.
142
- # When no session is configured, use a tempdir scoped to session_id.
139
+ # ``tool_output_dir`` defaults to a session-adjacent directory so logs
140
+ # (e.g. bash spill files) live next to the session JSONL; without a
141
+ # session, fall back to a tempdir scoped to ``session_id``.
143
142
  if session_dir is not None:
144
143
  tool_output_dir = session_dir / self.session_id / "tool-output"
145
144
  else:
@@ -434,21 +433,12 @@ class Agent:
434
433
  *,
435
434
  on_persist: PersistCallback | None = None,
436
435
  ) -> AsyncIterator[Event]:
437
- """Run the full agent loop for one user message.
438
-
439
- Each turn asks the provider for one assistant message. If the assistant
440
- requests tools, the agent runs them locally, appends one user-side
441
- tool_result message, and continues until the assistant stops using tools.
442
-
443
- When a ``session_dir`` is configured, every emitted message is appended
444
- to the on-disk session log. ``on_persist`` fires *before* that append
445
- regardless of whether a store is configured, so callers can plug in a
446
- custom backend or stage related records (the web server uses it to
447
- land a rewind marker first).
448
- """
436
+ """Run the full agent loop for one user message."""
449
437
 
450
438
  async def persist(message: ConversationMessage) -> None:
451
439
  if on_persist is not None:
440
+ # Callers may need to write related records before the SDK
441
+ # appends this message to its own session log.
452
442
  await on_persist(message)
453
443
  if self._store is None:
454
444
  return
@@ -517,7 +507,6 @@ class Agent:
517
507
  )
518
508
 
519
509
  try:
520
- # Phase 1: ask the provider for exactly one assistant turn.
521
510
  async for provider_event in self._stream_provider_turn(adapter, request):
522
511
  if self._cancel_event.is_set():
523
512
  provider_cancelled = True
@@ -675,8 +664,8 @@ class Agent:
675
664
  yield Event("error", {"message": "cancelled"})
676
665
  return
677
666
  except Exception:
678
- # Best-effort: transient failures retry next threshold check;
679
- # persistent ones surface from phase 1 of the next turn.
667
+ # Compaction must not block the current answer; the full
668
+ # transcript is still available for the next turn.
680
669
  logger.warning(
681
670
  "Context compaction failed, continuing without compaction",
682
671
  exc_info=True,
@@ -686,7 +675,6 @@ class Agent:
686
675
  break
687
676
 
688
677
  else:
689
- # while loop exhausted max_turns without breaking
690
678
  yield Event("error", {"message": "max_turns reached"})
691
679
  return
692
680
 
@@ -1,23 +1,18 @@
1
1
  """Internal conversation model shared by the runtime, session store, CLI, and UI.
2
2
 
3
- The runtime persists a single message shape everywhere:
4
-
5
- - user message: text blocks, image blocks, document blocks, and tool_result blocks
6
- - assistant message: thinking blocks, text blocks, and tool_use blocks
7
-
8
- Provider adapters translate between this internal shape and provider-specific wire
9
- formats. The agent loop and session store should never need to know provider wire
10
- details.
11
-
12
- Metadata contract:
13
-
14
- - assistant message `meta` keeps normalized top-level fields only:
15
- `provider`, `model`, `provider_message_id`, `stop_reason`, `total_tokens`,
16
- `context_window` (see docs/sessions.md for `total_tokens` semantics)
17
- - provider-specific assistant message extras live under `meta.native`
18
- - provider-specific block replay hints live under `block.meta.native`
19
- - local display metadata, such as `block.meta.duration_ms`, is never sent
20
- upstream; provider adapters must explicitly project only supported fields
3
+ Provider adapters translate this shape to and from provider-specific wire
4
+ formats so the agent loop and session store stay provider-agnostic.
5
+
6
+ Metadata layout:
7
+
8
+ - assistant message ``meta`` keeps only normalized top-level fields:
9
+ ``provider``, ``model``, ``provider_message_id``, ``stop_reason``,
10
+ ``total_tokens``, ``context_window`` (see docs/sessions.md for
11
+ ``total_tokens`` semantics)
12
+ - provider-specific extras live under ``meta.native`` on messages and
13
+ ``block.meta.native`` on blocks
14
+ - local display metadata such as ``block.meta.duration_ms`` is never sent
15
+ upstream
21
16
  """
22
17
 
23
18
  from __future__ import annotations
@@ -42,12 +42,7 @@ def load_models_catalog() -> dict[str, Any] | None:
42
42
 
43
43
 
44
44
  def infer_provider_from_model(model: str | None) -> str | None:
45
- """Return the canonical built-in provider id for a known model id, else None.
46
-
47
- Recognizes well-known prefixes on bare model ids and ``provider/model``
48
- ids alike. Returns ``None`` for unknown ids — callers should require an
49
- explicit provider in that case rather than guess.
50
- """
45
+ """Return the canonical built-in provider id for a known model id, else None."""
51
46
 
52
47
  bare = (model or "").strip().split("/", 1)[-1].strip().lower()
53
48
  if bare.startswith("claude-"):
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import hashlib
6
6
  from collections.abc import AsyncIterator
7
- from typing import Any, cast
7
+ from typing import Any, cast, override
8
8
 
9
9
  from anthropic import APIError, AsyncAnthropic
10
10
 
@@ -32,19 +32,7 @@ _THINKING_BUDGETS: dict[str, int] = {"low": 2048, "medium": 8192, "high": 24576,
32
32
 
33
33
 
34
34
  class AnthropicLikeAdapter(ProviderAdapter):
35
- """Shared Messages adapter for Anthropic-compatible providers.
36
-
37
- Anthropic, Moonshot, and MiniMax all document agent usage around the
38
- Anthropic Messages protocol. The differences we care about are limited to:
39
-
40
- - default base URL
41
- - API key env var names
42
- - optional thinking defaults
43
- - provider-native metadata carried in content blocks
44
-
45
- MiniMax requires the full assistant content (all blocks) to be sent on
46
- multi-turn tool-loop requests — not just the text portion.
47
- """
35
+ """Shared Messages adapter for Anthropic-compatible providers."""
48
36
 
49
37
  def thinking_config(self, request: ProviderRequest) -> dict[str, Any] | None:
50
38
  del request
@@ -90,6 +78,7 @@ class AnthropicLikeAdapter(ProviderAdapter):
90
78
  payload["output_config"] = output_config
91
79
  return payload
92
80
 
81
+ @override
93
82
  def project_tool_call_id(self, tool_call_id: str, used_tool_call_ids: set[str]) -> str:
94
83
  """Return a short ASCII ID without introducing collisions.
95
84
 
@@ -138,6 +127,7 @@ class AnthropicLikeAdapter(ProviderAdapter):
138
127
 
139
128
  return
140
129
 
130
+ @override
141
131
  async def stream_turn(self, request: ProviderRequest) -> AsyncIterator[ProviderStreamEvent]:
142
132
  api_key = self.require_api_key(request.api_key)
143
133
  client = AsyncAnthropic(
@@ -326,6 +316,7 @@ class AnthropicAdapter(AnthropicLikeAdapter):
326
316
  default_models = ("claude-sonnet-4-6", "claude-opus-4-7")
327
317
  supports_reasoning_effort = True
328
318
 
319
+ @override
329
320
  def thinking_config(self, request: ProviderRequest) -> dict[str, Any] | None:
330
321
  effort = request.reasoning_effort
331
322
  if not effort:
@@ -340,6 +331,7 @@ class AnthropicAdapter(AnthropicLikeAdapter):
340
331
  return thinking
341
332
  return self.manual_thinking_config(effort)
342
333
 
334
+ @override
343
335
  def output_config(self, request: ProviderRequest) -> dict[str, Any] | None:
344
336
  effort = request.reasoning_effort
345
337
  if not effort or effort == "none":
@@ -363,9 +355,8 @@ class AnthropicAdapter(AnthropicLikeAdapter):
363
355
  class MoonshotAIAdapter(AnthropicLikeAdapter):
364
356
  """Moonshot's Anthropic-compatible Messages endpoint.
365
357
 
366
- kimi-k2.6 tool loops work through this endpoint. When thinking is enabled,
367
- prior reasoning blocks must be replayed in the conversation history —
368
- Moonshot does not strip them on the server side.
358
+ When thinking is enabled, prior reasoning blocks must be replayed in the
359
+ conversation history Moonshot does not strip them on the server side.
369
360
  """
370
361
 
371
362
  provider_id = "moonshotai"
@@ -375,6 +366,7 @@ class MoonshotAIAdapter(AnthropicLikeAdapter):
375
366
  default_models = ("kimi-k2.6",)
376
367
  supports_reasoning_effort = True
377
368
 
369
+ @override
378
370
  def thinking_config(self, request: ProviderRequest) -> dict[str, Any] | None:
379
371
  return self.manual_thinking_config(request.reasoning_effort)
380
372
 
@@ -394,5 +386,6 @@ class MiniMaxAdapter(AnthropicLikeAdapter):
394
386
  default_models = ("MiniMax-M2.7", "MiniMax-M2.7-highspeed")
395
387
  supports_reasoning_effort = True
396
388
 
389
+ @override
397
390
  def thinking_config(self, request: ProviderRequest) -> dict[str, Any] | None:
398
391
  return self.manual_thinking_config(request.reasoning_effort)
@@ -1,14 +1,8 @@
1
1
  """Shared provider adapter interfaces.
2
2
 
3
- The agent loop talks to providers through a small normalized contract:
4
-
5
- - input: `ProviderRequest`
6
- - output: streamed `ProviderStreamEvent` objects
7
-
8
- Concrete adapters are free to use the official SDK or protocol that best matches
9
- their upstream provider. Each adapter is also responsible for projecting the
10
- canonical session transcript into a provider-safe replay history before a new
11
- request is sent upstream.
3
+ Each adapter implements `stream_turn()` and reuses `prepare_messages()` to
4
+ project the canonical session transcript into a provider-safe replay history
5
+ before sending a request upstream.
12
6
  """
13
7
 
14
8
  from __future__ import annotations
@@ -205,6 +199,8 @@ def repair_messages_for_replay(
205
199
  block_type = raw_block.get("type")
206
200
  if block_type in {"text", "thinking"}:
207
201
  text = str(raw_block.get("text") or "")
202
+ # Empty native thinking blocks may still carry signatures or
203
+ # provider replay state.
208
204
  if text or get_native_meta(raw_block):
209
205
  content.append(dict(raw_block))
210
206
  continue
@@ -256,6 +252,8 @@ def repair_messages_for_replay(
256
252
  if supported:
257
253
  content.append(dict(raw_block))
258
254
  else:
255
+ # Preserve that the file existed even when the target
256
+ # provider cannot accept the original media payload.
259
257
  default_mime = "image" if block_type == "image" else "application/pdf"
260
258
  label = "image input" if block_type == "image" else "PDF input"
261
259
  name = html.escape(str(raw_block.get("name") or f"attached-{block_type}"), quote=True)
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from collections.abc import AsyncIterator
6
- from typing import Any
6
+ from typing import Any, override
7
7
  from urllib.parse import urlparse
8
8
 
9
9
  from google import genai
@@ -51,6 +51,7 @@ class GoogleGeminiAdapter(ProviderAdapter):
51
51
  default_models = ("gemini-3.1-pro-preview", "gemini-3-flash-preview")
52
52
  supports_reasoning_effort = True
53
53
 
54
+ @override
54
55
  async def stream_turn(self, request: ProviderRequest) -> AsyncIterator[ProviderStreamEvent]:
55
56
  api_key = self.require_api_key(request.api_key)
56
57
  client = genai.Client(api_key=api_key, http_options=self._http_options(request.api_base))
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  from collections.abc import AsyncIterator
7
7
  from dataclasses import dataclass
8
- from typing import Any
8
+ from typing import Any, override
9
9
 
10
10
  from openai import APIError, AsyncOpenAI
11
11
 
@@ -42,6 +42,7 @@ class OpenAIChatAdapter(ProviderAdapter):
42
42
  env_api_key_names = ("OPENAI_API_KEY",)
43
43
  auto_discoverable = False
44
44
 
45
+ @override
45
46
  async def stream_turn(self, request: ProviderRequest) -> AsyncIterator[ProviderStreamEvent]:
46
47
  api_key = self.require_api_key(request.api_key)
47
48
  client = AsyncOpenAI(
@@ -50,8 +51,6 @@ class OpenAIChatAdapter(ProviderAdapter):
50
51
  timeout=DEFAULT_REQUEST_TIMEOUT,
51
52
  )
52
53
 
53
- # Keep the streamed turn state local to this adapter so the wire-format
54
- # mapping stays readable in one file.
55
54
  tool_calls: dict[int, _ChatToolCallState] = {}
56
55
  text_parts: list[str] = []
57
56
  thinking_parts: list[str] = []
@@ -338,6 +337,7 @@ class DeepSeekAdapter(OpenAIChatAdapter):
338
337
  auto_discoverable = True
339
338
  supports_reasoning_effort = True
340
339
 
340
+ @override
341
341
  def _build_provider_payload_overrides(self, request: ProviderRequest) -> dict[str, Any]:
342
342
  if request.reasoning_effort == "none":
343
343
  return {"extra_body": {"thinking": {"type": "disabled"}}}
@@ -353,9 +353,8 @@ class DeepSeekAdapter(OpenAIChatAdapter):
353
353
  class ZAIAdapter(OpenAIChatAdapter):
354
354
  """Z.AI's OpenAI-compatible chat endpoint.
355
355
 
356
- GLM models think by default. We still send the explicit thinking parameter
357
- so that clear_thinking=False preserves reasoning across multi-turn tool loops
358
- instead of resetting it on each turn.
356
+ GLM models think by default. The explicit thinking parameter is sent only
357
+ so ``clear_thinking=False`` preserves reasoning across multi-turn tool loops.
359
358
  """
360
359
 
361
360
  provider_id = "zai"
@@ -365,6 +364,7 @@ class ZAIAdapter(OpenAIChatAdapter):
365
364
  default_models = ("glm-5.1", "glm-5-turbo")
366
365
  auto_discoverable = True
367
366
 
367
+ @override
368
368
  def _build_provider_payload_overrides(self, request: ProviderRequest) -> dict[str, Any]:
369
369
  return {"extra_body": {"thinking": {"type": "enabled", "clear_thinking": False}}}
370
370
 
@@ -380,6 +380,7 @@ class OpenRouterAdapter(OpenAIChatAdapter):
380
380
  auto_discoverable = True
381
381
  supports_reasoning_effort = True
382
382
 
383
+ @override
383
384
  def _build_provider_payload_overrides(self, request: ProviderRequest) -> dict[str, Any]:
384
385
  if not request.reasoning_effort:
385
386
  return {}
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  from collections.abc import AsyncIterator
7
7
  from copy import deepcopy
8
- from typing import Any, cast
8
+ from typing import Any, cast, override
9
9
 
10
10
  from openai import APIError, AsyncOpenAI
11
11
 
@@ -33,6 +33,7 @@ class OpenAIResponsesAdapter(ProviderAdapter):
33
33
  default_models = ("gpt-5.5", "gpt-5.4-mini")
34
34
  supports_reasoning_effort = True
35
35
 
36
+ @override
36
37
  async def stream_turn(self, request: ProviderRequest) -> AsyncIterator[ProviderStreamEvent]:
37
38
  api_key = self.require_api_key(request.api_key)
38
39
  client = AsyncOpenAI(
@@ -20,23 +20,19 @@ from typing import TypedDict, cast
20
20
 
21
21
  from mycode.messages import ConversationMessage, flatten_message_text
22
22
 
23
- # ---------------------------------------------------------------------
24
- # Session format defaults
25
- # ---------------------------------------------------------------------
26
-
27
23
  MESSAGE_FORMAT_VERSION = 7
28
24
  DEFAULT_SESSION_TITLE = "New chat"
29
25
 
30
26
 
31
- # ---------------------------------------------------------------------
32
- # Rewind session events
33
- # ---------------------------------------------------------------------
34
-
35
-
36
27
  def _now() -> str:
37
28
  return datetime.now(UTC).isoformat()
38
29
 
39
30
 
31
+ # ---------------------------------------------------------------------
32
+ # Rewind markers
33
+ # ---------------------------------------------------------------------
34
+
35
+
40
36
  def build_rewind_event(rewind_to: int) -> ConversationMessage:
41
37
  """Build a rewind marker to append to session JSONL."""
42
38
 
@@ -112,7 +108,7 @@ class SessionStore:
112
108
  self.data_dir.mkdir(parents=True, exist_ok=True)
113
109
 
114
110
  # ---------------------------------------------------------------------
115
- # Session paths
111
+ # Paths and meta I/O
116
112
  # ---------------------------------------------------------------------
117
113
 
118
114
  def session_dir(self, session_id: str) -> Path:
@@ -1,14 +1,9 @@
1
1
  """Tool execution runtime.
2
2
 
3
- The SDK ships four built-in coding tools (``read`` / ``write`` / ``edit`` /
4
- ``bash``) as :data:`DEFAULT_TOOL_SPECS`. SDK users register additional tools
5
- either by building :class:`ToolSpec` directly or by wrapping a typed function
6
- with :func:`tool`.
7
-
8
- Every tool runner receives a :class:`ToolContext` and a raw argument dict.
9
- Context exposes the runtime state (cwd, output directory, image support) plus
10
- typed facades for the four built-ins so custom tools can invoke them without
11
- stringly-typed dispatch::
3
+ The four built-in tools (``read`` / ``write`` / ``edit`` / ``bash``) are
4
+ bundled as :data:`DEFAULT_TOOL_SPECS`. Custom tools wrap a typed Python
5
+ function with :func:`tool` and can invoke the built-ins through the facades
6
+ on :class:`ToolContext`::
12
7
 
13
8
  @tool
14
9
  def smart_read(ctx: ToolContext, path: str) -> str:
@@ -42,11 +37,10 @@ from mycode.messages import image_block, text_block
42
37
  # ---------------------------------------------------------------------------
43
38
  # Limits
44
39
  # ---------------------------------------------------------------------------
45
- # read
40
+
46
41
  DEFAULT_MAX_LINES = 2000
47
42
  DEFAULT_MAX_BYTES = 50 * 1024
48
43
  READ_MAX_LINE_CHARS = 2000
49
- # bash
50
44
  BASH_TIMEOUT_SECONDS = 120
51
45
  _BASH_MAX_IN_MEMORY_BYTES = 5_000_000
52
46
 
@@ -60,20 +54,13 @@ ToolOutputCallback = Callable[[str], None]
60
54
 
61
55
  @dataclass(frozen=True)
62
56
  class ToolExecutionResult:
63
- """Result of one tool run.
64
-
65
- Two consumers read this:
66
-
67
- - **provider** — gets ``output`` (a text summary) and, for multimodal
68
- returns, ``content`` (structured blocks such as an image). ``output``
69
- is replayed on later turns.
70
- - **UI** — reads ``metadata`` for structured rendering (e.g. edit patch
71
- and stats). When ``metadata`` is absent, the UI falls back to ``output``
72
- as a one-line summary.
73
- """
57
+ """Result of one tool run."""
74
58
 
59
+ # Replayed to providers on later turns.
75
60
  output: str
61
+ # Structured replay content, e.g. an image returned by a tool.
76
62
  content: list[dict[str, Any]] | None = None
63
+ # UI-only structured data such as edit patches and line stats.
77
64
  metadata: dict[str, Any] | None = None
78
65
  is_error: bool = False
79
66
 
@@ -83,18 +70,13 @@ ToolRunner = Callable[["ToolContext", dict[str, Any]], ToolExecutionResult]
83
70
 
84
71
  @dataclass(frozen=True)
85
72
  class ToolSpec:
86
- """One tool the agent can call.
87
-
88
- ``runner`` receives a :class:`ToolContext` and the raw argument dict.
89
- Tools that emit incremental output (``bash``) set ``streams_output=True``
90
- and push lines via ``ctx.emit``; the agent forwards them as
91
- ``tool_output`` events live during execution.
92
- """
73
+ """One tool the agent can call."""
93
74
 
94
75
  name: str
95
76
  description: str
96
77
  input_schema: dict[str, Any]
97
78
  runner: ToolRunner
79
+ # Streaming tools push incremental output through ToolContext.emit.
98
80
  streams_output: bool = False
99
81
 
100
82
 
@@ -107,26 +89,17 @@ class ToolSpec:
107
89
  class ToolContext:
108
90
  """Per-call runtime context handed to every tool runner.
109
91
 
110
- ``cwd`` / ``tool_output_dir`` / ``supports_image_input`` describe where
111
- the tool runs and what the model accepts. ``tool_call_id`` and ``emit``
112
- are set by the agent for live streaming; they are ``None`` when a tool
113
- is invoked outside the agent loop (e.g. TUI attachment read).
114
-
115
- The four facades :meth:`read`, :meth:`write`, :meth:`edit`, :meth:`bash`
116
- are the SDK-friendly way to invoke the built-in tools from custom code.
117
- :meth:`call` is a generic fallback for dispatching to any registered
118
- tool by name.
92
+ Includes typed facades for built-in tools and generic registry dispatch.
119
93
  """
120
94
 
121
95
  executor: ToolExecutor
122
96
  cwd: str
123
97
  tool_output_dir: Path
124
98
  supports_image_input: bool = False
99
+ # Only agent-loop calls have a provider tool-call id and live output sink.
125
100
  tool_call_id: str | None = None
126
101
  emit: ToolOutputCallback | None = None
127
102
 
128
- # Typed facades for the built-ins.
129
-
130
103
  def read(
131
104
  self,
132
105
  path: str,
@@ -146,14 +119,10 @@ class ToolContext:
146
119
  return self.call("bash", {"command": command, "timeout": timeout})
147
120
 
148
121
  def call(self, name: str, args: dict[str, Any]) -> ToolExecutionResult:
149
- """Dispatch to a tool by name. Used by the facades above and by
150
- custom tools that wrap another registered tool."""
122
+ """Dispatch through the registry, including from custom tool wrappers."""
151
123
 
152
124
  return self.executor.execute(name, args, self)
153
125
 
154
- # Subprocess hooks — used by bash to register/unregister its child so
155
- # ``Agent.cancel`` and ``cancel_all_tools`` can terminate it.
156
-
157
126
  def track_proc(self, proc: subprocess.Popen[str]) -> None:
158
127
  self.executor.track_proc(proc)
159
128
 
@@ -167,12 +136,7 @@ class ToolContext:
167
136
 
168
137
 
169
138
  class ToolExecutor:
170
- """Tool registry plus subprocess lifecycle for one agent session.
171
-
172
- Agents construct one executor internally; SDK users normally pass
173
- ``tools=[...]`` to :class:`~mycode.agent.Agent` rather than building
174
- this directly.
175
- """
139
+ """Tool registry plus subprocess lifecycle for one agent session."""
176
140
 
177
141
  def __init__(self, tools: Sequence[ToolSpec]):
178
142
  names = [spec.name for spec in tools]
@@ -184,8 +148,6 @@ class ToolExecutor:
184
148
 
185
149
  @property
186
150
  def definitions(self) -> list[dict[str, Any]]:
187
- """Provider-facing tool definitions (name / description / schema)."""
188
-
189
151
  return [
190
152
  {"name": spec.name, "description": spec.description, "input_schema": spec.input_schema}
191
153
  for spec in self._tools.values()
@@ -230,10 +192,9 @@ class ToolExecutor:
230
192
  # ---------------------------------------------------------------------------
231
193
  # Subprocess lifecycle (process-global)
232
194
  # ---------------------------------------------------------------------------
233
- # Bash registers each child in both the executor and this module-level set.
234
- # The executor set scopes cancellation to one session (multiple agents may
235
- # run concurrently in the same process); the global set is a shutdown-time
236
- # safety net exposed as ``cancel_all_tools``.
195
+ # The executor's tracking set scopes cancellation to one session (multiple
196
+ # agents may run concurrently in the same process); this module-level set is
197
+ # a shutdown-time safety net exposed as ``cancel_all_tools``.
237
198
 
238
199
  _ACTIVE_PROCS: set[subprocess.Popen[str]] = set()
239
200
  _ACTIVE_PROCS_LOCK = threading.Lock()
@@ -580,8 +541,6 @@ def _run_edit(ctx: ToolContext, args: dict[str, Any]) -> ToolExecutionResult:
580
541
  is_error=True,
581
542
  )
582
543
 
583
- # Fuzzy fallback: normalize both sides, find in normalized space,
584
- # but map the span back to the original text for replacement.
585
544
  if norm_text is None:
586
545
  norm_text, norm_imap = _normalize_text(text)
587
546
  norm_old, _ = _normalize_text(old_text)
@@ -604,6 +563,8 @@ def _run_edit(ctx: ToolContext, args: dict[str, Any]) -> ToolExecutionResult:
604
563
 
605
564
  idx = norm_text.find(norm_old)
606
565
  assert norm_imap is not None
566
+ # Normalized matching tolerates whitespace drift; replacement still
567
+ # uses original offsets so untouched content is preserved exactly.
607
568
  orig_start = norm_imap[idx]
608
569
  end_idx = idx + len(norm_old)
609
570
  orig_end = norm_imap[end_idx] if end_idx < len(norm_imap) else len(text)
@@ -1009,14 +970,11 @@ def tool(
1009
970
  description: str | None = None,
1010
971
  streams_output: bool = False,
1011
972
  ) -> ToolSpec | Callable[[Callable[..., Any]], ToolSpec]:
1012
- """Wrap a plain Python function as a :class:`ToolSpec`.
1013
-
1014
- Sync and async functions are both supported. If the first parameter is
1015
- annotated :class:`ToolContext`, the context is injected automatically
1016
- and the remaining parameters drive the JSON schema sent to the provider.
973
+ """Wrap a sync or async Python function as a :class:`ToolSpec`.
1017
974
 
1018
- The function may return a :class:`ToolExecutionResult` or any
1019
- JSON-serializable value; non-result values are wrapped as text output.
975
+ A first parameter annotated :class:`ToolContext` is injected automatically;
976
+ the remaining parameters drive the input schema. The function may return a
977
+ :class:`ToolExecutionResult` or any JSON-serializable value.
1020
978
  """
1021
979
 
1022
980
  def wrap(fn: Callable[..., Any]) -> ToolSpec:
@@ -1026,6 +984,8 @@ def tool(
1026
984
  except Exception:
1027
985
  resolved_hints = {}
1028
986
  wants_context = bool(parameters) and resolved_hints.get(parameters[0].name) is ToolContext
987
+ # ToolContext is injected locally and must not appear in the provider
988
+ # input schema.
1029
989
  tool_params = parameters[1:] if wants_context else parameters
1030
990
  param_names = {p.name for p in tool_params}
1031
991
  input_schema, coercions = _build_input_schema(tool_params, resolved_hints)
@@ -1070,7 +1030,7 @@ def _build_input_schema(
1070
1030
  ) -> tuple[dict[str, Any], dict[str, Callable[[Any], Any]]]:
1071
1031
  """Build the JSON schema for ``parameters`` and a per-name coercion map.
1072
1032
 
1073
- The coercion map carries post-JSON conversions for annotations with no
1033
+ The coercion map carries post-JSON conversions for annotations without a
1074
1034
  native JSON type (currently only ``Path``).
1075
1035
  """
1076
1036
 
File without changes
File without changes
File without changes