mycode-sdk 0.4.2__tar.gz → 0.5.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mycode-sdk
3
- Version: 0.4.2
3
+ Version: 0.5.0
4
4
  Summary: Multi-turn tool-calling agent runtime for embedding the mycode agent loop.
5
5
  Project-URL: Homepage, https://github.com/legibet/mycode
6
6
  Project-URL: Repository, https://github.com/legibet/mycode
@@ -25,7 +25,7 @@ Description-Content-Type: text/markdown
25
25
 
26
26
  # mycode-sdk
27
27
 
28
- Lightweight Python SDK for the mycode multi-turn tool-calling agent. Import name: `mycode`.
28
+ Lightweight Python SDK for building agents.
29
29
 
30
30
  ## Install
31
31
 
@@ -35,9 +35,7 @@ uv add mycode-sdk
35
35
  pip install mycode-sdk
36
36
  ```
37
37
 
38
- ## Quick Start
39
-
40
- `Agent(...)` fills in sensible defaults: provider inferred from the model id, `session_id` generated, session log auto-persisted to `~/.mycode/sessions/<session_id>/`. By default no tools are registered — pick the built-ins you want or register your own via `@tool`.
38
+ ## Quick start
41
39
 
42
40
  ```python
43
41
  import asyncio
@@ -50,31 +48,43 @@ async def main() -> None:
50
48
  model="claude-sonnet-4-6",
51
49
  api_key="YOUR_API_KEY",
52
50
  cwd=".",
53
- system="You are a concise coding assistant.",
54
51
  tools=[read_tool, bash_tool],
55
52
  )
56
53
 
57
54
  async for event in agent.achat("Read pyproject.toml and tell me the project name."):
58
55
  if event.type == "text":
59
- print(event.data["delta"], end="")
56
+ print(event.data["delta"], end="", flush=True)
60
57
 
61
58
 
62
59
  asyncio.run(main())
63
60
  ```
64
61
 
65
- To resume a previous conversation, pass the same `session_id` (the agent loads its own history from disk).
62
+ `Agent(...)` infers the provider from the model id. No tools are registered unless you pass `tools=[...]`, and nothing is persisted unless you pass `session_dir=`.
66
63
 
67
- ## Built-in tools
64
+ For a synchronous call, use `run()` — it collects the stream into a `RunResult`:
65
+
66
+ ```python
67
+ result = agent.run("Read pyproject.toml and tell me the project name.")
68
+ print(result.text)
69
+ ```
68
70
 
69
- Pick and combine the four bundled coding tools:
71
+ Call `achat` or `run` again on the same `Agent` to continue the conversation — history accumulates in `agent.messages`.
72
+
73
+ To persist across processes, pass `session_dir` as the root directory; each `session_id` becomes a subdirectory. Reconstruct with the same `(session_dir, session_id)` to resume.
74
+
75
+ ## Built-in tools
70
76
 
71
77
  ```python
72
78
  from mycode import read_tool, write_tool, edit_tool, bash_tool
73
79
  ```
74
80
 
75
- ## Custom Tools
81
+ Only `bash_tool` streams incremental output as `tool_output` events; the others return a single result.
82
+
83
+ ## Custom tools
84
+
85
+ `@tool` wraps a sync or `async def` Python function as a `ToolSpec`. Parameter type hints become the JSON schema sent to the provider.
76
86
 
77
- `@tool` wraps a plain Python function (sync or async) as a `ToolSpec`. If the first parameter is annotated `ToolContext`, the context is injected; use `ctx.call("read", {...})` to invoke another registered tool.
87
+ Annotate the first parameter as `ToolContext` to have the context injected. Use `ctx.read / ctx.write / ctx.edit / ctx.bash` to invoke the built-ins, or `ctx.call(name, args)` for any registered tool by name.
78
88
 
79
89
  ```python
80
90
  from mycode import Agent, ToolContext, read_tool, tool
@@ -84,8 +94,8 @@ from mycode import Agent, ToolContext, read_tool, tool
84
94
  def summarize_file(ctx: ToolContext, path: str) -> str:
85
95
  """Return the first line of a text file."""
