mycode-cli 0.1.2__py3-none-any.whl → 0.2.0__py3-none-any.whl
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/cli/chat.py +59 -36
- mycode/cli/main.py +5 -7
- mycode/cli/render.py +8 -4
- mycode/cli/runtime.py +4 -0
- mycode/core/agent.py +24 -14
- mycode/core/config.py +52 -36
- mycode/core/messages.py +21 -5
- mycode/core/models.py +2 -0
- mycode/core/models_catalog.json +761 -353
- mycode/core/providers/anthropic_like.py +11 -3
- mycode/core/providers/base.py +42 -6
- mycode/core/providers/gemini.py +12 -11
- mycode/core/providers/openai_chat.py +25 -14
- mycode/core/providers/openai_responses.py +63 -25
- mycode/core/session.py +29 -16
- mycode/core/system_prompt.py +1 -1
- mycode/core/tools.py +20 -5
- mycode/core/utils.py +5 -0
- mycode/server/routers/chat.py +76 -35
- mycode/server/routers/sessions.py +2 -1
- mycode/server/run_manager.py +2 -3
- mycode/server/schemas.py +2 -1
- mycode/server/static/assets/{EditDiff-B_aujzJQ.js → EditDiff-HrQSuYB-.js} +1 -1
- mycode/server/static/assets/index-gc57yaYT.js +208 -0
- mycode/server/static/assets/index-rDD0Lk3o.css +1 -0
- mycode/server/static/index.html +2 -2
- {mycode_cli-0.1.2.dist-info → mycode_cli-0.2.0.dist-info}/METADATA +3 -3
- {mycode_cli-0.1.2.dist-info → mycode_cli-0.2.0.dist-info}/RECORD +31 -31
- mycode/server/static/assets/index-BhG63UMx.css +0 -1
- mycode/server/static/assets/index-DpmWOCHa.js +0 -206
- {mycode_cli-0.1.2.dist-info → mycode_cli-0.2.0.dist-info}/WHEEL +0 -0
- {mycode_cli-0.1.2.dist-info → mycode_cli-0.2.0.dist-info}/entry_points.txt +0 -0
- {mycode_cli-0.1.2.dist-info → mycode_cli-0.2.0.dist-info}/licenses/LICENSE +0 -0
mycode/cli/chat.py
CHANGED
|
@@ -7,15 +7,18 @@ import html
|
|
|
7
7
|
import re
|
|
8
8
|
import shlex
|
|
9
9
|
from base64 import b64encode
|
|
10
|
+
from collections.abc import Iterable
|
|
10
11
|
from pathlib import Path
|
|
11
|
-
from typing import Any
|
|
12
|
+
from typing import Any, override
|
|
12
13
|
|
|
13
14
|
from prompt_toolkit import PromptSession
|
|
14
15
|
from prompt_toolkit.application import Application, get_app
|
|
15
|
-
from prompt_toolkit.completion import Completer, Completion
|
|
16
|
-
from prompt_toolkit.
|
|
16
|
+
from prompt_toolkit.completion import CompleteEvent, Completer, Completion
|
|
17
|
+
from prompt_toolkit.document import Document
|
|
18
|
+
from prompt_toolkit.formatted_text import ANSI, StyleAndTextTuples
|
|
17
19
|
from prompt_toolkit.history import FileHistory
|
|
18
20
|
from prompt_toolkit.key_binding import KeyBindings
|
|
21
|
+
from prompt_toolkit.key_binding.key_processor import KeyPressEvent
|
|
19
22
|
from prompt_toolkit.keys import Keys
|
|
20
23
|
from prompt_toolkit.layout import Layout
|
|
21
24
|
from prompt_toolkit.widgets import RadioList
|
|
@@ -23,9 +26,9 @@ from rich.text import Text
|
|
|
23
26
|
|
|
24
27
|
from mycode.core.agent import Agent
|
|
25
28
|
from mycode.core.config import resolve_mycode_home
|
|
26
|
-
from mycode.core.messages import build_message, image_block, text_block
|
|
29
|
+
from mycode.core.messages import build_message, document_block, image_block, text_block
|
|
27
30
|
from mycode.core.session import SessionStore
|
|
28
|
-
from mycode.core.tools import detect_image_mime_type, resolve_path
|
|
31
|
+
from mycode.core.tools import detect_document_mime_type, detect_image_mime_type, resolve_path
|
|
29
32
|
|
|
30
33
|
from .render import ReplyRenderer, TerminalView, format_local_timestamp
|
|
31
34
|
from .runtime import (
|
|
@@ -62,17 +65,19 @@ _AT_PATH_RE = re.compile(r"(?<!\S)@(?:(?P<quote>['\"])(?P<quoted>[^'\"]*)|(?P<pl
|
|
|
62
65
|
_FOCUSED_STYLE = "bold blue" if TERMINAL_THEME == "light" else "bold cyan"
|
|
63
66
|
|
|
64
67
|
|
|
65
|
-
class _InlineRadioList[T](RadioList):
|
|
68
|
+
class _InlineRadioList[T](RadioList[T]):
|
|
66
69
|
"""Arrow-key list that shows > on the focused item and exits on Enter."""
|
|
67
70
|
|
|
71
|
+
@override
|
|
68
72
|
def _handle_enter(self) -> None:
|
|
69
73
|
# Only called by Enter/Space (not arrows), so safe to exit.
|
|
70
74
|
self.current_value = self.values[self._selected_index][0]
|
|
71
75
|
get_app().exit(result=self.current_value)
|
|
72
76
|
|
|
73
|
-
|
|
77
|
+
@override
|
|
78
|
+
def _get_text_fragments(self) -> StyleAndTextTuples:
|
|
74
79
|
# Override rendering: show > based on focus, not checked state.
|
|
75
|
-
result:
|
|
80
|
+
result: StyleAndTextTuples = []
|
|
76
81
|
for i, (_value, text) in enumerate(self.values):
|
|
77
82
|
focused = i == self._selected_index
|
|
78
83
|
style = _FOCUSED_STYLE if focused else ""
|
|
@@ -97,7 +102,7 @@ async def choose[T](options: list[tuple[T, str]], *, default: T | None = None) -
|
|
|
97
102
|
|
|
98
103
|
@kb.add("c-c")
|
|
99
104
|
@kb.add("escape")
|
|
100
|
-
def _cancel(event) -> None:
|
|
105
|
+
def _cancel(event: KeyPressEvent) -> None:
|
|
101
106
|
event.app.exit(result=None)
|
|
102
107
|
|
|
103
108
|
app: Application[T | None] = Application(
|
|
@@ -116,7 +121,8 @@ class _PromptCompleter(Completer):
|
|
|
116
121
|
def __init__(self, *, cwd: str | None = None) -> None:
|
|
117
122
|
self._cwd = cwd
|
|
118
123
|
|
|
119
|
-
def get_completions(self, document, complete_event):
|
|
124
|
+
def get_completions(self, document: Document, complete_event: CompleteEvent) -> Iterable[Completion]:
|
|
125
|
+
del complete_event
|
|
120
126
|
text_before_cursor = document.text_before_cursor
|
|
121
127
|
text = text_before_cursor.lstrip()
|
|
122
128
|
if self._cwd:
|
|
@@ -186,16 +192,25 @@ def _build_chat_key_bindings() -> KeyBindings:
|
|
|
186
192
|
"""Build key bindings for the main chat prompt."""
|
|
187
193
|
kb = KeyBindings()
|
|
188
194
|
|
|
189
|
-
|
|
195
|
+
def _clear(event: KeyPressEvent) -> None:
|
|
196
|
+
event.app.renderer.clear()
|
|
197
|
+
|
|
198
|
+
kb.add("c-l")(_clear)
|
|
190
199
|
|
|
191
200
|
# In multiline mode the default Enter inserts a newline; override it to submit.
|
|
192
|
-
|
|
201
|
+
def _submit(event: KeyPressEvent) -> None:
|
|
202
|
+
event.current_buffer.validate_and_handle()
|
|
203
|
+
|
|
204
|
+
kb.add("enter", eager=True)(_submit)
|
|
193
205
|
|
|
194
206
|
# Esc+Enter (Meta+Enter) inserts a newline for multiline input.
|
|
195
|
-
|
|
207
|
+
def _insert_newline(event: KeyPressEvent) -> None:
|
|
208
|
+
event.current_buffer.insert_text("\n")
|
|
209
|
+
|
|
210
|
+
kb.add("escape", "enter")(_insert_newline)
|
|
196
211
|
|
|
197
212
|
@kb.add(Keys.BracketedPaste, eager=True)
|
|
198
|
-
def _handle_bracketed_paste(event) -> None:
|
|
213
|
+
def _handle_bracketed_paste(event: KeyPressEvent) -> None:
|
|
199
214
|
pasted = event.data.replace("\r\n", "\n").replace("\r", "\n")
|
|
200
215
|
event.current_buffer.insert_text(_rewrite_pasted_file_paths(pasted) or pasted)
|
|
201
216
|
|
|
@@ -225,7 +240,7 @@ class TerminalChat:
|
|
|
225
240
|
self.store = store
|
|
226
241
|
self.session_id = session_id
|
|
227
242
|
self.view = view or TerminalView()
|
|
228
|
-
self.prompt_session = PromptSession(
|
|
243
|
+
self.prompt_session: PromptSession[str] = PromptSession(
|
|
229
244
|
history=FileHistory(history_file_path()),
|
|
230
245
|
completer=_PromptCompleter(cwd=self.agent.cwd),
|
|
231
246
|
key_bindings=_build_chat_key_bindings(),
|
|
@@ -284,9 +299,9 @@ class TerminalChat:
|
|
|
284
299
|
def _build_user_message(self, text: str) -> dict[str, Any]:
|
|
285
300
|
"""Build one user message with the raw prompt first, then resolved attachments.
|
|
286
301
|
|
|
287
|
-
Text files are appended as extra text blocks
|
|
288
|
-
|
|
289
|
-
that resolve to real files are attached.
|
|
302
|
+
Text files are appended as extra text blocks. Images and PDFs become
|
|
303
|
+
native blocks only when the current model supports that input type.
|
|
304
|
+
Only explicit `@path` tokens that resolve to real files are attached.
|
|
290
305
|
"""
|
|
291
306
|
|
|
292
307
|
blocks = [text_block(text)]
|
|
@@ -309,10 +324,27 @@ class TerminalChat:
|
|
|
309
324
|
continue
|
|
310
325
|
seen.add(path_text)
|
|
311
326
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
327
|
+
# Detect image or document; bundle (kind, mime, supported) together.
|
|
328
|
+
media: tuple[str, str, bool] | None = None
|
|
329
|
+
if m := detect_image_mime_type(path):
|
|
330
|
+
media = ("image", m, self.agent.supports_image_input)
|
|
331
|
+
elif m := detect_document_mime_type(path):
|
|
332
|
+
media = ("document", m, self.agent.supports_pdf_input)
|
|
333
|
+
|
|
334
|
+
if media:
|
|
335
|
+
kind, mime_type, supported = media
|
|
336
|
+
if supported:
|
|
337
|
+
data = b64encode(path.read_bytes()).decode("utf-8")
|
|
338
|
+
fn = image_block if kind == "image" else document_block
|
|
339
|
+
blocks.append(fn(data, mime_type=mime_type, name=path.name))
|
|
340
|
+
else:
|
|
341
|
+
label = "image input" if kind == "image" else "PDF input"
|
|
342
|
+
blocks.append(
|
|
343
|
+
text_block(
|
|
344
|
+
f'<file name="{html.escape(path_text, quote=True)}" media_type="{mime_type}" kind="{kind}">Current model does not support {label}.</file>',
|
|
345
|
+
meta={"attachment": True, "path": path_text},
|
|
346
|
+
)
|
|
347
|
+
)
|
|
316
348
|
continue
|
|
317
349
|
|
|
318
350
|
# Reuse the existing read tool so attached text files follow the same
|
|
@@ -459,20 +491,16 @@ class TerminalChat:
|
|
|
459
491
|
|
|
460
492
|
# Collect real user text messages (skip synthetic compact summaries
|
|
461
493
|
# and tool-result-only user messages).
|
|
462
|
-
user_turns:
|
|
494
|
+
user_turns: dict[int, str] = {} # message_index -> text
|
|
463
495
|
for i, msg in enumerate(messages):
|
|
464
496
|
if msg.get("role") != "user":
|
|
465
497
|
continue
|
|
466
498
|
if (msg.get("meta") or {}).get("synthetic"):
|
|
467
499
|
continue
|
|
468
|
-
|
|
469
|
-
text = ""
|
|
470
|
-
for b in blocks:
|
|
500
|
+
for b in msg.get("content") or []:
|
|
471
501
|
if isinstance(b, dict) and b.get("type") == "text" and b.get("text"):
|
|
472
|
-
|
|
502
|
+
user_turns[i] = str(b["text"]).strip()
|
|
473
503
|
break
|
|
474
|
-
if text:
|
|
475
|
-
user_turns.append((i, text))
|
|
476
504
|
|
|
477
505
|
if not user_turns:
|
|
478
506
|
self.view.console.print("[dim]no user messages to rewind to[/dim]")
|
|
@@ -480,7 +508,7 @@ class TerminalChat:
|
|
|
480
508
|
|
|
481
509
|
# Build selector options — most recent first.
|
|
482
510
|
options: list[tuple[int, str]] = []
|
|
483
|
-
for msg_index, text in reversed(user_turns):
|
|
511
|
+
for msg_index, text in reversed(list(user_turns.items())):
|
|
484
512
|
preview = text.replace("\n", " ")[:60]
|
|
485
513
|
if len(text) > 60:
|
|
486
514
|
preview += "..."
|
|
@@ -490,12 +518,7 @@ class TerminalChat:
|
|
|
490
518
|
if selected is None:
|
|
491
519
|
return None
|
|
492
520
|
|
|
493
|
-
|
|
494
|
-
original_text = ""
|
|
495
|
-
for msg_index, text in user_turns:
|
|
496
|
-
if msg_index == selected:
|
|
497
|
-
original_text = text
|
|
498
|
-
break
|
|
521
|
+
original_text = user_turns.get(selected, "")
|
|
499
522
|
|
|
500
523
|
# Persist the rewind event and truncate in-memory messages.
|
|
501
524
|
await self.store.append_rewind(self.session_id, selected)
|
mycode/cli/main.py
CHANGED
|
@@ -11,6 +11,7 @@ import typer
|
|
|
11
11
|
|
|
12
12
|
from mycode.core.agent import Agent
|
|
13
13
|
from mycode.core.config import get_settings, resolve_provider
|
|
14
|
+
from mycode.core.messages import ConversationMessage
|
|
14
15
|
from mycode.core.session import SessionStore
|
|
15
16
|
|
|
16
17
|
from .chat import TerminalChat
|
|
@@ -22,9 +23,6 @@ session_app = typer.Typer(help="Session management")
|
|
|
22
23
|
app.add_typer(session_app, name="session")
|
|
23
24
|
|
|
24
25
|
|
|
25
|
-
# -- Shared helpers ----------------------------------------------------------
|
|
26
|
-
|
|
27
|
-
|
|
28
26
|
async def run_noninteractive(
|
|
29
27
|
agent: Agent,
|
|
30
28
|
*,
|
|
@@ -32,11 +30,11 @@ async def run_noninteractive(
|
|
|
32
30
|
session_id: str,
|
|
33
31
|
message: str,
|
|
34
32
|
) -> int:
|
|
35
|
-
"""Run one
|
|
33
|
+
"""Run one message non-interactively and print only the final assistant reply."""
|
|
36
34
|
|
|
37
|
-
latest_assistant:
|
|
35
|
+
latest_assistant: ConversationMessage | None = None
|
|
38
36
|
|
|
39
|
-
async def persist(payload:
|
|
37
|
+
async def persist(payload: ConversationMessage) -> None:
|
|
40
38
|
nonlocal latest_assistant
|
|
41
39
|
if payload.get("role") == "assistant":
|
|
42
40
|
latest_assistant = payload
|
|
@@ -67,7 +65,7 @@ async def run_noninteractive(
|
|
|
67
65
|
|
|
68
66
|
|
|
69
67
|
def _validate_session_options(session: str | None, continue_last: bool) -> None:
|
|
70
|
-
"""Reject conflicting session options."""
|
|
68
|
+
"""Reject conflicting --session and --continue options."""
|
|
71
69
|
|
|
72
70
|
if session and continue_last:
|
|
73
71
|
raise typer.BadParameter("--session and --continue are mutually exclusive")
|
mycode/cli/render.py
CHANGED
|
@@ -412,6 +412,8 @@ class ReplyRenderer:
|
|
|
412
412
|
case "error":
|
|
413
413
|
exit_code = 1
|
|
414
414
|
self.error(event.data.get("message", ""))
|
|
415
|
+
case _:
|
|
416
|
+
pass
|
|
415
417
|
|
|
416
418
|
self.finish()
|
|
417
419
|
return exit_code
|
|
@@ -711,19 +713,21 @@ class ReplyRenderer:
|
|
|
711
713
|
self._thinking_collapsed = False
|
|
712
714
|
self._thinking_start_time = None
|
|
713
715
|
|
|
714
|
-
def _build_live_renderable(self):
|
|
716
|
+
def _build_live_renderable(self) -> Spinner | _LeftMarkdown:
|
|
715
717
|
"""Build the Rich renderable used while a reply is streaming."""
|
|
716
718
|
|
|
717
719
|
# No content yet: plain spinner
|
|
718
720
|
if not self._reasoning and not self._text:
|
|
719
721
|
return Spinner("dots", style="dim")
|
|
720
722
|
|
|
721
|
-
# Thinking in progress: show rolling preview of reasoning content
|
|
723
|
+
# Thinking in progress: show rolling preview of reasoning content.
|
|
724
|
+
# Join only the tail to avoid O(full_length) work on every frame.
|
|
722
725
|
if self._reasoning and not self._text:
|
|
723
|
-
|
|
726
|
+
tail = "".join(self._reasoning[-30:])
|
|
727
|
+
content = " ".join(tail.split())
|
|
724
728
|
if content:
|
|
725
729
|
preview = content[-80:].strip()
|
|
726
|
-
if len(content) > 80:
|
|
730
|
+
if len(self._reasoning) > 30 or len(content) > 80:
|
|
727
731
|
preview = "…" + preview
|
|
728
732
|
return Spinner("dots", text=Text(f" {preview}", style=THINKING), style="dim")
|
|
729
733
|
return Spinner("dots", text=Text(" thinking…", style=THINKING), style="dim")
|
mycode/cli/runtime.py
CHANGED
|
@@ -64,6 +64,7 @@ def build_agent(
|
|
|
64
64
|
settings=settings,
|
|
65
65
|
reasoning_effort=resolved_provider.reasoning_effort,
|
|
66
66
|
supports_image_input=resolved_provider.supports_image_input,
|
|
67
|
+
supports_pdf_input=resolved_provider.supports_pdf_input,
|
|
67
68
|
max_tokens=resolved_provider.max_tokens,
|
|
68
69
|
context_window=resolved_provider.context_window,
|
|
69
70
|
compact_threshold=settings.compact_threshold,
|
|
@@ -87,6 +88,7 @@ def clone_agent(agent: Agent, *, store: SessionStore, session_id: str, messages:
|
|
|
87
88
|
max_tokens=agent.max_tokens,
|
|
88
89
|
reasoning_effort=agent.reasoning_effort,
|
|
89
90
|
supports_image_input=agent.supports_image_input,
|
|
91
|
+
supports_pdf_input=agent.supports_pdf_input,
|
|
90
92
|
settings=agent.settings,
|
|
91
93
|
)
|
|
92
94
|
|
|
@@ -255,6 +257,7 @@ async def update_agent_runtime(
|
|
|
255
257
|
or agent.max_tokens != resolved.max_tokens
|
|
256
258
|
or agent.context_window != resolved.context_window
|
|
257
259
|
or agent.supports_image_input != resolved.supports_image_input
|
|
260
|
+
or agent.supports_pdf_input != resolved.supports_pdf_input
|
|
258
261
|
)
|
|
259
262
|
|
|
260
263
|
agent.provider = resolved.provider
|
|
@@ -265,6 +268,7 @@ async def update_agent_runtime(
|
|
|
265
268
|
agent.max_tokens = resolved.max_tokens
|
|
266
269
|
agent.context_window = resolved.context_window
|
|
267
270
|
agent.supports_image_input = bool(resolved.supports_image_input)
|
|
271
|
+
agent.supports_pdf_input = bool(resolved.supports_pdf_input)
|
|
268
272
|
agent.settings = settings
|
|
269
273
|
if hasattr(agent, "tools") and hasattr(agent.tools, "supports_image_input"):
|
|
270
274
|
agent.tools.supports_image_input = bool(agent.supports_image_input)
|
mycode/core/agent.py
CHANGED
|
@@ -7,7 +7,7 @@ import logging
|
|
|
7
7
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
8
8
|
from dataclasses import dataclass, field
|
|
9
9
|
from pathlib import Path
|
|
10
|
-
from typing import Any
|
|
10
|
+
from typing import Any, cast
|
|
11
11
|
|
|
12
12
|
from mycode.core.config import Settings, get_settings
|
|
13
13
|
from mycode.core.messages import (
|
|
@@ -62,6 +62,7 @@ class Agent:
|
|
|
62
62
|
compact_threshold: float | None = None,
|
|
63
63
|
reasoning_effort: str | None = None,
|
|
64
64
|
supports_image_input: bool | None = None,
|
|
65
|
+
supports_pdf_input: bool | None = None,
|
|
65
66
|
settings: Settings | None = None,
|
|
66
67
|
system: str | None = None,
|
|
67
68
|
tool_executor: ToolExecutor | None = None,
|
|
@@ -79,6 +80,7 @@ class Agent:
|
|
|
79
80
|
self.compact_threshold = compact_threshold if compact_threshold is not None else DEFAULT_COMPACT_THRESHOLD
|
|
80
81
|
self.reasoning_effort = reasoning_effort
|
|
81
82
|
self.supports_image_input: bool = bool(supports_image_input)
|
|
83
|
+
self.supports_pdf_input: bool = bool(supports_pdf_input)
|
|
82
84
|
self.settings = settings or get_settings(self.cwd)
|
|
83
85
|
self.system = system or build_system_prompt(self.cwd, self.settings)
|
|
84
86
|
self._cancel_event = asyncio.Event()
|
|
@@ -99,9 +101,7 @@ class Agent:
|
|
|
99
101
|
|
|
100
102
|
@staticmethod
|
|
101
103
|
def _tool_done_event(tool_id: str, result: ToolExecutionResult) -> Event:
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
data = {
|
|
104
|
+
data: dict[str, Any] = {
|
|
105
105
|
"tool_use_id": tool_id,
|
|
106
106
|
"model_text": result.model_text,
|
|
107
107
|
"display_text": result.display_text,
|
|
@@ -109,10 +109,7 @@ class Agent:
|
|
|
109
109
|
}
|
|
110
110
|
if result.content:
|
|
111
111
|
data["content"] = result.content
|
|
112
|
-
return Event(
|
|
113
|
-
"tool_done",
|
|
114
|
-
data,
|
|
115
|
-
)
|
|
112
|
+
return Event("tool_done", data)
|
|
116
113
|
|
|
117
114
|
async def _run_streaming_tool(self, *, tool_id: str, name: str, args: dict[str, Any]) -> AsyncIterator[Event]:
|
|
118
115
|
"""Run one streaming tool and forward live output until it finishes."""
|
|
@@ -236,12 +233,16 @@ class Agent:
|
|
|
236
233
|
"""Iterate one provider turn with best-effort cancellation support."""
|
|
237
234
|
|
|
238
235
|
provider_stream: AsyncIterator[ProviderStreamEvent] = adapter.stream_turn(request)
|
|
236
|
+
|
|
237
|
+
async def next_provider_event() -> ProviderStreamEvent:
|
|
238
|
+
return await anext(provider_stream)
|
|
239
|
+
|
|
239
240
|
try:
|
|
240
241
|
while True:
|
|
241
242
|
if self._cancel_event.is_set():
|
|
242
243
|
raise asyncio.CancelledError
|
|
243
244
|
|
|
244
|
-
self._provider_event_task = asyncio.create_task(
|
|
245
|
+
self._provider_event_task = asyncio.create_task(next_provider_event())
|
|
245
246
|
try:
|
|
246
247
|
yield await self._provider_event_task
|
|
247
248
|
except StopAsyncIteration:
|
|
@@ -249,8 +250,8 @@ class Agent:
|
|
|
249
250
|
finally:
|
|
250
251
|
self._provider_event_task = None
|
|
251
252
|
finally:
|
|
252
|
-
close = getattr(provider_stream, "aclose", None)
|
|
253
|
-
if
|
|
253
|
+
close = cast(Callable[[], Awaitable[None]] | None, getattr(provider_stream, "aclose", None))
|
|
254
|
+
if close is not None:
|
|
254
255
|
try:
|
|
255
256
|
await close()
|
|
256
257
|
except Exception:
|
|
@@ -271,17 +272,19 @@ class Agent:
|
|
|
271
272
|
|
|
272
273
|
self._cancel_event.clear()
|
|
273
274
|
supports_image_input = self.supports_image_input
|
|
275
|
+
supports_pdf_input = self.supports_pdf_input
|
|
274
276
|
self.tools.supports_image_input = supports_image_input
|
|
275
277
|
|
|
276
278
|
if isinstance(user_input, str):
|
|
277
279
|
user_message = user_text_message(user_input)
|
|
278
280
|
else:
|
|
279
|
-
user_message = {
|
|
281
|
+
user_message: ConversationMessage = {
|
|
280
282
|
"role": str(user_input.get("role") or "user"),
|
|
281
283
|
"content": [dict(b) for b in user_input.get("content") or [] if isinstance(b, dict)],
|
|
282
284
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
+
raw_meta = user_input.get("meta")
|
|
286
|
+
if isinstance(raw_meta, dict):
|
|
287
|
+
user_message["meta"] = {str(k): v for k, v in raw_meta.items()}
|
|
285
288
|
|
|
286
289
|
if user_message.get("role") != "user":
|
|
287
290
|
yield Event("error", {"message": "user input must be a user message"})
|
|
@@ -292,6 +295,11 @@ class Agent:
|
|
|
292
295
|
):
|
|
293
296
|
yield Event("error", {"message": "current model does not support image input"})
|
|
294
297
|
return
|
|
298
|
+
if not supports_pdf_input and any(
|
|
299
|
+
isinstance(block, dict) and block.get("type") == "document" for block in user_message.get("content") or []
|
|
300
|
+
):
|
|
301
|
+
yield Event("error", {"message": "current model does not support PDF input"})
|
|
302
|
+
return
|
|
295
303
|
|
|
296
304
|
self.messages.append(user_message)
|
|
297
305
|
if on_persist:
|
|
@@ -319,6 +327,7 @@ class Agent:
|
|
|
319
327
|
api_base=self.api_base,
|
|
320
328
|
reasoning_effort=self.reasoning_effort,
|
|
321
329
|
supports_image_input=supports_image_input,
|
|
330
|
+
supports_pdf_input=supports_pdf_input,
|
|
322
331
|
)
|
|
323
332
|
|
|
324
333
|
try:
|
|
@@ -467,6 +476,7 @@ class Agent:
|
|
|
467
476
|
api_key=self.api_key,
|
|
468
477
|
api_base=self.api_base,
|
|
469
478
|
supports_image_input=self.supports_image_input,
|
|
479
|
+
supports_pdf_input=self.supports_pdf_input,
|
|
470
480
|
)
|
|
471
481
|
|
|
472
482
|
summary_message: ConversationMessage | None = None
|
mycode/core/config.py
CHANGED
|
@@ -40,6 +40,7 @@ class ModelConfig:
|
|
|
40
40
|
max_output_tokens: int | None = None
|
|
41
41
|
supports_reasoning: bool | None = None
|
|
42
42
|
supports_image_input: bool | None = None
|
|
43
|
+
supports_pdf_input: bool | None = None
|
|
43
44
|
|
|
44
45
|
|
|
45
46
|
@dataclass(frozen=True)
|
|
@@ -79,6 +80,7 @@ class ResolvedProvider:
|
|
|
79
80
|
context_window: int | None = 128_000
|
|
80
81
|
supports_reasoning: bool | None = None
|
|
81
82
|
supports_image_input: bool | None = None
|
|
83
|
+
supports_pdf_input: bool | None = None
|
|
82
84
|
provider_name: str | None = None
|
|
83
85
|
|
|
84
86
|
@property
|
|
@@ -139,6 +141,7 @@ def _normalize_models(value: Any) -> dict[str, ModelConfig]:
|
|
|
139
141
|
max_output_tokens=as_int(raw_config.get("max_output_tokens")),
|
|
140
142
|
supports_reasoning=as_bool(raw_config.get("supports_reasoning")),
|
|
141
143
|
supports_image_input=as_bool(raw_config.get("supports_image_input")),
|
|
144
|
+
supports_pdf_input=as_bool(raw_config.get("supports_pdf_input")),
|
|
142
145
|
)
|
|
143
146
|
return models
|
|
144
147
|
|
|
@@ -293,20 +296,21 @@ def get_settings(cwd: str | None = None) -> Settings:
|
|
|
293
296
|
|
|
294
297
|
raw_providers[name] = merged
|
|
295
298
|
|
|
296
|
-
default = data.get("default")
|
|
297
|
-
if
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
299
|
+
default = data.get("default")
|
|
300
|
+
if isinstance(default, dict):
|
|
301
|
+
if "provider" in default:
|
|
302
|
+
v = default.get("provider")
|
|
303
|
+
default_provider = v if isinstance(v, str) else None
|
|
304
|
+
if "model" in default:
|
|
305
|
+
v = default.get("model")
|
|
306
|
+
default_model = v if isinstance(v, str) else None
|
|
307
|
+
if "reasoning_effort" in default:
|
|
308
|
+
v = default.get("reasoning_effort")
|
|
309
|
+
default_reasoning_effort = v if isinstance(v, str) else None
|
|
310
|
+
if "compact_threshold" in default:
|
|
311
|
+
parsed_threshold = _parse_compact_threshold(default.get("compact_threshold"))
|
|
312
|
+
if parsed_threshold is not None:
|
|
313
|
+
compact_threshold = parsed_threshold
|
|
310
314
|
|
|
311
315
|
return Settings(
|
|
312
316
|
providers=_build_providers(raw_providers),
|
|
@@ -341,24 +345,27 @@ def resolve_provider(
|
|
|
341
345
|
api_base=api_base,
|
|
342
346
|
)
|
|
343
347
|
|
|
344
|
-
|
|
348
|
+
refs = _available_provider_references(settings)
|
|
349
|
+
if refs:
|
|
345
350
|
return _resolve_provider_runtime(
|
|
346
351
|
settings,
|
|
347
|
-
selected_name=
|
|
352
|
+
selected_name=refs[0][0],
|
|
348
353
|
model=model,
|
|
349
354
|
api_key=api_key,
|
|
350
355
|
api_base=api_base,
|
|
351
356
|
)
|
|
352
357
|
|
|
353
|
-
env_names
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
+
env_names = list(
|
|
359
|
+
dict.fromkeys(
|
|
360
|
+
env_name
|
|
361
|
+
for provider_id in list_env_discoverable_providers()
|
|
362
|
+
for env_name in provider_env_api_key_names(provider_id)
|
|
363
|
+
)
|
|
364
|
+
)
|
|
358
365
|
checked = ", ".join(env_names) or "<api key env>"
|
|
359
366
|
raise ValueError(
|
|
360
367
|
"no available providers found; set one of the supported API key env vars "
|
|
361
|
-
f"({checked}) or configure a provider in ~/.mycode/config.json or <workspace>/.mycode/config.json"
|
|
368
|
+
+ f"({checked}) or configure a provider in ~/.mycode/config.json or <workspace>/.mycode/config.json"
|
|
362
369
|
)
|
|
363
370
|
|
|
364
371
|
|
|
@@ -461,6 +468,7 @@ def _resolve_provider_runtime(
|
|
|
461
468
|
max_output_tokens=model_config.max_output_tokens,
|
|
462
469
|
supports_reasoning=model_config.supports_reasoning,
|
|
463
470
|
supports_image_input=model_config.supports_image_input,
|
|
471
|
+
supports_pdf_input=model_config.supports_pdf_input,
|
|
464
472
|
)
|
|
465
473
|
else:
|
|
466
474
|
# Per-field: use the config override when set, keep catalog value otherwise.
|
|
@@ -478,11 +486,16 @@ def _resolve_provider_runtime(
|
|
|
478
486
|
supports_image_input=model_config.supports_image_input
|
|
479
487
|
if model_config.supports_image_input is not None
|
|
480
488
|
else model_metadata.supports_image_input,
|
|
489
|
+
supports_pdf_input=model_config.supports_pdf_input
|
|
490
|
+
if model_config.supports_pdf_input is not None
|
|
491
|
+
else model_metadata.supports_pdf_input,
|
|
481
492
|
)
|
|
482
493
|
|
|
483
|
-
configured_effort =
|
|
484
|
-
|
|
485
|
-
|
|
494
|
+
configured_effort = (
|
|
495
|
+
provider_config.reasoning_effort
|
|
496
|
+
if provider_config and provider_config.reasoning_effort is not None
|
|
497
|
+
else settings.default_reasoning_effort
|
|
498
|
+
)
|
|
486
499
|
|
|
487
500
|
if configured_effort is not None and configured_effort not in _VALID_REASONING_EFFORTS:
|
|
488
501
|
supported = ", ".join(_VALID_REASONING_EFFORTS)
|
|
@@ -490,16 +503,18 @@ def _resolve_provider_runtime(
|
|
|
490
503
|
|
|
491
504
|
supports_reasoning = model_metadata.supports_reasoning if model_metadata else None
|
|
492
505
|
supports_image_input = model_metadata.supports_image_input if model_metadata else None
|
|
506
|
+
supports_pdf_input = model_metadata.supports_pdf_input if model_metadata else None
|
|
493
507
|
adapter = get_provider_adapter(provider_type)
|
|
494
|
-
|
|
495
|
-
configured_effort
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
508
|
+
reasoning_effort = (
|
|
509
|
+
configured_effort
|
|
510
|
+
if (
|
|
511
|
+
configured_effort is not None
|
|
512
|
+
and model_metadata is not None
|
|
513
|
+
and supports_reasoning is True
|
|
514
|
+
and adapter.supports_reasoning_effort
|
|
515
|
+
)
|
|
516
|
+
else None
|
|
517
|
+
)
|
|
503
518
|
|
|
504
519
|
resolved_api_key = api_key
|
|
505
520
|
if not resolved_api_key and provider_config:
|
|
@@ -521,10 +536,11 @@ def _resolve_provider_runtime(
|
|
|
521
536
|
api_key=resolved_api_key,
|
|
522
537
|
api_base=resolved_api_base,
|
|
523
538
|
reasoning_effort=reasoning_effort,
|
|
524
|
-
max_tokens=model_metadata.max_output_tokens if model_metadata
|
|
525
|
-
context_window=model_metadata.context_window if model_metadata
|
|
539
|
+
max_tokens=(model_metadata.max_output_tokens if model_metadata else None) or 16_384,
|
|
540
|
+
context_window=(model_metadata.context_window if model_metadata else None) or 128_000,
|
|
526
541
|
supports_reasoning=supports_reasoning,
|
|
527
542
|
supports_image_input=supports_image_input,
|
|
543
|
+
supports_pdf_input=supports_pdf_input,
|
|
528
544
|
)
|
|
529
545
|
|
|
530
546
|
|
mycode/core/messages.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The runtime persists a single message shape everywhere:
|
|
4
4
|
|
|
5
|
-
- user message: text blocks, image blocks, and tool_result blocks
|
|
5
|
+
- user message: text blocks, image blocks, document blocks, and tool_result blocks
|
|
6
6
|
- assistant message: thinking blocks, text blocks, and tool_use blocks
|
|
7
7
|
|
|
8
8
|
Provider adapters translate between this internal shape and provider-specific wire
|
|
@@ -21,6 +21,8 @@ from __future__ import annotations
|
|
|
21
21
|
|
|
22
22
|
from typing import Any
|
|
23
23
|
|
|
24
|
+
from mycode.core.utils import omit_none
|
|
25
|
+
|
|
24
26
|
ContentBlock = dict[str, Any]
|
|
25
27
|
ConversationMessage = dict[str, Any]
|
|
26
28
|
|
|
@@ -54,6 +56,21 @@ def image_block(
|
|
|
54
56
|
return block
|
|
55
57
|
|
|
56
58
|
|
|
59
|
+
def document_block(
|
|
60
|
+
data: str,
|
|
61
|
+
*,
|
|
62
|
+
mime_type: str,
|
|
63
|
+
name: str | None = None,
|
|
64
|
+
meta: dict[str, Any] | None = None,
|
|
65
|
+
) -> ContentBlock:
|
|
66
|
+
block: ContentBlock = {"type": "document", "data": data, "mime_type": mime_type}
|
|
67
|
+
if name:
|
|
68
|
+
block["name"] = name
|
|
69
|
+
if meta:
|
|
70
|
+
block["meta"] = dict(meta)
|
|
71
|
+
return block
|
|
72
|
+
|
|
73
|
+
|
|
57
74
|
def tool_use_block(
|
|
58
75
|
*,
|
|
59
76
|
tool_id: str,
|
|
@@ -141,7 +158,7 @@ def assistant_message(
|
|
|
141
158
|
if usage is not None:
|
|
142
159
|
meta["usage"] = usage
|
|
143
160
|
if native_meta:
|
|
144
|
-
native =
|
|
161
|
+
native = omit_none(native_meta)
|
|
145
162
|
if native:
|
|
146
163
|
meta["native"] = native
|
|
147
164
|
return build_message("assistant", blocks, meta=meta or None)
|
|
@@ -159,8 +176,7 @@ def flatten_message_text(message: ConversationMessage, *, include_thinking: bool
|
|
|
159
176
|
# Attached file snapshots should not become session titles or history labels.
|
|
160
177
|
if meta.get("attachment"):
|
|
161
178
|
continue
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
elif include_thinking and block.get("type") == "thinking":
|
|
179
|
+
btype = block.get("type")
|
|
180
|
+
if btype == "text" or (include_thinking and btype == "thinking"):
|
|
165
181
|
parts.append(str(block.get("text") or ""))
|
|
166
182
|
return " ".join(part.strip() for part in parts if part and part.strip()).strip()
|