mycode-sdk 0.8.10__tar.gz → 0.9.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 (23) hide show
  1. {mycode_sdk-0.8.10 → mycode_sdk-0.9.1}/PKG-INFO +35 -7
  2. {mycode_sdk-0.8.10 → mycode_sdk-0.9.1}/README.md +29 -3
  3. {mycode_sdk-0.8.10 → mycode_sdk-0.9.1}/pyproject.toml +6 -4
  4. {mycode_sdk-0.8.10 → mycode_sdk-0.9.1}/src/mycode/__init__.py +7 -13
  5. {mycode_sdk-0.8.10 → mycode_sdk-0.9.1}/src/mycode/agent.py +99 -88
  6. mycode_sdk-0.9.1/src/mycode/attachments.py +176 -0
  7. {mycode_sdk-0.8.10 → mycode_sdk-0.9.1}/src/mycode/models_catalog.json +146 -328
  8. {mycode_sdk-0.8.10 → mycode_sdk-0.9.1}/src/mycode/providers/anthropic_like.py +14 -12
  9. {mycode_sdk-0.8.10 → mycode_sdk-0.9.1}/src/mycode/providers/base.py +27 -17
  10. {mycode_sdk-0.8.10 → mycode_sdk-0.9.1}/src/mycode/providers/gemini.py +3 -3
  11. {mycode_sdk-0.8.10 → mycode_sdk-0.9.1}/src/mycode/providers/openai_chat.py +52 -47
  12. {mycode_sdk-0.8.10 → mycode_sdk-0.9.1}/src/mycode/providers/openai_responses.py +133 -101
  13. {mycode_sdk-0.8.10 → mycode_sdk-0.9.1}/src/mycode/session.py +55 -18
  14. {mycode_sdk-0.8.10 → mycode_sdk-0.9.1}/src/mycode/tools.py +372 -455
  15. {mycode_sdk-0.8.10 → mycode_sdk-0.9.1}/src/mycode/utils.py +10 -0
  16. {mycode_sdk-0.8.10 → mycode_sdk-0.9.1}/.gitignore +0 -0
  17. {mycode_sdk-0.8.10 → mycode_sdk-0.9.1}/LICENSE +0 -0
  18. {mycode_sdk-0.8.10 → mycode_sdk-0.9.1}/src/mycode/compact.py +0 -0
  19. {mycode_sdk-0.8.10 → mycode_sdk-0.9.1}/src/mycode/hooks.py +0 -0
  20. {mycode_sdk-0.8.10 → mycode_sdk-0.9.1}/src/mycode/messages.py +0 -0
  21. {mycode_sdk-0.8.10 → mycode_sdk-0.9.1}/src/mycode/models.py +0 -0
  22. {mycode_sdk-0.8.10 → mycode_sdk-0.9.1}/src/mycode/providers/__init__.py +0 -0
  23. {mycode_sdk-0.8.10 → mycode_sdk-0.9.1}/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.10
3
+ Version: 0.9.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
@@ -18,9 +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.102.0
22
- Requires-Dist: google-genai>=2.3.0
23
- Requires-Dist: openai>=2.36.0
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
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.10"
7
+ version = "0.9.1"
8
8
  description = "Lightweight Python SDK for building AI agents."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -23,9 +23,11 @@ classifiers = [
23
23
  ]
24
24
  keywords = ["agent", "llm", "anthropic", "openai", "gemini", "sdk"]
25
25
  dependencies = [
26
- "anthropic>=0.102.0",
27
- "google-genai>=2.3.0",
28
- "openai>=2.36.0",
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",
29
31
  ]
30
32
 
31
33
  [project.urls]
@@ -1,15 +1,9 @@
1
- """mycode — multi-turn tool-calling agent runtime.
2
-
3
- Public API for embedding the agent loop in other Python applications. The
4
- runtime ships four built-in coding tools (``read``, ``write``, ``edit``,
5
- ``bash``) exposed as :data:`read_tool`, :data:`write_tool`, :data:`edit_tool`,
6
- :data:`bash_tool` — pick the ones you want via ``tools=[...]`` rather than
7
- silently exposing file system and shell access.
8
- """
1
+ """mycode — multi-turn tool-calling agent runtime."""
9
2
 
10
3
  from importlib import metadata
11
4
 
12
5
  from mycode.agent import Agent, Event, PersistCallback, RunResult
