mycode-sdk 0.9.5__tar.gz → 0.11.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.
- {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/PKG-INFO +5 -3
- {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/README.md +4 -2
- {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/pyproject.toml +1 -1
- {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/__init__.py +2 -0
- {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/agent.py +117 -22
- {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/compact.py +19 -0
- {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/messages.py +2 -2
- {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/models.py +4 -4
- {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/providers/anthropic_like.py +3 -2
- {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/providers/base.py +1 -1
- {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/providers/gemini.py +8 -8
- {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/providers/openai_chat.py +2 -2
- {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/providers/openai_responses.py +4 -5
- {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/session.py +4 -7
- {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/tools.py +91 -24
- {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/.gitignore +0 -0
- {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/LICENSE +0 -0
- {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/attachments.py +0 -0
- {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/hooks.py +0 -0
- {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/models_catalog.json +0 -0
- {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/providers/__init__.py +0 -0
- {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/py.typed +0 -0
- {mycode_sdk-0.9.5 → mycode_sdk-0.11.0}/src/mycode/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mycode-sdk
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.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
|
|
@@ -71,7 +71,7 @@ print(result.text)
|
|
|
71
71
|
|
|
72
72
|
## Multi-turn conversations
|
|
73
73
|
|
|
74
|
-
Call `achat()` or `run()` again on the same `Agent`
|
|
74
|
+
Call `achat()` or `run()` again on the same `Agent` to continue the conversation:
|
|
75
75
|
|
|
76
76
|
```python
|
|
77
77
|
agent = Agent(model="claude-sonnet-4-6", api_key="...")
|
|
@@ -117,7 +117,7 @@ agent = Agent(
|
|
|
117
117
|
)
|
|
118
118
|
```
|
|
119
119
|
|
|
120
|
-
Construct another `Agent` with the same `(session_dir, session_id)`
|
|
120
|
+
Construct another `Agent` with the same `(session_dir, session_id)` to load the conversation history.
|
|
121
121
|
|
|
122
122
|
## Built-in tools
|
|
123
123
|
|
|
@@ -167,6 +167,8 @@ def summarize_file(ctx: ToolContext, path: str) -> str:
|
|
|
167
167
|
return result.output.splitlines()[0] if result.output else ""
|
|
168
168
|
```
|
|
169
169
|
|
|
170
|
+
Async tools use `await ctx.aread()`, `await ctx.awrite()`, `await ctx.aedit()`, and `await ctx.abash()`. Use `await ctx.acall(name, args)` to call another registered tool by name.
|
|
171
|
+
|
|
170
172
|
## Tool hooks
|
|
171
173
|
|
|
172
174
|
Inspect or replace tool calls before they run. Return `None` from `before_tool` to let the tool execute, or a `ToolExecutionResult` to skip it:
|
|
@@ -44,7 +44,7 @@ print(result.text)
|
|
|
44
44
|
|
|
45
45
|
## Multi-turn conversations
|
|
46
46
|
|
|
47
|
-
Call `achat()` or `run()` again on the same `Agent`
|
|
47
|
+
Call `achat()` or `run()` again on the same `Agent` to continue the conversation:
|
|
48
48
|
|
|
49
49
|
```python
|
|
50
50
|
agent = Agent(model="claude-sonnet-4-6", api_key="...")
|
|
@@ -90,7 +90,7 @@ agent = Agent(
|
|
|
90
90
|
)
|
|
91
91
|
```
|
|
92
92
|
|
|
93
|
-
Construct another `Agent` with the same `(session_dir, session_id)`
|
|
93
|
+
Construct another `Agent` with the same `(session_dir, session_id)` to load the conversation history.
|
|
94
94
|
|
|
95
95
|
## Built-in tools
|
|
96
96
|
|
|
@@ -140,6 +140,8 @@ def summarize_file(ctx: ToolContext, path: str) -> str:
|
|
|
140
140
|
return result.output.splitlines()[0] if result.output else ""
|
|
141
141
|
```
|
|
142
142
|
|
|
143
|
+
Async tools use `await ctx.aread()`, `await ctx.awrite()`, `await ctx.aedit()`, and `await ctx.abash()`. Use `await ctx.acall(name, args)` to call another registered tool by name.
|
|
144
|
+
|
|
143
145
|
## Tool hooks
|
|
144
146
|
|
|
145
147
|
Inspect or replace tool calls before they run. Return `None` from `before_tool` to let the tool execute, or a `ToolExecutionResult` to skip it:
|
|
@@ -4,6 +4,7 @@ from importlib import metadata
|
|
|
4
4
|
|
|
5
5
|
from mycode.agent import Agent, Event, PersistCallback, RunResult
|
|
6
6
|
from mycode.attachments import Attachment
|
|
7
|
+
from mycode.compact import NothingToCompactError
|
|
7
8
|
from mycode.hooks import AfterToolHook, BeforeToolHook, HookResult, Hooks, ToolHookContext
|
|
8
9
|
from mycode.messages import (
|
|
9
10
|
ContentBlock,
|
|
@@ -45,6 +46,7 @@ __all__ = [
|
|
|
45
46
|
"BeforeToolHook",
|
|
46
47
|
"HookResult",
|
|
47
48
|
"Hooks",
|
|
49
|
+
"NothingToCompactError",
|
|
48
50
|
"PersistCallback",
|
|
49
51
|
"RunResult",
|
|
50
52
|
"SessionStore",
|
|
@@ -23,7 +23,9 @@ from mycode.attachments import AttachmentLike, build_attachment_blocks
|
|
|
23
23
|
from mycode.compact import (
|
|
24
24
|
COMPACT_SUMMARY_PROMPT,
|
|
25
25
|
DEFAULT_COMPACT_THRESHOLD,
|
|
26
|
+
NothingToCompactError,
|
|
26
27
|
build_compact_event,
|
|
28
|
+
has_compactable_history,
|
|
27
29
|
should_compact,
|
|
28
30
|
)
|
|
29
31
|
from mycode.hooks import Hooks, ToolHookContext
|
|
@@ -129,7 +131,9 @@ class Agent:
|
|
|
129
131
|
self.system = system
|
|
130
132
|
self.hooks = hooks or Hooks()
|
|
131
133
|
self._cancel_event = asyncio.Event()
|
|
134
|
+
self._event_loop: asyncio.AbstractEventLoop | None = None
|
|
132
135
|
self._provider_event_task: asyncio.Future[ProviderStreamEvent] | None = None
|
|
136
|
+
self._active_tool_task: asyncio.Task[ToolExecutionResult] | None = None
|
|
133
137
|
|
|
134
138
|
# History resolution:
|
|
135
139
|
# - messages is None → auto-resume from disk if the session exists
|
|
@@ -199,10 +203,24 @@ class Agent:
|
|
|
199
203
|
def cancel(self) -> None:
|
|
200
204
|
"""Request cancellation of the in-flight turn."""
|
|
201
205
|
|
|
206
|
+
try:
|
|
207
|
+
running_loop = asyncio.get_running_loop()
|
|
208
|
+
except RuntimeError:
|
|
209
|
+
running_loop = None
|
|
210
|
+
|
|
211
|
+
loop = self._event_loop
|
|
212
|
+
if loop is not None and loop.is_running() and running_loop is not loop:
|
|
213
|
+
loop.call_soon_threadsafe(self._cancel_in_loop)
|
|
214
|
+
return
|
|
215
|
+
self._cancel_in_loop()
|
|
216
|
+
|
|
217
|
+
def _cancel_in_loop(self) -> None:
|
|
202
218
|
self._cancel_event.set()
|
|
203
219
|
self.tools.cancel_active()
|
|
204
220
|
if self._provider_event_task and not self._provider_event_task.done():
|
|
205
221
|
self._provider_event_task.cancel()
|
|
222
|
+
if self._active_tool_task and not self._active_tool_task.done():
|
|
223
|
+
self._active_tool_task.cancel()
|
|
206
224
|
|
|
207
225
|
def clear(self) -> None:
|
|
208
226
|
"""Drop the in-memory conversation history."""
|
|
@@ -213,6 +231,23 @@ class Agent:
|
|
|
213
231
|
# Tool execution
|
|
214
232
|
# ------------------------------------------------------------------
|
|
215
233
|
|
|
234
|
+
async def _execute_tool(
|
|
235
|
+
self,
|
|
236
|
+
spec: ToolSpec,
|
|
237
|
+
args: dict[str, Any],
|
|
238
|
+
ctx: ToolContext,
|
|
239
|
+
) -> ToolExecutionResult:
|
|
240
|
+
if not spec.is_async:
|
|
241
|
+
return await self.tools.aexecute(spec.name, args, ctx)
|
|
242
|
+
|
|
243
|
+
task = asyncio.create_task(self.tools.aexecute(spec.name, args, ctx))
|
|
244
|
+
self._active_tool_task = task
|
|
245
|
+
try:
|
|
246
|
+
return await task
|
|
247
|
+
finally:
|
|
248
|
+
if self._active_tool_task is task:
|
|
249
|
+
self._active_tool_task = None
|
|
250
|
+
|
|
216
251
|
async def _run_tool_call(self, tool_use: dict[str, Any]) -> AsyncIterator[Event]:
|
|
217
252
|
"""Run one tool call and emit the standard tool events."""
|
|
218
253
|
|
|
@@ -259,13 +294,23 @@ class Agent:
|
|
|
259
294
|
return
|
|
260
295
|
|
|
261
296
|
if spec.streams_output:
|
|
262
|
-
async for event in self._run_streaming_tool(
|
|
297
|
+
async for event in self._run_streaming_tool(
|
|
298
|
+
tool_id=tool_id,
|
|
299
|
+
spec=spec,
|
|
300
|
+
args=args,
|
|
301
|
+
hook_ctx=hook_ctx,
|
|
302
|
+
):
|
|
263
303
|
yield event
|
|
264
304
|
return
|
|
265
305
|
|
|
266
306
|
try:
|
|
267
307
|
ctx = self._ctx_for_call(tool_id)
|
|
268
|
-
result = await
|
|
308
|
+
result = await self._execute_tool(spec, args, ctx)
|
|
309
|
+
except asyncio.CancelledError:
|
|
310
|
+
if self._cancel_event.is_set():
|
|
311
|
+
yield self._error_done(tool_id, "error: cancelled")
|
|
312
|
+
return
|
|
313
|
+
raise
|
|
269
314
|
except Exception as exc: # pragma: no cover - defensive
|
|
270
315
|
result = ToolExecutionResult(output=f"error: {exc}", is_error=True)
|
|
271
316
|
|
|
@@ -275,7 +320,7 @@ class Agent:
|
|
|
275
320
|
self,
|
|
276
321
|
*,
|
|
277
322
|
tool_id: str,
|
|
278
|
-
|
|
323
|
+
spec: ToolSpec,
|
|
279
324
|
args: dict[str, Any],
|
|
280
325
|
hook_ctx: ToolHookContext,
|
|
281
326
|
) -> AsyncIterator[Event]:
|
|
@@ -289,13 +334,13 @@ class Agent:
|
|
|
289
334
|
|
|
290
335
|
ctx = self._ctx_for_call(tool_id, emit=on_output)
|
|
291
336
|
|
|
292
|
-
async def
|
|
337
|
+
async def run_tool() -> ToolExecutionResult:
|
|
293
338
|
try:
|
|
294
|
-
return await
|
|
339
|
+
return await self._execute_tool(spec, args, ctx)
|
|
295
340
|
finally:
|
|
296
341
|
loop.call_soon_threadsafe(output_queue.put_nowait, None)
|
|
297
342
|
|
|
298
|
-
task = asyncio.create_task(
|
|
343
|
+
task = asyncio.create_task(run_tool())
|
|
299
344
|
was_cancelled = False
|
|
300
345
|
output_parts: list[str] = []
|
|
301
346
|
|
|
@@ -317,8 +362,8 @@ class Agent:
|
|
|
317
362
|
output_parts.append(output)
|
|
318
363
|
yield Event("tool_output", {"tool_use_id": tool_id, "output": output})
|
|
319
364
|
|
|
320
|
-
if was_cancelled:
|
|
321
|
-
with suppress(Exception):
|
|
365
|
+
if was_cancelled or (self._cancel_event.is_set() and task.cancelled()):
|
|
366
|
+
with suppress(asyncio.CancelledError, Exception):
|
|
322
367
|
await task
|
|
323
368
|
output = "\n".join([*output_parts, "error: cancelled"]) if output_parts else "error: cancelled"
|
|
324
369
|
yield self._error_done(tool_id, output)
|
|
@@ -455,6 +500,23 @@ class Agent:
|
|
|
455
500
|
block["meta"] = {**meta, "duration_ms": duration_ms}
|
|
456
501
|
return
|
|
457
502
|
|
|
503
|
+
async def _persist_message(
|
|
504
|
+
self,
|
|
505
|
+
message: ConversationMessage,
|
|
506
|
+
on_persist: PersistCallback | None,
|
|
507
|
+
) -> None:
|
|
508
|
+
"""Persist one message: caller callback first, then the SDK session store."""
|
|
509
|
+
|
|
510
|
+
if on_persist is not None:
|
|
511
|
+
# Callers may need to write related records before the SDK
|
|
512
|
+
# appends this message to its own session log.
|
|
513
|
+
await on_persist(message)
|
|
514
|
+
if self._store is None:
|
|
515
|
+
return
|
|
516
|
+
if not self._store.session_exists(self.session_id):
|
|
517
|
+
await self._store.create_session(self.session_id, cwd=self.cwd)
|
|
518
|
+
await self._store.append_message(self.session_id, message)
|
|
519
|
+
|
|
458
520
|
# ------------------------------------------------------------------
|
|
459
521
|
# Public entry points
|
|
460
522
|
# ------------------------------------------------------------------
|
|
@@ -469,16 +531,9 @@ class Agent:
|
|
|
469
531
|
"""Run the full agent loop for one user message."""
|
|
470
532
|
|
|
471
533
|
async def persist(message: ConversationMessage) -> None:
|
|
472
|
-
|
|
473
|
-
# Callers may need to write related records before the SDK
|
|
474
|
-
# appends this message to its own session log.
|
|
475
|
-
await on_persist(message)
|
|
476
|
-
if self._store is None:
|
|
477
|
-
return
|
|
478
|
-
if not self._store.session_exists(self.session_id):
|
|
479
|
-
await self._store.create_session(self.session_id, cwd=self.cwd)
|
|
480
|
-
await self._store.append_message(self.session_id, message)
|
|
534
|
+
await self._persist_message(message, on_persist)
|
|
481
535
|
|
|
536
|
+
self._event_loop = asyncio.get_running_loop()
|
|
482
537
|
self._cancel_event.clear()
|
|
483
538
|
|
|
484
539
|
user_message: ConversationMessage
|
|
@@ -671,7 +726,7 @@ class Agent:
|
|
|
671
726
|
return
|
|
672
727
|
if should_compact(total_tokens, self.context_window, self.compact_threshold):
|
|
673
728
|
try:
|
|
674
|
-
await self._compact(adapter,
|
|
729
|
+
await self._compact(adapter, on_persist)
|
|
675
730
|
yield Event("compact", {})
|
|
676
731
|
except asyncio.CancelledError:
|
|
677
732
|
yield Event("error", {"message": "cancelled"})
|
|
@@ -719,12 +774,48 @@ class Agent:
|
|
|
719
774
|
# Context compaction
|
|
720
775
|
# ------------------------------------------------------------------
|
|
721
776
|
|
|
777
|
+
async def acompact(
|
|
778
|
+
self,
|
|
779
|
+
*,
|
|
780
|
+
on_persist: PersistCallback | None = None,
|
|
781
|
+
) -> ConversationMessage:
|
|
782
|
+
"""Compact the conversation now and return the persisted compact marker.
|
|
783
|
+
|
|
784
|
+
Raises :class:`NothingToCompactError` when no new context follows the
|
|
785
|
+
latest compact marker, and :class:`asyncio.CancelledError` when
|
|
786
|
+
:meth:`cancel` stops the summary request.
|
|
787
|
+
"""
|
|
788
|
+
|
|
789
|
+
self._event_loop = asyncio.get_running_loop()
|
|
790
|
+
self._cancel_event.clear()
|
|
791
|
+
adapter = get_provider_adapter(self.provider)
|
|
792
|
+
return await self._compact(adapter, on_persist)
|
|
793
|
+
|
|
794
|
+
def compact(
|
|
795
|
+
self,
|
|
796
|
+
*,
|
|
797
|
+
on_persist: PersistCallback | None = None,
|
|
798
|
+
) -> ConversationMessage:
|
|
799
|
+
"""Compact the conversation synchronously; see :meth:`acompact`."""
|
|
800
|
+
|
|
801
|
+
try:
|
|
802
|
+
asyncio.get_running_loop()
|
|
803
|
+
except RuntimeError:
|
|
804
|
+
pass
|
|
805
|
+
else:
|
|
806
|
+
raise RuntimeError("Agent.compact() cannot run inside an active event loop; use Agent.acompact() instead")
|
|
807
|
+
|
|
808
|
+
return asyncio.run(self.acompact(on_persist=on_persist))
|
|
809
|
+
|
|
722
810
|
async def _compact(
|
|
723
811
|
self,
|
|
724
812
|
adapter: ProviderAdapter,
|
|
725
|
-
|
|
726
|
-
) ->
|
|
727
|
-
"""Ask the provider for a summary, persist the compact
|
|
813
|
+
on_persist: PersistCallback | None,
|
|
814
|
+
) -> ConversationMessage:
|
|
815
|
+
"""Ask the provider for a summary, persist and append the compact marker."""
|
|
816
|
+
|
|
817
|
+
if not has_compactable_history(self.messages):
|
|
818
|
+
raise NothingToCompactError("nothing to compact")
|
|
728
819
|
|
|
729
820
|
request = self._build_request(
|
|
730
821
|
tools=[],
|
|
@@ -746,6 +837,9 @@ class Agent:
|
|
|
746
837
|
if not summary_text:
|
|
747
838
|
raise ValueError("compaction produced empty summary")
|
|
748
839
|
|
|
840
|
+
if self._cancel_event.is_set():
|
|
841
|
+
raise asyncio.CancelledError
|
|
842
|
+
|
|
749
843
|
summary_total_tokens = (summary_message.get("meta") or {}).get("total_tokens")
|
|
750
844
|
compact_event = build_compact_event(
|
|
751
845
|
summary_text,
|
|
@@ -754,5 +848,6 @@ class Agent:
|
|
|
754
848
|
total_tokens=summary_total_tokens,
|
|
755
849
|
)
|
|
756
850
|
|
|
757
|
-
await
|
|
851
|
+
await self._persist_message(compact_event, on_persist)
|
|
758
852
|
self.messages.append(compact_event)
|
|
853
|
+
return compact_event
|
|
@@ -8,6 +8,11 @@ from mycode.messages import ConversationMessage, build_message, text_block
|
|
|
8
8
|
|
|
9
9
|
DEFAULT_COMPACT_THRESHOLD = 0.8
|
|
10
10
|
|
|
11
|
+
|
|
12
|
+
class NothingToCompactError(ValueError):
|
|
13
|
+
"""Raised when there is no new context to compact past the latest marker."""
|
|
14
|
+
|
|
15
|
+
|
|
11
16
|
COMPACT_SUMMARY_PROMPT = """\
|
|
12
17
|
Summarize this conversation to create a continuation document. \
|
|
13
18
|
This summary will replace the full conversation history, so it must \
|
|
@@ -65,6 +70,20 @@ def should_compact(
|
|
|
65
70
|
return last_total_tokens >= context_window * threshold
|
|
66
71
|
|
|
67
72
|
|
|
73
|
+
def has_compactable_history(messages: list[ConversationMessage]) -> bool:
|
|
74
|
+
"""True when at least one non-empty user/assistant message follows the latest compact marker."""
|
|
75
|
+
|
|
76
|
+
last_compact = -1
|
|
77
|
+
for i, message in enumerate(messages):
|
|
78
|
+
if message.get("role") == "compact":
|
|
79
|
+
last_compact = i
|
|
80
|
+
|
|
81
|
+
return any(
|
|
82
|
+
message.get("role") in ("user", "assistant") and message.get("content")
|
|
83
|
+
for message in messages[last_compact + 1 :]
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
68
87
|
def build_compact_event(
|
|
69
88
|
summary_text: str,
|
|
70
89
|
*,
|
|
@@ -165,8 +165,8 @@ def flatten_message_text(message: ConversationMessage, *, include_thinking: bool
|
|
|
165
165
|
continue
|
|
166
166
|
raw_meta = block.get("meta")
|
|
167
167
|
meta = raw_meta if isinstance(raw_meta, dict) else {}
|
|
168
|
-
#
|
|
169
|
-
if meta.get("attachment"):
|
|
168
|
+
# Local payload blocks should not become session titles or history labels.
|
|
169
|
+
if meta.get("attachment") or meta.get("skill_snapshot"):
|
|
170
170
|
continue
|
|
171
171
|
btype = block.get("type")
|
|
172
172
|
if btype == "text" or (include_thinking and btype == "thinking"):
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
import functools
|
|
6
5
|
import json
|
|
7
6
|
from dataclasses import dataclass, replace
|
|
7
|
+
from functools import cache
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from typing import Any
|
|
10
10
|
|
|
@@ -30,14 +30,14 @@ class ModelMetadata:
|
|
|
30
30
|
supports_pdf_input: bool | None = None
|
|
31
31
|
|
|
32
32
|
|
|
33
|
-
@
|
|
33
|
+
@cache
|
|
34
34
|
def load_models_catalog() -> dict[str, Any] | None:
|
|
35
35
|
"""Load the bundled model catalog from disk once per process."""
|
|
36
36
|
|
|
37
37
|
try:
|
|
38
38
|
data = json.loads(_MODELS_CATALOG_PATH.read_text(encoding="utf-8"))
|
|
39
|
-
except
|
|
40
|
-
|
|
39
|
+
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
40
|
+
return None
|
|
41
41
|
return data if isinstance(data, dict) else None
|
|
42
42
|
|
|
43
43
|
|
|
@@ -111,8 +111,9 @@ class AnthropicLikeAdapter(ProviderAdapter):
|
|
|
111
111
|
if not isinstance(content, list):
|
|
112
112
|
return
|
|
113
113
|
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
for block in reversed(content):
|
|
115
|
+
if not isinstance(block, dict):
|
|
116
|
+
continue
|
|
116
117
|
block_type = str(block.get("type") or "")
|
|
117
118
|
if block_type not in {"text", "image", "document", "tool_result"}:
|
|
118
119
|
continue
|
|
@@ -88,7 +88,7 @@ def parse_tool_call_input(raw_arguments: str) -> tuple[dict[str, Any], dict[str,
|
|
|
88
88
|
try:
|
|
89
89
|
parsed = json.loads(raw_arguments)
|
|
90
90
|
except json.JSONDecodeError:
|
|
91
|
-
|
|
91
|
+
return {}, {"raw_arguments": raw_arguments}
|
|
92
92
|
if isinstance(parsed, dict):
|
|
93
93
|
return parsed, {}
|
|
94
94
|
return {}, {"raw_arguments": raw_arguments}
|
|
@@ -84,7 +84,7 @@ class GoogleGeminiAdapter(ProviderAdapter):
|
|
|
84
84
|
finish_message = getattr(candidate, "finish_message", None) or finish_message
|
|
85
85
|
|
|
86
86
|
for part in getattr(getattr(candidate, "content", None), "parts", None) or []:
|
|
87
|
-
|
|
87
|
+
if event := self._consume_part(blocks, part):
|
|
88
88
|
yield event
|
|
89
89
|
except APIError as exc:
|
|
90
90
|
raise ValueError(str(exc)) from exc
|
|
@@ -223,7 +223,7 @@ class GoogleGeminiAdapter(ProviderAdapter):
|
|
|
223
223
|
return contents
|
|
224
224
|
|
|
225
225
|
def _build_config(self, request: ProviderRequest) -> types.GenerateContentConfig:
|
|
226
|
-
tools: list[types.Tool
|
|
226
|
+
tools: list[types.Tool] | None = None
|
|
227
227
|
if request.tools:
|
|
228
228
|
tools = [
|
|
229
229
|
types.Tool(
|
|
@@ -262,7 +262,7 @@ class GoogleGeminiAdapter(ProviderAdapter):
|
|
|
262
262
|
thinking_config=thinking_config,
|
|
263
263
|
)
|
|
264
264
|
|
|
265
|
-
def _consume_part(self, blocks: list[dict[str, Any]], part: Any) ->
|
|
265
|
+
def _consume_part(self, blocks: list[dict[str, Any]], part: Any) -> ProviderStreamEvent | None:
|
|
266
266
|
native_part = _to_json(part) or {}
|
|
267
267
|
if native_part.get("thought") is False:
|
|
268
268
|
native_part.pop("thought", None)
|
|
@@ -278,12 +278,12 @@ class GoogleGeminiAdapter(ProviderAdapter):
|
|
|
278
278
|
meta={"native": {"part": native_part}},
|
|
279
279
|
)
|
|
280
280
|
)
|
|
281
|
-
return
|
|
281
|
+
return None
|
|
282
282
|
|
|
283
283
|
text = getattr(part, "text", None)
|
|
284
284
|
if text is None or text == "":
|
|
285
285
|
if not native_part.get("thought_signature"):
|
|
286
|
-
return
|
|
286
|
+
return None
|
|
287
287
|
|
|
288
288
|
# Gemini may put the final thought signature into an empty-text part.
|
|
289
289
|
# Keep it as a separate empty block so replay preserves the original
|
|
@@ -291,7 +291,7 @@ class GoogleGeminiAdapter(ProviderAdapter):
|
|
|
291
291
|
part_meta = {"native": {"part": native_part}}
|
|
292
292
|
is_thought = getattr(part, "thought", False)
|
|
293
293
|
blocks.append(thinking_block("", meta=part_meta) if is_thought else text_block("", meta=part_meta))
|
|
294
|
-
return
|
|
294
|
+
return None
|
|
295
295
|
|
|
296
296
|
is_thought = bool(getattr(part, "thought", False))
|
|
297
297
|
event = ProviderStreamEvent("thinking_delta" if is_thought else "text_delta", {"text": str(text)})
|
|
@@ -310,10 +310,10 @@ class GoogleGeminiAdapter(ProviderAdapter):
|
|
|
310
310
|
last_part["text"] = f"{last_part.get('text') or ''}{text}"
|
|
311
311
|
if current_signature and not last_signature:
|
|
312
312
|
last_part["thought_signature"] = current_signature
|
|
313
|
-
return
|
|
313
|
+
return event
|
|
314
314
|
|
|
315
315
|
part_meta = {"native": {"part": native_part}}
|
|
316
316
|
blocks.append(
|
|
317
317
|
thinking_block(str(text), meta=part_meta) if is_thought else text_block(str(text), meta=part_meta)
|
|
318
318
|
)
|
|
319
|
-
return
|
|
319
|
+
return event
|
|
@@ -9,7 +9,7 @@ from typing import Any, override
|
|
|
9
9
|
|
|
10
10
|
from openai import APIError, AsyncOpenAI
|
|
11
11
|
|
|
12
|
-
from mycode.messages import assistant_message, text_block, thinking_block, tool_use_block
|
|
12
|
+
from mycode.messages import ConversationMessage, assistant_message, text_block, thinking_block, tool_use_block
|
|
13
13
|
from mycode.providers.base import (
|
|
14
14
|
DEFAULT_REQUEST_TIMEOUT,
|
|
15
15
|
ProviderAdapter,
|
|
@@ -174,7 +174,7 @@ class OpenAIChatAdapter(ProviderAdapter):
|
|
|
174
174
|
},
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
def _serialize_message(self, message:
|
|
177
|
+
def _serialize_message(self, message: ConversationMessage) -> list[dict[str, Any]]:
|
|
178
178
|
"""Convert one canonical message into Chat Completions wire messages."""
|
|
179
179
|
|
|
180
180
|
role = str(message.get("role") or "user")
|
|
@@ -102,9 +102,8 @@ class OpenAIResponsesAdapter(ProviderAdapter):
|
|
|
102
102
|
raise ValueError(str(exc)) from exc
|
|
103
103
|
|
|
104
104
|
def _build_request_payload(self, request: ProviderRequest) -> dict[str, Any]:
|
|
105
|
-
prepared_messages = self.prepare_messages(request)
|
|
106
105
|
input_items: list[dict[str, Any]] = []
|
|
107
|
-
for message in
|
|
106
|
+
for message in self.prepare_messages(request):
|
|
108
107
|
role = message.get("role")
|
|
109
108
|
if role == "user":
|
|
110
109
|
input_items.extend(self._serialize_user_message(message))
|
|
@@ -262,7 +261,7 @@ class OpenAIResponsesAdapter(ProviderAdapter):
|
|
|
262
261
|
response: Any,
|
|
263
262
|
*,
|
|
264
263
|
output_items: list[Any] | None = None,
|
|
265
|
-
) ->
|
|
264
|
+
) -> ConversationMessage:
|
|
266
265
|
raw_output = output_items if output_items is not None else (getattr(response, "output", None) or [])
|
|
267
266
|
dumped_output_items = dump_model(raw_output)
|
|
268
267
|
blocks: list[dict[str, Any]] = []
|
|
@@ -357,8 +356,8 @@ def _normalize_strict_schema(schema: Any) -> None:
|
|
|
357
356
|
|
|
358
357
|
properties = schema.get("properties")
|
|
359
358
|
if isinstance(properties, dict):
|
|
360
|
-
required_names = {
|
|
361
|
-
for name, property_schema in
|
|
359
|
+
required_names = {name for name in schema.get("required", []) if isinstance(name, str)}
|
|
360
|
+
for name, property_schema in properties.items():
|
|
362
361
|
_normalize_strict_schema(property_schema)
|
|
363
362
|
if name not in required_names:
|
|
364
363
|
properties[name] = _nullable_schema(property_schema)
|
|
@@ -139,7 +139,7 @@ class SessionStore:
|
|
|
139
139
|
meta = _project_meta(meta)
|
|
140
140
|
self.meta_path(session_id).write_text(json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
141
141
|
index = self._read_index()
|
|
142
|
-
index[session_id] =
|
|
142
|
+
index[session_id] = meta
|
|
143
143
|
self._write_index(index)
|
|
144
144
|
|
|
145
145
|
def _read_index(self) -> SessionIndex:
|
|
@@ -161,7 +161,7 @@ class SessionStore:
|
|
|
161
161
|
continue
|
|
162
162
|
meta = self._read_meta(entry.name)
|
|
163
163
|
if meta is not None:
|
|
164
|
-
index[entry.name] =
|
|
164
|
+
index[entry.name] = meta
|
|
165
165
|
self._write_index(index)
|
|
166
166
|
return index
|
|
167
167
|
|
|
@@ -246,9 +246,7 @@ class SessionStore:
|
|
|
246
246
|
|
|
247
247
|
async def delete_session(self, session_id: str) -> None:
|
|
248
248
|
def delete() -> None:
|
|
249
|
-
|
|
250
|
-
if sdir.exists():
|
|
251
|
-
shutil.rmtree(sdir, ignore_errors=True)
|
|
249
|
+
shutil.rmtree(self.session_dir(session_id), ignore_errors=True)
|
|
252
250
|
index = self._read_index()
|
|
253
251
|
index.pop(session_id, None)
|
|
254
252
|
self._write_index(index)
|
|
@@ -285,8 +283,7 @@ class SessionStore:
|
|
|
285
283
|
|
|
286
284
|
def append() -> None:
|
|
287
285
|
with self.messages_path(session_id).open("a", encoding="utf-8") as handle:
|
|
288
|
-
handle.write(json.dumps(message, ensure_ascii=False))
|
|
289
|
-
handle.write("\n")
|
|
286
|
+
handle.write(json.dumps(message, ensure_ascii=False) + "\n")
|
|
290
287
|
|
|
291
288
|
meta = self._read_meta(session_id)
|
|
292
289
|
if meta is None:
|
|
@@ -13,6 +13,7 @@ facades on :class:`ToolContext`::
|
|
|
13
13
|
from __future__ import annotations
|
|
14
14
|
|
|
15
15
|
import asyncio
|
|
16
|
+
import functools
|
|
16
17
|
import inspect
|
|
17
18
|
import json
|
|
18
19
|
import os
|
|
@@ -25,7 +26,7 @@ import time
|
|
|
25
26
|
import typing
|
|
26
27
|
from base64 import b64encode
|
|
27
28
|
from collections import deque
|
|
28
|
-
from collections.abc import Callable, Mapping, Sequence
|
|
29
|
+
from collections.abc import Callable, Coroutine, Mapping, Sequence
|
|
29
30
|
from contextlib import suppress
|
|
30
31
|
from dataclasses import dataclass
|
|
31
32
|
from difflib import SequenceMatcher, unified_diff
|
|
@@ -71,7 +72,19 @@ class ToolExecutionResult:
|
|
|
71
72
|
is_error: bool = False
|
|
72
73
|
|
|
73
74
|
|
|
74
|
-
|
|
75
|
+
SyncToolRunner = Callable[["ToolContext", dict[str, Any]], ToolExecutionResult]
|
|
76
|
+
AsyncToolRunner = Callable[["ToolContext", dict[str, Any]], Coroutine[Any, Any, ToolExecutionResult]]
|
|
77
|
+
ToolRunner = SyncToolRunner | AsyncToolRunner
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _is_async_callable(runner: ToolRunner) -> bool:
|
|
81
|
+
current: Any = runner
|
|
82
|
+
while True:
|
|
83
|
+
current = inspect.unwrap(current)
|
|
84
|
+
if isinstance(current, functools.partial):
|
|
85
|
+
current = current.func
|
|
86
|
+
continue
|
|
87
|
+
return inspect.iscoroutinefunction(current) or inspect.iscoroutinefunction(type(current).__call__)
|
|
75
88
|
|
|
76
89
|
|
|
77
90
|
@dataclass(frozen=True)
|
|
@@ -85,6 +98,10 @@ class ToolSpec:
|
|
|
85
98
|
# Streaming tools push incremental output through ToolContext.emit.
|
|
86
99
|
streams_output: bool = False
|
|
87
100
|
|
|
101
|
+
@property
|
|
102
|
+
def is_async(self) -> bool:
|
|
103
|
+
return _is_async_callable(self.runner)
|
|
104
|
+
|
|
88
105
|
|
|
89
106
|
# ---------------------------------------------------------------------------
|
|
90
107
|
# ToolContext
|
|
@@ -115,25 +132,47 @@ class ToolContext:
|
|
|
115
132
|
) -> ToolExecutionResult:
|
|
116
133
|
return self.call("read", {"path": path, "offset": offset, "limit": limit})
|
|
117
134
|
|
|
135
|
+
async def aread(
|
|
136
|
+
self,
|
|
137
|
+
path: str,
|
|
138
|
+
*,
|
|
139
|
+
offset: int | None = None,
|
|
140
|
+
limit: int | None = None,
|
|
141
|
+
) -> ToolExecutionResult:
|
|
142
|
+
return await self.acall("read", {"path": path, "offset": offset, "limit": limit})
|
|
143
|
+
|
|
118
144
|
def write(self, path: str, content: str) -> ToolExecutionResult:
|
|
119
145
|
return self.call("write", {"path": path, "content": content})
|
|
120
146
|
|
|
147
|
+
async def awrite(self, path: str, content: str) -> ToolExecutionResult:
|
|
148
|
+
return await self.acall("write", {"path": path, "content": content})
|
|
149
|
+
|
|
121
150
|
def edit(self, path: str, edits: list[dict[str, str]]) -> ToolExecutionResult:
|
|
122
151
|
return self.call("edit", {"path": path, "edits": edits})
|
|
123
152
|
|
|
153
|
+
async def aedit(self, path: str, edits: list[dict[str, str]]) -> ToolExecutionResult:
|
|
154
|
+
return await self.acall("edit", {"path": path, "edits": edits})
|
|
155
|
+
|
|
124
156
|
def bash(self, command: str, *, timeout: int | None = None) -> ToolExecutionResult:
|
|
125
157
|
return self.call("bash", {"command": command, "timeout": timeout})
|
|
126
158
|
|
|
159
|
+
async def abash(
|
|
160
|
+
self,
|
|
161
|
+
command: str,
|
|
162
|
+
*,
|
|
163
|
+
timeout: int | None = None, # noqa: ASYNC109
|
|
164
|
+
) -> ToolExecutionResult:
|
|
165
|
+
return await self.acall("bash", {"command": command, "timeout": timeout})
|
|
166
|
+
|
|
127
167
|
def call(self, name: str, args: dict[str, Any]) -> ToolExecutionResult:
|
|
128
168
|
"""Dispatch through the registry, including from custom tool wrappers."""
|
|
129
169
|
|
|
130
170
|
return self.executor.execute(name, args, self)
|
|
131
171
|
|
|
132
|
-
def
|
|
133
|
-
|
|
172
|
+
async def acall(self, name: str, args: dict[str, Any]) -> ToolExecutionResult:
|
|
173
|
+
"""Asynchronously dispatch through the registry."""
|
|
134
174
|
|
|
135
|
-
|
|
136
|
-
self.executor.untrack_proc(proc)
|
|
175
|
+
return await self.executor.aexecute(name, args, self)
|
|
137
176
|
|
|
138
177
|
|
|
139
178
|
# ---------------------------------------------------------------------------
|
|
@@ -166,7 +205,21 @@ class ToolExecutor:
|
|
|
166
205
|
spec = self._tools.get(name)
|
|
167
206
|
if spec is None:
|
|
168
207
|
return ToolExecutionResult(output=f"error: unknown tool: {name}", is_error=True)
|
|
169
|
-
|
|
208
|
+
if spec.is_async:
|
|
209
|
+
runner = cast(AsyncToolRunner, spec.runner)
|
|
210
|
+
return asyncio.run(runner(ctx, args))
|
|
211
|
+
runner = cast(SyncToolRunner, spec.runner)
|
|
212
|
+
return runner(ctx, args)
|
|
213
|
+
|
|
214
|
+
async def aexecute(self, name: str, args: dict[str, Any], ctx: ToolContext) -> ToolExecutionResult:
|
|
215
|
+
spec = self._tools.get(name)
|
|
216
|
+
if spec is None:
|
|
217
|
+
return ToolExecutionResult(output=f"error: unknown tool: {name}", is_error=True)
|
|
218
|
+
if spec.is_async:
|
|
219
|
+
runner = cast(AsyncToolRunner, spec.runner)
|
|
220
|
+
return await runner(ctx, args)
|
|
221
|
+
runner = cast(SyncToolRunner, spec.runner)
|
|
222
|
+
return await asyncio.to_thread(runner, ctx, args)
|
|
170
223
|
|
|
171
224
|
# Subprocess tracking: register with both this executor (per-session
|
|
172
225
|
# cancel) and the process-global set (shutdown cleanup).
|
|
@@ -202,6 +255,9 @@ class ToolExecutor:
|
|
|
202
255
|
# agents may run concurrently in the same process); this module-level set is
|
|
203
256
|
# a shutdown-time safety net exposed as ``cancel_all_tools``.
|
|
204
257
|
|
|
258
|
+
_ACTIVE_PROCS: set[subprocess.Popen[str]] = set()
|
|
259
|
+
_ACTIVE_PROCS_LOCK = threading.Lock()
|
|
260
|
+
|
|
205
261
|
|
|
206
262
|
def cancel_all_tools() -> None:
|
|
207
263
|
"""Terminate every running bash subprocess in the current process."""
|
|
@@ -213,10 +269,6 @@ def cancel_all_tools() -> None:
|
|
|
213
269
|
_kill_proc_tree(proc)
|
|
214
270
|
|
|
215
271
|
|
|
216
|
-
_ACTIVE_PROCS: set[subprocess.Popen[str]] = set()
|
|
217
|
-
_ACTIVE_PROCS_LOCK = threading.Lock()
|
|
218
|
-
|
|
219
|
-
|
|
220
272
|
def _kill_proc_tree(proc: subprocess.Popen[str]) -> None:
|
|
221
273
|
try:
|
|
222
274
|
if os.name == "posix":
|
|
@@ -326,7 +378,7 @@ def tool(
|
|
|
326
378
|
|
|
327
379
|
is_async = inspect.iscoroutinefunction(fn)
|
|
328
380
|
|
|
329
|
-
def
|
|
381
|
+
def prepare_args(args: dict[str, Any]) -> dict[str, Any] | ToolExecutionResult:
|
|
330
382
|
# Strict providers send explicit null for an omitted optional field;
|
|
331
383
|
# drop it so the parameter default applies instead of failing validation.
|
|
332
384
|
validation_args = {
|
|
@@ -339,14 +391,29 @@ def tool(
|
|
|
339
391
|
except ValidationError as exc:
|
|
340
392
|
return ToolExecutionResult(output=f"error: invalid tool input: {exc}", is_error=True)
|
|
341
393
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
394
|
+
return {name: getattr(parsed_args, name) for name in tool_param_names}
|
|
395
|
+
|
|
396
|
+
if is_async:
|
|
397
|
+
|
|
398
|
+
async def async_runner(context: ToolContext, args: dict[str, Any]) -> ToolExecutionResult:
|
|
399
|
+
call_args = prepare_args(args)
|
|
400
|
+
if isinstance(call_args, ToolExecutionResult):
|
|
401
|
+
return call_args
|
|
402
|
+
value = await (fn(context, **call_args) if wants_context else fn(**call_args))
|
|
403
|
+
return _coerce_tool_result(value)
|
|
404
|
+
|
|
405
|
+
runner: ToolRunner = async_runner
|
|
406
|
+
|
|
407
|
+
else:
|
|
408
|
+
|
|
409
|
+
def sync_runner(context: ToolContext, args: dict[str, Any]) -> ToolExecutionResult:
|
|
410
|
+
call_args = prepare_args(args)
|
|
411
|
+
if isinstance(call_args, ToolExecutionResult):
|
|
412
|
+
return call_args
|
|
348
413
|
value = fn(context, **call_args) if wants_context else fn(**call_args)
|
|
349
|
-
|
|
414
|
+
return _coerce_tool_result(value)
|
|
415
|
+
|
|
416
|
+
runner = sync_runner
|
|
350
417
|
|
|
351
418
|
return ToolSpec(
|
|
352
419
|
name=tool_name,
|
|
@@ -479,8 +546,8 @@ def read_tool(
|
|
|
479
546
|
],
|
|
480
547
|
)
|
|
481
548
|
|
|
482
|
-
start_line = offset if
|
|
483
|
-
line_limit = limit if
|
|
549
|
+
start_line = offset if offset is not None and offset > 0 else 1
|
|
550
|
+
line_limit = limit if limit is not None and limit > 0 else DEFAULT_MAX_LINES
|
|
484
551
|
lines: list[str] = []
|
|
485
552
|
total_lines = 0
|
|
486
553
|
next_offset: int | None = None
|
|
@@ -764,7 +831,7 @@ def bash_tool(ctx: ToolContext, command: str, timeout: int | None = None) -> Too
|
|
|
764
831
|
again by :func:`truncate_text` to the display limits.
|
|
765
832
|
"""
|
|
766
833
|
|
|
767
|
-
timeout_seconds =
|
|
834
|
+
timeout_seconds = timeout if timeout is not None and timeout > 0 else BASH_TIMEOUT_SECONDS
|
|
768
835
|
|
|
769
836
|
proc: subprocess.Popen[str] | None = None
|
|
770
837
|
log_path = ctx.tool_output_dir / f"bash-{ctx.tool_call_id or 'call'}.log"
|
|
@@ -787,7 +854,7 @@ def bash_tool(ctx: ToolContext, command: str, timeout: int | None = None) -> Too
|
|
|
787
854
|
bufsize=1,
|
|
788
855
|
start_new_session=os.name == "posix",
|
|
789
856
|
)
|
|
790
|
-
ctx.track_proc(proc)
|
|
857
|
+
ctx.executor.track_proc(proc)
|
|
791
858
|
|
|
792
859
|
stdout = cast(TextIO, proc.stdout)
|
|
793
860
|
output_queue: queue.Queue[str | None] = queue.Queue()
|
|
@@ -903,7 +970,7 @@ def bash_tool(ctx: ToolContext, command: str, timeout: int | None = None) -> Too
|
|
|
903
970
|
with suppress(Exception):
|
|
904
971
|
log_file.close()
|
|
905
972
|
if proc is not None:
|
|
906
|
-
ctx.untrack_proc(proc)
|
|
973
|
+
ctx.executor.untrack_proc(proc)
|
|
907
974
|
if proc.poll() is None:
|
|
908
975
|
_kill_proc_tree(proc)
|
|
909
976
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|