86
96
 
87
- result = ctx.call("read", {"path": path})
88
- return result.model_text.splitlines()[0] if result.model_text else ""
97
+ result = ctx.read(path)
98
+ return result.output.splitlines()[0] if result.output else ""
89
99
 
90
100
 
91
101
  agent = Agent(
@@ -96,19 +106,6 @@ agent = Agent(
96
106
  )
97
107
  ```
98
108
 
99
- Type hints drive the JSON schema. Unknown types raise; missing docstrings raise. `async def` tools are run via `asyncio.run` on the executor's worker thread.
109
+ A bare `str` return becomes the tool `output`; any other JSON-serializable value is dumped to JSON. For finer control, return a `ToolExecutionResult` to set `output`, `content` (multimodal blocks such as images), `metadata` (structured UI data), and `is_error` independently.
100
110
 
101
- ## Disabling auto-persistence
102
-
103
- Point `session_dir` (or the implied `SessionStore` data dir) at a temporary directory if you need an ephemeral session:
104
-
105
- ```python
106
- import tempfile
107
- from pathlib import Path
108
-
109
- agent = Agent(
110
- model="claude-sonnet-4-6",
111
- cwd=".",
112
- session_dir=Path(tempfile.mkdtemp()) / "scratch",
113
- )
114
- ```
111
+ See [docs/sdk.md](../docs/sdk.md) for the event stream, cancellation, session rules, and the full `Agent` / `@tool` reference.
@@ -0,0 +1,86 @@
1
+ # mycode-sdk
2
+
3
+ Lightweight Python SDK for building agents.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ uv add mycode-sdk
9
+ # or
10
+ pip install mycode-sdk
11
+ ```
12
+
13
+ ## Quick start
14
+
15
+ ```python
16
+ import asyncio
17
+
18
+ from mycode import Agent, bash_tool, read_tool
19
+
20
+
21
+ async def main() -> None:
22
+ agent = Agent(
23
+ model="claude-sonnet-4-6",
24
+ api_key="YOUR_API_KEY",
25
+ cwd=".",
26
+ tools=[read_tool, bash_tool],
27
+ )
28
+
29
+ async for event in agent.achat("Read pyproject.toml and tell me the project name."):
30
+ if event.type == "text":
31
+ print(event.data["delta"], end="", flush=True)
32
+
33
+
34
+ asyncio.run(main())
35
+ ```
36
+
37
+ `Agent(...)` infers the provider from the model id. No tools are registered unless you pass `tools=[...]`, and nothing is persisted unless you pass `session_dir=`.
38
+
39
+ For a synchronous call, use `run()` — it collects the stream into a `RunResult`:
40
+
41
+ ```python
42
+ result = agent.run("Read pyproject.toml and tell me the project name.")
43
+ print(result.text)
44
+ ```
45
+
46
+ Call `achat` or `run` again on the same `Agent` to continue the conversation — history accumulates in `agent.messages`.
47
+
48
+ To persist across processes, pass `session_dir` as the root directory; each `session_id` becomes a subdirectory. Reconstruct with the same `(session_dir, session_id)` to resume.
49
+
50
+ ## Built-in tools
51
+
52
+ ```python
53
+ from mycode import read_tool, write_tool, edit_tool, bash_tool
54
+ ```
55
+
56
+ Only `bash_tool` streams incremental output as `tool_output` events; the others return a single result.
57
+
58
+ ## Custom tools
59
+
60
+ `@tool` wraps a sync or `async def` Python function as a `ToolSpec`. Parameter type hints become the JSON schema sent to the provider.
61
+
62
+ Annotate the first parameter as `ToolContext` to have the context injected. Use `ctx.read / ctx.write / ctx.edit / ctx.bash` to invoke the built-ins, or `ctx.call(name, args)` for any registered tool by name.
63
+
64
+ ```python
65
+ from mycode import Agent, ToolContext, read_tool, tool
66
+
67
+
68
+ @tool
69
+ def summarize_file(ctx: ToolContext, path: str) -> str:
70
+ """Return the first line of a text file."""
71
+
72
+ result = ctx.read(path)
73
+ return result.output.splitlines()[0] if result.output else ""
74
+
75
+
76
+ agent = Agent(
77
+ model="claude-sonnet-4-6",
78
+ api_key="YOUR_API_KEY",
79
+ cwd=".",
80
+ tools=[read_tool, summarize_file],
81
+ )
82
+ ```
83
+
84
+ A bare `str` return becomes the tool `output`; any other JSON-serializable value is dumped to JSON. For finer control, return a `ToolExecutionResult` to set `output`, `content` (multimodal blocks such as images), `metadata` (structured UI data), and `is_error` independently.
85
+
86
+ See [docs/sdk.md](../docs/sdk.md) for the event stream, cancellation, session rules, and the full `Agent` / `@tool` reference.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "mycode-sdk"
7
- version = "0.4.2"
7
+ version = "0.5.0"
8
8
  description = "Multi-turn tool-calling agent runtime for embedding the mycode agent loop."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -7,7 +7,7 @@ runtime ships four built-in coding tools (``read``, ``write``, ``edit``,
7
7
  silently exposing file system and shell access.
8
8
  """
9
9
 
10
- from mycode.agent import Agent, Event, PersistCallback
10
+ from mycode.agent import Agent, Event, PersistCallback, RunResult
11
11
  from mycode.messages import (
12
12
  ContentBlock,
13
13
  ConversationMessage,
@@ -47,6 +47,7 @@ __all__ = [
47
47
  "DEFAULT_TOOL_SPECS",
48
48
  "Event",
49
49
  "PersistCallback",
50
+ "RunResult",
50
51
  "SessionStore",
51
52
  "ToolContext",
52
53
  "ToolExecutionResult",
@@ -1,14 +1,15 @@
1
1
  """Multi-turn agent loop.
2
2
 
3
3
  :class:`Agent` drives one conversation. Each call to :meth:`Agent.achat`
4
- runs one user turn and appends every emitted message to the on-disk
5
- session log.
4
+ runs one user turn; when a ``session_dir`` is configured, every emitted
5
+ message is appended to the on-disk session log.
6
6
  """
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
10
  import asyncio
11
11
  import logging
12
+ import tempfile
12
13
  from collections.abc import AsyncIterator, Awaitable, Callable, Sequence
13
14
  from dataclasses import dataclass, field
14
15
  from pathlib import Path
@@ -31,10 +32,9 @@ from mycode.session import (
31
32
  SessionStore,
32
33
  apply_compact,
33
34
  build_compact_event,
34
- resolve_sessions_dir,
35
35
  should_compact,
36
36
  )
37
- from mycode.tools import ToolExecutionResult, ToolExecutor, ToolSpec
37
+ from mycode.tools import ToolContext, ToolExecutionResult, ToolExecutor, ToolSpec
38
38
 
39
39
  logger = logging.getLogger(__name__)
40
40
 
@@ -49,6 +49,15 @@ class Event:
49
49
  data: dict[str, Any] = field(default_factory=dict)
50
50
 
51
51
 
52
+ @dataclass
53
+ class RunResult:
54
+ """Collected result returned by :meth:`Agent.run`."""
55
+
56
+ text: str = ""
57
+ events: list[Event] = field(default_factory=list)
58
+ error: str | None = None
59
+
60
+
52
61
  class Agent:
53
62
  """Multi-turn tool-calling agent runtime."""
54
63
 
@@ -58,8 +67,8 @@ class Agent:
58
67
  model: str,
59
68
  cwd: str,
60
69
  provider: str | None = None,
61
- session_id: str | None = None,
62
70
  session_dir: Path | None = None,
71
+ session_id: str | None = None,
63
72
  api_key: str | None = None,
64
73
  api_base: str | None = None,
65
74
  messages: list[ConversationMessage] | None = None,
@@ -72,7 +81,7 @@ class Agent:
72
81
  supports_image_input: bool | None = None,
73
82
  supports_pdf_input: bool | None = None,
74
83
  system: str = "",
75
- tools: ToolExecutor | Sequence[ToolSpec] | None = None,
84
+ tools: Sequence[ToolSpec] = (),
76
85
  ):
77
86
  self.model = model
78
87
  if provider is None:
@@ -83,10 +92,13 @@ class Agent:
83
92
  self.provider = provider
84
93
 
85
94
  self.cwd = str(Path(cwd).resolve(strict=False))
86
- # If only ``session_dir`` is supplied, derive the id from its directory name.
87
- self.session_id = (session_id or "").strip() or (session_dir.name if session_dir is not None else uuid4().hex)
88
- self.session_dir = session_dir if session_dir is not None else resolve_sessions_dir() / self.session_id
89
- self._store = SessionStore(data_dir=self.session_dir.parent)
95
+
96
+ # Persistence is opt-in: a store is only created when ``session_dir``
97
+ # is supplied. ``session_id`` is always populated (uuid when absent)
98
+ # so Events can carry a stable runtime tag even in memory-only mode.
99
+ self.session_dir = session_dir
100
+ self.session_id = (session_id or "").strip() or uuid4().hex
101
+ self._store: SessionStore | None = SessionStore(data_dir=session_dir) if session_dir is not None else None
90
102
 
91
103
  self.api_key = api_key
92
104
  self.api_base = api_base
@@ -98,20 +110,38 @@ class Agent:
98
110
  self._cancel_event = asyncio.Event()
99
111
  self._provider_event_task: asyncio.Future[ProviderStreamEvent] | None = None
100
112
 
113
+ # History resolution:
114
+ # - messages is None → auto-resume from disk if the session exists
115
+ # - messages is [] or [...] → use as-is; refuse if it would overwrite disk
101
116
  if messages is None:
102
- data = self._store.load_session_sync(self.session_id)
103
- messages = list(data["messages"]) if data is not None else []
117
+ if self._store is not None:
118
+ data = self._store.load_session_sync(self.session_id)
119
+ messages = list(data["messages"]) if data is not None else []
120
+ else:
121
+ messages = []
122
+ elif self._store is not None and self._store.session_exists(self.session_id):
123
+ msg = (
124
+ f"session {self.session_id!r} already exists on disk; "
125
+ "pass messages=None to resume or choose a different session_id"
126
+ )
127
+ raise ValueError(msg)
104
128
  self.messages: list[ConversationMessage] = list(messages)
105
129
 
106
- if isinstance(tools, ToolExecutor):
107
- self.tools = tools
130
+ # Tool runtime: one executor per agent. ``tool_output_dir`` is where
131
+ # tools can drop artifacts — defaults to a session-adjacent directory
132
+ # so logs (e.g. bash spill files) live next to the session JSONL.
133
+ # When no session is configured, use a tempdir scoped to session_id.
134
+ if session_dir is not None:
135
+ tool_output_dir = session_dir / self.session_id / "tool-output"
108
136
  else:
109
- self.tools = ToolExecutor(
110
- cwd=self.cwd,
111
- session_dir=self.session_dir,
112
- tools=list(tools) if tools is not None else [],
113
- supports_image_input=False,
114
- )
137
+ tool_output_dir = Path(tempfile.gettempdir()) / "mycode" / self.session_id / "tool-output"
138
+ self.tools = ToolExecutor(tools)
139
+ self.tool_ctx = ToolContext(
140
+ executor=self.tools,
141
+ cwd=self.cwd,
142
+ tool_output_dir=tool_output_dir,
143
+ supports_image_input=False,
144
+ )
115
145
 
116
146
  self.refresh_capabilities(
117
147
  max_tokens=max_tokens,
@@ -151,7 +181,7 @@ class Agent:
151
181
  self.supports_reasoning: bool | None = meta.supports_reasoning
152
182
  self.supports_image_input: bool = bool(meta.supports_image_input)
153
183
  self.supports_pdf_input: bool = bool(meta.supports_pdf_input)
154
- self.tools.supports_image_input = self.supports_image_input
184
+ self.tool_ctx.supports_image_input = self.supports_image_input
155
185
 
156
186
  def cancel(self) -> None:
157
187
  """Request cancellation of the in-flight turn."""
@@ -183,11 +213,7 @@ class Agent:
183
213
  if self._cancel_event.is_set():
184
214
  yield self._tool_done_event(
185
215
  tool_id,
186
- ToolExecutionResult(
187
- model_text="error: cancelled",
188
- display_text="Cancelled",
189
- is_error=True,
190
- ),
216
+ ToolExecutionResult(output="error: cancelled", is_error=True),
191
217
  )
192
218
  return
193
219
 
@@ -195,11 +221,7 @@ class Agent:
195
221
  if spec is None:
196
222
  yield self._tool_done_event(
197
223
  tool_id,
198
- ToolExecutionResult(
199
- model_text=f"error: unknown tool: {name}",
200
- display_text=f"Unknown tool: {name}",
201
- is_error=True,
202
- ),
224
+ ToolExecutionResult(output=f"error: unknown tool: {name}", is_error=True),
203
225
  )
204
226
  return
205
227
 
@@ -209,13 +231,10 @@ class Agent:
209
231
  return
210
232
 
211
233
  try:
212
- result = await asyncio.to_thread(self.tools.execute, name, args)
234
+ ctx = self._ctx_for_call(tool_id)
235
+ result = await asyncio.to_thread(self.tools.execute, name, args, ctx)
213
236
  except Exception as exc: # pragma: no cover - defensive
214
- result = ToolExecutionResult(
215
- model_text=f"error: {exc}",
216
- display_text=str(exc),
217
- is_error=True,
218
- )
237
+ result = ToolExecutionResult(output=f"error: {exc}", is_error=True)
219
238
 
220
239
  yield self._tool_done_event(tool_id, result)
221
240
 
@@ -234,15 +253,11 @@ class Agent:
234
253
  def on_output(line: str) -> None:
235
254
  loop.call_soon_threadsafe(output_queue.put_nowait, line)
236
255
 
256
+ ctx = self._ctx_for_call(tool_id, emit=on_output)
257
+
237
258
  async def run_in_thread() -> ToolExecutionResult:
238
259
  try:
239
- return await asyncio.to_thread(
240
- self.tools.execute,
241
- name,
242
- args,
243
- tool_call_id=tool_id,
244
- on_output=on_output,
245
- )
260
+ return await asyncio.to_thread(self.tools.execute, name, args, ctx)
246
261
  finally:
247
262
  loop.call_soon_threadsafe(output_queue.put_nowait, None)
248
263
 
@@ -271,31 +286,41 @@ class Agent:
271
286
  await task
272
287
  except Exception:
273
288
  pass
274
- result = ToolExecutionResult(
275
- model_text="error: cancelled",
276
- display_text="Cancelled",
277
- is_error=True,
278
- )
289
+ result = ToolExecutionResult(output="error: cancelled", is_error=True)
279
290
  else:
280
291
  try:
281
292
  result = await task
282
293
  except Exception as exc: # pragma: no cover - defensive
283
- result = ToolExecutionResult(
284
- model_text=f"error: {exc}",
285
- display_text=str(exc),
286
- is_error=True,
287
- )
294
+ result = ToolExecutionResult(output=f"error: {exc}", is_error=True)
288
295
 
289
296
  yield self._tool_done_event(tool_id, result)
290
297
 
298
+ def _ctx_for_call(
299
+ self,
300
+ tool_id: str,
301
+ *,
302
+ emit: Callable[[str], None] | None = None,
303
+ ) -> ToolContext:
304
+ """Build a per-call ToolContext from the base context."""
305
+
306
+ return ToolContext(
307
+ executor=self.tools,
308
+ cwd=self.tool_ctx.cwd,
309
+ tool_output_dir=self.tool_ctx.tool_output_dir,
310
+ supports_image_input=self.tool_ctx.supports_image_input,
311
+ tool_call_id=tool_id,
312
+ emit=emit,
313
+ )
314
+
291
315
  @staticmethod
292
316
  def _tool_done_event(tool_id: str, result: ToolExecutionResult) -> Event:
293
317
  data: dict[str, Any] = {
294
318
  "tool_use_id": tool_id,
295
- "model_text": result.model_text,
296
- "display_text": result.display_text,
319
+ "output": result.output,
297
320
  "is_error": result.is_error,
298
321
  }
322
+ if result.metadata:
323
+ data["metadata"] = result.metadata
299
324
  if result.content:
300
325
  data["content"] = result.content
301
326
  return Event("tool_done", data)
@@ -337,7 +362,7 @@ class Agent:
337
362
  pass
338
363
 
339
364
  # ------------------------------------------------------------------
340
- # Public entry point
365
+ # Public entry points
341
366
  # ------------------------------------------------------------------
342
367
 
343
368
  async def achat(
@@ -352,27 +377,26 @@ class Agent:
352
377
  requests tools, the agent runs them locally, appends one user-side
353
378
  tool_result message, and continues until the assistant stops using tools.
354
379
 
355
- Every emitted message is appended to the session log. ``on_persist`` is
356
- an optional hook fired *before* that append, useful for staging related
357
- side effects (the web server uses it to land a rewind marker first).
380
+ When a ``session_dir`` is configured, every emitted message is appended
381
+ to the on-disk session log. ``on_persist`` fires *before* that append
382
+ regardless of whether a store is configured, so callers can plug in a
383
+ custom backend or stage related records (the web server uses it to
384
+ land a rewind marker first).
358
385
  """
359
386
 
360
387
  async def persist(message: ConversationMessage) -> None:
361
388
  if on_persist is not None:
362
389
  await on_persist(message)
363
- await self._store.append_message(
364
- self.session_id,
365
- message,
366
- provider=self.provider,
367
- model=self.model,
368
- cwd=self.cwd,
369
- api_base=self.api_base,
370
- )
390
+ if self._store is None:
391
+ return
392
+ if not self._store.session_exists(self.session_id):
393
+ await self._store.create_session(self.session_id, cwd=self.cwd)
394
+ await self._store.append_message(self.session_id, message)
371
395
 
372
396
  self._cancel_event.clear()
373
397
  supports_image_input = self.supports_image_input
374
398
  supports_pdf_input = self.supports_pdf_input
375
- self.tools.supports_image_input = supports_image_input
399
+ self.tool_ctx.supports_image_input = supports_image_input
376
400
 
377
401
  if isinstance(user_input, str):
378
402
  user_message = user_text_message(user_input)
@@ -491,19 +515,20 @@ class Agent:
491
515
  continue
492
516
 
493
517
  d = event.data
494
- model_text = str(d.get("model_text") or "")
518
+ output = str(d.get("output") or "")
519
+ metadata = d.get("metadata") if isinstance(d.get("metadata"), dict) else None
495
520
  content = d.get("content")
496
521
  tool_results.append(
497
522
  tool_result_block(
498
523
  tool_use_id=str(d.get("tool_use_id") or ""),
499
- model_text=model_text,
500
- display_text=str(d.get("display_text") or ""),
524
+ output=output,
525
+ metadata=metadata,
501
526
  is_error=bool(d.get("is_error")),
502
527
  content=content if isinstance(content, list) else None,
503
528
  )
504
529
  )
505
530
 
506
- if model_text == "error: cancelled" and self._cancel_event.is_set():
531
+ if output == "error: cancelled" and self._cancel_event.is_set():
507
532
  tool_result_message = build_message("user", tool_results)
508
533
  self.messages.append(tool_result_message)
509
534
  await persist(tool_result_message)
@@ -524,6 +549,33 @@ class Agent:
524
549
  async for event in self._compact_if_needed(adapter, persist):
525
550
  yield event
526
551
 
552
+ def run(
553
+ self,
554
+ user_input: str | ConversationMessage,
555
+ *,
556
+ on_persist: PersistCallback | None = None,
557
+ ) -> RunResult:
558
+ """Run one user turn synchronously and collect the streamed result."""
559
+
560
+ try:
561
+ asyncio.get_running_loop()
562
+ except RuntimeError:
563
+ pass
564
+ else:
565
+ raise RuntimeError("Agent.run() cannot run inside an active event loop; use Agent.achat() instead")
566
+
567
+ async def collect() -> RunResult:
568
+ result = RunResult()
569
+ async for event in self.achat(user_input, on_persist=on_persist):
570
+ result.events.append(event)
571
+ if event.type == "text":
572
+ result.text += str(event.data.get("delta") or "")
573
+ elif event.type == "error" and result.error is None:
574
+ result.error = str(event.data.get("message") or "")
575
+ return result
576
+
577
+ return asyncio.run(collect())
578
+
527
579
  # ------------------------------------------------------------------
528
580
  # Context compaction
529
581
  # ------------------------------------------------------------------
@@ -92,25 +92,28 @@ def tool_use_block(
92
92
  def tool_result_block(
93
93
  *,
94
94
  tool_use_id: str,
95
- model_text: str,
96
- display_text: str,
95
+ output: str,
96
+ metadata: dict[str, Any] | None = None,
97
97
  is_error: bool = False,
98
98
  content: list[ContentBlock] | None = None,
99
99
  meta: dict[str, Any] | None = None,
100
100
  ) -> ContentBlock:
101
101
  """Build a tool-result block.
102
102
 
103
- `model_text` is replayed back to providers on later turns.
104
- `display_text` is the user-facing text shown by CLI and web UI.
103
+ `output` is replayed back to providers on later turns.
104
+ `content` carries multimodal blocks (e.g. images) that providers should
105
+ replay alongside the text. `metadata` is an optional structured payload
106
+ for UI consumption (e.g. edit diff line numbers).
105
107
  """
106
108
 
107
109
  block: ContentBlock = {
108
110
  "type": "tool_result",
109
111
  "tool_use_id": tool_use_id,
110
- "model_text": model_text,
111
- "display_text": display_text,
112
+ "output": output,
112
113
  "is_error": is_error,
113
114
  }
115
+ if metadata:
116
+ block["metadata"] = dict(metadata)
114
117
  if content:
115
118
  block["content"] = [dict(item) for item in content]
116
119
  if meta:
@@ -901,13 +901,6 @@
901
901
  "supports_pdf_input": false,
902
902
  "supports_reasoning": false
903
903
  },
904
- "codex-mini-latest": {
905
- "context_window": 200000,
906
- "max_output_tokens": 100000,
907
- "supports_image_input": false,
908
- "supports_pdf_input": false,
909
- "supports_reasoning": true
910
- },
911
904
  "gpt-3.5-turbo": {
912
905
  "context_window": 16385,
913
906
  "max_output_tokens": 4096,
@@ -300,7 +300,7 @@ class AnthropicLikeAdapter(ProviderAdapter):
300
300
  return {
301
301
  "type": "tool_result",
302
302
  "tool_use_id": block.get("tool_use_id"),
303
- "content": content_blocks or str(block.get("model_text") or ""),
303
+ "content": content_blocks or str(block.get("output") or ""),
304
304
  "is_error": bool(block.get("is_error")),
305
305
  }
306
306
 
@@ -338,8 +338,7 @@ def _interrupted_tool_result_message(tool_use_ids: list[str]) -> ConversationMes
338
338
  [
339
339
  tool_result_block(
340
340
  tool_use_id=tool_use_id,
341
- model_text="error: tool call was interrupted",
342
- display_text="Tool call was interrupted",
341
+ output="error: tool call was interrupted",
343
342
  is_error=True,
344
343
  )
345
344
  for tool_use_id in tool_use_ids
@@ -384,4 +383,4 @@ def tool_result_content_blocks(block: dict[str, Any]) -> list[dict[str, Any]]:
384
383
  structured = [dict(item) for item in raw_content if isinstance(item, dict)]
385
384
  if structured:
386
385
  return structured
387
- return [text_block(str(block.get("model_text") or ""))]
386
+ return [text_block(str(block.get("output") or ""))]
@@ -198,7 +198,7 @@ class GoogleGeminiAdapter(ProviderAdapter):
198
198
  continue
199
199
 
200
200
  tool_id = str(block.get("tool_use_id") or "")
201
- response: dict[str, Any] = {"result": str(block.get("model_text") or "")}
201
+ response: dict[str, Any] = {"result": str(block.get("output") or "")}
202
202
  if block.get("is_error"):
203
203
  response["is_error"] = True
204
204