6
+ from mycode.attachments import Attachment
13
7
  from mycode.hooks import AfterToolHook, BeforeToolHook, HookResult, Hooks, ToolHookContext
14
8
  from mycode.messages import (
15
9
  ContentBlock,
@@ -27,25 +21,25 @@ from mycode.messages import (
27
21
  )
28
22
  from mycode.session import SessionStore
29
23
  from mycode.tools import (
30
- DEFAULT_TOOL_SPECS,
31
24
  ToolContext,
32
25
  ToolExecutionResult,
33
26
  ToolExecutor,
34
27
  ToolSpec,
28
+ bash_tool,
35
29
  cancel_all_tools,
30
+ edit_tool,
31
+ read_tool,
36
32
  tool,
33
+ write_tool,
37
34
  )
38
35
 
39
- # The package metadata in mycode/pyproject.toml is the single version source.
40
36
  __version__ = metadata.version("mycode-sdk")
41
37
 
42
- read_tool, write_tool, edit_tool, bash_tool = DEFAULT_TOOL_SPECS
43
-
44
38
  __all__ = [
45
39
  "Agent",
40
+ "Attachment",
46
41
  "ContentBlock",
47
42
  "ConversationMessage",
48
- "DEFAULT_TOOL_SPECS",
49
43
  "Event",
50
44
  "AfterToolHook",
51
45
  "BeforeToolHook",
@@ -13,15 +13,16 @@ import os
13
13
  import tempfile
14
14
  import time
15
15
  from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
16
+ from contextlib import suppress
16
17
  from dataclasses import dataclass, field
17
18
  from pathlib import Path
18
19
  from typing import Any, cast
19
20
  from uuid import uuid4
20
21
 
22
+ from mycode.attachments import AttachmentLike, build_attachment_blocks
21
23
  from mycode.compact import (
22
24
  COMPACT_SUMMARY_PROMPT,
23
25
  DEFAULT_COMPACT_THRESHOLD,
24
- apply_compact_replay,
25
26
  build_compact_event,
26
27
  should_compact,
27
28
  )
@@ -79,6 +80,7 @@ class Agent:
79
80
  messages: list[ConversationMessage] | None = None,
80
81
  max_turns: int | None = None,
81
82
  max_tokens: int | None = None,
83
+ temperature: float = 1.0,
82
84
  context_window: int | None = None,
83
85
  compact_threshold: float | None = None,
84
86
  reasoning_effort: str | None = None,
@@ -111,6 +113,16 @@ class Agent:
111
113
  self.api_key = api_key
112
114
  self.api_base = api_base
113
115
  self.max_turns = max_turns
116
+ if not 0 <= temperature <= 1:
117
+ raise ValueError("temperature must be between 0 and 1")
118
+ if (
119
+ provider in {"anthropic", "moonshotai", "minimax"}
120
+ and reasoning_effort
121
+ and reasoning_effort != "none"
122
+ and temperature != 1.0
123
+ ):
124
+ raise ValueError(f"{provider} does not support custom temperature when thinking is enabled")
125
+ self.temperature = float(temperature)
114
126
  self.compact_threshold = compact_threshold if compact_threshold is not None else DEFAULT_COMPACT_THRESHOLD
115
127
  self.reasoning_effort = reasoning_effort
116
128
 
@@ -221,18 +233,12 @@ class Agent:
221
233
  yield Event("tool_start", {"tool_call": {"id": tool_id, "name": name, "input": args}})
222
234
 
223
235
  if self._cancel_event.is_set():
224
- yield self._tool_done_event(
225
- tool_id,
226
- ToolExecutionResult(output="error: cancelled", is_error=True),
227
- )
236
+ yield self._error_done(tool_id, "error: cancelled")
228
237
  return
229
238
 
230
239
  spec = self.tools.get(name)
231
240
  if spec is None:
232
- yield self._tool_done_event(
233
- tool_id,
234
- ToolExecutionResult(output=f"error: unknown tool: {name}", is_error=True),
235
- )
241
+ yield self._error_done(tool_id, f"error: unknown tool: {name}")
236
242
  return
237
243
 
238
244
  hook_ctx = ToolHookContext(
@@ -248,10 +254,7 @@ class Agent:
248
254
  try:
249
255
  result = await self.hooks.run_before_tool(hook_ctx)
250
256
  except Exception as exc:
251
- yield self._tool_done_event(
252
- tool_id,
253
- ToolExecutionResult(output=f"error: tool hook failed: {exc}", is_error=True),
254
- )
257
+ yield self._error_done(tool_id, f"error: tool hook failed: {exc}")
255
258
  return
256
259
 
257
260
  if result is not None:
@@ -259,10 +262,7 @@ class Agent:
259
262
  return
260
263
 
261
264
  if self._cancel_event.is_set():
262
- yield self._tool_done_event(
263
- tool_id,
264
- ToolExecutionResult(output="error: cancelled", is_error=True),
265
- )
265
+ yield self._error_done(tool_id, "error: cancelled")
266
266
  return
267
267
 
268
268
  if spec.streams_output:
@@ -325,13 +325,10 @@ class Agent:
325
325
  yield Event("tool_output", {"tool_use_id": tool_id, "output": output})
326
326
 
327
327
  if was_cancelled:
328
- try:
328
+ with suppress(Exception):
329
329
  await task
330
- except Exception:
331
- pass
332
330
  output = "\n".join([*output_parts, "error: cancelled"]) if output_parts else "error: cancelled"
333
- result = ToolExecutionResult(output=output, is_error=True)
334
- yield self._tool_done_event(tool_id, result)
331
+ yield self._error_done(tool_id, output)
335
332
  return
336
333
  else:
337
334
  try:
@@ -387,6 +384,11 @@ class Agent:
387
384
  data["content"] = result.content
388
385
  return Event("tool_done", data)
389
386
 
387
+ def _error_done(self, tool_id: str, message: str) -> Event:
388
+ """Build a tool_done event carrying an error result."""
389
+
390
+ return self._tool_done_event(tool_id, ToolExecutionResult(output=message, is_error=True))
391
+
390
392
  # ------------------------------------------------------------------
391
393
  # Provider streaming
392
394
  # ------------------------------------------------------------------
@@ -418,10 +420,52 @@ class Agent:
418
420
  finally:
419
421
  close = cast(Callable[[], Awaitable[None]] | None, getattr(provider_stream, "aclose", None))
420
422
  if close is not None:
421
- try:
423
+ with suppress(Exception):
422
424
  await close()
423
- except Exception:
424
- pass
425
+
426
+ def _build_request(
427
+ self,
428
+ *,
429
+ tools: list[dict[str, Any]] | None = None,
430
+ max_tokens: int | None = None,
431
+ reasoning_effort: str | None = None,
432
+ append_messages: Sequence[ConversationMessage] = (),
433
+ ) -> ProviderRequest:
434
+ """Build a ProviderRequest from the agent's current state."""
435
+
436
+ return ProviderRequest(
437
+ provider=self.provider,
438
+ model=self.model,
439
+ session_id=self.session_id,
440
+ messages=self.messages,
441
+ system=self.system,
442
+ tools=self.tools.definitions if tools is None else tools,
443
+ max_tokens=self.max_tokens if max_tokens is None else max_tokens,
444
+ temperature=self.temperature,
445
+ api_key=self.api_key,
446
+ api_base=self.api_base,
447
+ reasoning_effort=reasoning_effort,
448
+ supports_image_input=self.supports_image_input,
449
+ supports_pdf_input=self.supports_pdf_input,
450
+ transcript_path=self.transcript_path,
451
+ append_messages=list(append_messages),
452
+ )
453
+
454
+ @staticmethod
455
+ def _elapsed_ms(start: float) -> int:
456
+ return max(0, int((time.monotonic() - start) * 1000))
457
+
458
+ @staticmethod
459
+ def _stamp_thinking_duration(content: list[Any], duration_ms: int) -> None:
460
+ """Merge duration_ms onto the last thinking block in content, if any."""
461
+
462
+ for block in reversed(content):
463
+ if not isinstance(block, dict) or block.get("type") != "thinking":
464
+ continue
465
+ raw_meta = block.get("meta")
466
+ meta = raw_meta if isinstance(raw_meta, dict) else {}
467
+ block["meta"] = {**meta, "duration_ms": duration_ms}
468
+ return
425
469
 
426
470
  # ------------------------------------------------------------------
427
471
  # Public entry points
@@ -431,6 +475,7 @@ class Agent:
431
475
  self,
432
476
  user_input: str | ConversationMessage,
433
477
  *,
478
+ attachments: Sequence[AttachmentLike] = (),
434
479
  on_persist: PersistCallback | None = None,
435
480
  ) -> AsyncIterator[Event]:
436
481
  """Run the full agent loop for one user message."""
@@ -448,8 +493,9 @@ class Agent:
448
493
 
449
494
  self._cancel_event.clear()
450
495
 
496
+ user_message: ConversationMessage
451
497
  if isinstance(user_input, str):
452
- user_message: ConversationMessage = user_text_message(user_input)
498
+ user_message = user_text_message(user_input)
453
499
  else:
454
500
  if (user_input.get("role") or "user") != "user":
455
501
  yield Event("error", {"message": "user input must be a user message"})
@@ -462,17 +508,20 @@ class Agent:
462
508
  if isinstance(raw_meta, dict):
463
509
  user_message["meta"] = {str(k): v for k, v in raw_meta.items()}
464
510
 
511
+ if attachments:
512
+ blocks = await asyncio.to_thread(build_attachment_blocks, attachments, cwd=self.cwd)
513
+ user_message["content"] = list(user_message.get("content") or []) + blocks
514
+
465
515
  content_blocks = user_message.get("content") or []
466
- if not self.supports_image_input and any(
467
- isinstance(block, dict) and block.get("type") == "image" for block in content_blocks
516
+ for block_type, supported, label in (
517
+ ("image", self.supports_image_input, "image input"),
518
+ ("document", self.supports_pdf_input, "PDF input"),
468
519
  ):
469
- yield Event("error", {"message": "current model does not support image input"})
470
- return
471
- if not self.supports_pdf_input and any(
472
- isinstance(block, dict) and block.get("type") == "document" for block in content_blocks
473
- ):
474
- yield Event("error", {"message": "current model does not support PDF input"})
475
- return
520
+ if not supported and any(
521
+ isinstance(block, dict) and block.get("type") == block_type for block in content_blocks
522
+ ):
523
+ yield Event("error", {"message": f"current model does not support {label}"})
524
+ return
476
525
 
477
526
  self.messages.append(user_message)
478
527
  await persist(user_message)
@@ -491,20 +540,7 @@ class Agent:
491
540
  thinking_started_at: float | None = None
492
541
  thinking_duration_ms: int | None = None
493
542
  provider_cancelled = False
494
- request = ProviderRequest(
495
- provider=self.provider,
496
- model=self.model,
497
- session_id=self.session_id,
498
- messages=apply_compact_replay(self.messages, transcript_path=self.transcript_path),
499
- system=self.system,
500
- tools=self.tools.definitions,
501
- max_tokens=self.max_tokens,
502
- api_key=self.api_key,
503
- api_base=self.api_base,
504
- reasoning_effort=self.reasoning_effort,
505
- supports_image_input=self.supports_image_input,
506
- supports_pdf_input=self.supports_pdf_input,
507
- )
543
+ request = self._build_request(reasoning_effort=self.reasoning_effort)
508
544
 
509
545
  try:
510
546
  async for provider_event in self._stream_provider_turn(adapter, request):
@@ -528,7 +564,7 @@ class Agent:
528
564
  delta_text = str(provider_event.data.get("text") or "")
529
565
  if delta_text:
530
566
  if thinking_started_at is not None and thinking_duration_ms is None:
531
- thinking_duration_ms = max(0, int((time.monotonic() - thinking_started_at) * 1000))
567
+ thinking_duration_ms = self._elapsed_ms(thinking_started_at)
532
568
  yield Event("reasoning_done", {"duration_ms": thinking_duration_ms})
533
569
  if partial_content and partial_content[-1].get("type") == "text":
534
570
  partial_content[-1]["text"] = f"{partial_content[-1].get('text') or ''}{delta_text}"
@@ -544,7 +580,7 @@ class Agent:
544
580
  continue
545
581
 
546
582
  if thinking_started_at is not None and thinking_duration_ms is None:
547
- thinking_duration_ms = max(0, int((time.monotonic() - thinking_started_at) * 1000))
583
+ thinking_duration_ms = self._elapsed_ms(thinking_started_at)
548
584
  yield Event("reasoning_done", {"duration_ms": thinking_duration_ms})
549
585
 
550
586
  message = provider_event.data.get("message")
@@ -561,15 +597,9 @@ class Agent:
561
597
  if provider_cancelled:
562
598
  if partial_content:
563
599
  if thinking_started_at is not None and thinking_duration_ms is None:
564
- thinking_duration_ms = max(0, int((time.monotonic() - thinking_started_at) * 1000))
600
+ thinking_duration_ms = self._elapsed_ms(thinking_started_at)
565
601
  if thinking_duration_ms is not None:
566
- for block in reversed(partial_content):
567
- if block.get("type") != "thinking":
568
- continue
569
- raw_meta = block.get("meta")
570
- meta = cast(dict[str, Any], raw_meta) if isinstance(raw_meta, dict) else {}
571
- block["meta"] = {**meta, "duration_ms": thinking_duration_ms}
572
- break
602
+ self._stamp_thinking_duration(partial_content, thinking_duration_ms)
573
603
  cancelled_message = build_message(
574
604
  "assistant",
575
605
  [dict(block) for block in partial_content],
@@ -589,13 +619,7 @@ class Agent:
589
619
  return
590
620
 
591
621
  if thinking_duration_ms is not None:
592
- for block in reversed(assistant_message.get("content") or []):
593
- if not isinstance(block, dict) or block.get("type") != "thinking":
594
- continue
595
- raw_meta = block.get("meta")
596
- meta = cast(dict[str, Any], raw_meta) if isinstance(raw_meta, dict) else {}
597
- block["meta"] = {**meta, "duration_ms": thinking_duration_ms}
598
- break
622
+ self._stamp_thinking_duration(assistant_message.get("content") or [], thinking_duration_ms)
599
623
 
600
624
  # Stamp context_window onto the persisted assistant message so
601
625
  # rewinds and refreshed clients can render token-usage % without
@@ -631,16 +655,13 @@ class Agent:
631
655
  continue
632
656
 
633
657
  d = event.data
634
- output = str(d.get("output") or "")
635
- metadata = d.get("metadata") if isinstance(d.get("metadata"), dict) else None
636
- content = d.get("content")
637
658
  tool_results.append(
638
659
  tool_result_block(
639
- tool_use_id=str(d.get("tool_use_id") or ""),
640
- output=output,
641
- metadata=metadata,
642
- is_error=bool(d.get("is_error")),
643
- content=content if isinstance(content, list) else None,
660
+ tool_use_id=d["tool_use_id"],
661
+ output=d["output"],
662
+ metadata=d.get("metadata"),
663
+ is_error=d["is_error"],
664
+ content=d.get("content"),
644
665
  )
645
666
  )
646
667
 
@@ -682,6 +703,7 @@ class Agent:
682
703
  self,
683
704
  user_input: str | ConversationMessage,
684
705
  *,
706
+ attachments: Sequence[AttachmentLike] = (),
685
707
  on_persist: PersistCallback | None = None,
686
708
  ) -> RunResult:
687
709
  """Run one user turn synchronously and collect the streamed result."""
@@ -695,7 +717,7 @@ class Agent:
695
717
 
696
718
  async def collect() -> RunResult:
697
719
  result = RunResult()
698
- async for event in self.achat(user_input, on_persist=on_persist):
720
+ async for event in self.achat(user_input, attachments=attachments, on_persist=on_persist):
699
721
  result.events.append(event)
700
722
  if event.type == "text":
701
723
  result.text += str(event.data.get("delta") or "")
@@ -716,21 +738,10 @@ class Agent:
716
738
  ) -> None:
717
739
  """Ask the provider for a summary, persist the compact event, append it."""
718
740
 
719
- compact_messages = apply_compact_replay(self.messages, transcript_path=self.transcript_path) + [
720
- user_text_message(COMPACT_SUMMARY_PROMPT)
721
- ]
722
- request = ProviderRequest(
723
- provider=self.provider,
724
- model=self.model,
725
- session_id=self.session_id,
726
- messages=compact_messages,
727
- system=self.system,
741
+ request = self._build_request(
728
742
  tools=[],
729
743
  max_tokens=min(self.max_tokens, 8192),
730
- api_key=self.api_key,
731
- api_base=self.api_base,
732
- supports_image_input=self.supports_image_input,
733
- supports_pdf_input=self.supports_pdf_input,
744
+ append_messages=[user_text_message(COMPACT_SUMMARY_PROMPT)],
734
745
  )
735
746
 
736
747
  summary_message: ConversationMessage | None = None