seedcode-cli 6.1.5__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.
Files changed (114) hide show
  1. seedcode/__init__.py +14 -0
  2. seedcode/__main__.py +12 -0
  3. seedcode/app.py +508 -0
  4. seedcode/apps/__init__.py +32 -0
  5. seedcode/apps/discovery.py +241 -0
  6. seedcode/apps/installer.py +164 -0
  7. seedcode/apps/launcher.py +156 -0
  8. seedcode/apps/verifier.py +119 -0
  9. seedcode/assets/logo.txt +15 -0
  10. seedcode/cli.py +95 -0
  11. seedcode/commands/__init__.py +81 -0
  12. seedcode/commands/about.py +34 -0
  13. seedcode/commands/agent.py +94 -0
  14. seedcode/commands/assist.py +201 -0
  15. seedcode/commands/clear.py +20 -0
  16. seedcode/commands/desktop.py +104 -0
  17. seedcode/commands/doctor.py +152 -0
  18. seedcode/commands/help.py +61 -0
  19. seedcode/commands/history.py +365 -0
  20. seedcode/commands/palette.py +100 -0
  21. seedcode/commands/provider.py +451 -0
  22. seedcode/commands/theme.py +76 -0
  23. seedcode/computer/__init__.py +98 -0
  24. seedcode/computer/browser.py +276 -0
  25. seedcode/computer/browser_cdp.py +567 -0
  26. seedcode/computer/browser_engine.py +546 -0
  27. seedcode/computer/browser_extract.py +301 -0
  28. seedcode/computer/browser_popups.py +329 -0
  29. seedcode/computer/browser_selenium.py +209 -0
  30. seedcode/computer/browser_skills.py +245 -0
  31. seedcode/computer/catalog.py +200 -0
  32. seedcode/computer/controller.py +324 -0
  33. seedcode/computer/dispatcher.py +272 -0
  34. seedcode/computer/dpi.py +185 -0
  35. seedcode/computer/engine.py +105 -0
  36. seedcode/computer/keyboard.py +101 -0
  37. seedcode/computer/logbook.py +104 -0
  38. seedcode/computer/mouse.py +48 -0
  39. seedcode/computer/ocr.py +213 -0
  40. seedcode/computer/operator_skills.py +577 -0
  41. seedcode/computer/permissions.py +203 -0
  42. seedcode/computer/recovery.py +115 -0
  43. seedcode/computer/registry.py +107 -0
  44. seedcode/computer/resolver.py +434 -0
  45. seedcode/computer/screen.py +130 -0
  46. seedcode/computer/screen_state.py +412 -0
  47. seedcode/computer/selfguard.py +197 -0
  48. seedcode/computer/semantic.py +100 -0
  49. seedcode/computer/skills.py +139 -0
  50. seedcode/computer/state.py +199 -0
  51. seedcode/computer/verifier.py +177 -0
  52. seedcode/computer/vision.py +327 -0
  53. seedcode/computer/windows.py +217 -0
  54. seedcode/config/__init__.py +8 -0
  55. seedcode/config/defaults.py +22 -0
  56. seedcode/config/manager.py +62 -0
  57. seedcode/core/__init__.py +31 -0
  58. seedcode/core/agent.py +534 -0
  59. seedcode/core/chat.py +128 -0
  60. seedcode/core/client.py +9 -0
  61. seedcode/core/errors.py +199 -0
  62. seedcode/core/identity.py +66 -0
  63. seedcode/core/identity_store.py +119 -0
  64. seedcode/core/lifecycle.py +240 -0
  65. seedcode/core/limits.py +35 -0
  66. seedcode/core/models.py +347 -0
  67. seedcode/core/project.py +96 -0
  68. seedcode/core/providers/__init__.py +58 -0
  69. seedcode/core/providers/aerolink.py +324 -0
  70. seedcode/core/providers/base.py +230 -0
  71. seedcode/core/providers/freemodel.py +931 -0
  72. seedcode/core/providers/ollama.py +262 -0
  73. seedcode/core/providers/openrouter.py +393 -0
  74. seedcode/core/streaming.py +21 -0
  75. seedcode/memory/__init__.py +8 -0
  76. seedcode/memory/manager.py +47 -0
  77. seedcode/memory/storage.py +38 -0
  78. seedcode/memory/store.py +257 -0
  79. seedcode/tools/__init__.py +35 -0
  80. seedcode/tools/base.py +179 -0
  81. seedcode/tools/desktop.py +371 -0
  82. seedcode/tools/filesystem.py +309 -0
  83. seedcode/tools/git.py +72 -0
  84. seedcode/tools/patch.py +170 -0
  85. seedcode/tools/permissions.py +288 -0
  86. seedcode/tools/search.py +137 -0
  87. seedcode/tools/terminal.py +200 -0
  88. seedcode/tools/textio.py +59 -0
  89. seedcode/ui/__init__.py +164 -0
  90. seedcode/ui/badges.py +64 -0
  91. seedcode/ui/banner.py +78 -0
  92. seedcode/ui/dashboard.py +197 -0
  93. seedcode/ui/dialog.py +62 -0
  94. seedcode/ui/fuzzy.py +128 -0
  95. seedcode/ui/layout.py +54 -0
  96. seedcode/ui/menu.py +61 -0
  97. seedcode/ui/palette.py +40 -0
  98. seedcode/ui/progress.py +41 -0
  99. seedcode/ui/prompts.py +16 -0
  100. seedcode/ui/renderer.py +36 -0
  101. seedcode/ui/searchbox.py +70 -0
  102. seedcode/ui/selector.py +514 -0
  103. seedcode/ui/statusbar.py +38 -0
  104. seedcode/ui/textbox.py +61 -0
  105. seedcode/ui/theme.py +204 -0
  106. seedcode/ui/tree.py +91 -0
  107. seedcode/utils/__init__.py +22 -0
  108. seedcode/utils/helpers.py +97 -0
  109. seedcode/utils/logger.py +65 -0
  110. seedcode_cli-6.1.5.dist-info/METADATA +368 -0
  111. seedcode_cli-6.1.5.dist-info/RECORD +114 -0
  112. seedcode_cli-6.1.5.dist-info/WHEEL +4 -0
  113. seedcode_cli-6.1.5.dist-info/entry_points.txt +2 -0
  114. seedcode_cli-6.1.5.dist-info/licenses/LICENSE +21 -0
seedcode/core/agent.py ADDED
@@ -0,0 +1,534 @@
1
+ """Agent loop: lets the model act on the project through the tool engine.
2
+
3
+ Two tool-calling paths share one loop:
4
+
5
+ * **Native** (preferred): providers that support function/tool calling get
6
+ the registry's JSON schemas via ``stream_chat_with_tools`` and stream back
7
+ :class:`ToolCallEvent`s. History records the calls structurally
8
+ (``Message.tool_calls`` / role=="tool" results) so each provider can
9
+ serialize them to its own wire format.
10
+ * **Text protocol** (fallback): the model emits a fenced block
11
+
12
+ ```tool
13
+ {"tool": "read_file", "args": {"path": "main.py"}}
14
+ ```
15
+
16
+ which the loop regex-parses; results are fed back as a ``[TOOL RESULTS]``
17
+ user message. Used when the provider has no native support or its first
18
+ native attempt fails (the session then downgrades once and stays there).
19
+
20
+ Either way the loop is: detect calls → execute through the permission gate →
21
+ feed results back → ask again, until the model answers with no tool calls
22
+ (the final response) or the step budget runs out. Malformed calls are not
23
+ fatal: the parse error is fed back so the model can correct itself, and
24
+ consecutive failures are bounded.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import re
31
+ from dataclasses import dataclass
32
+ from typing import Callable
33
+
34
+ from .chat import ChatEngine, ChatError
35
+ from .identity import build_system_prompt
36
+ from .models import AppConfig, Message, ToolCallRecord
37
+ from .project import detect_project
38
+ from .providers import provider_label
39
+ from .providers.base import TextDelta, ToolCallEvent, ToolSpec
40
+ from ..tools import PermissionError_, PermissionManager, ToolError, get_tool, tool_manifest
41
+ from ..tools.base import ToolResult, tool_specs
42
+ from ..utils.logger import get_logger
43
+
44
+ _log = get_logger("agent")
45
+
46
+ # Hard bounds so a confused model can never loop forever.
47
+ MAX_STEPS = 15
48
+ _MAX_CONSECUTIVE_FAILURES = 3
49
+ _MAX_CALLS_PER_STEP = 8
50
+
51
+ _TOOL_BLOCK = re.compile(r"```tool\s*\n(.*?)```", re.DOTALL)
52
+
53
+ _AGENT_PREAMBLE = (
54
+ "\n\nYou are in AGENT MODE with access to the user's project at {workspace} "
55
+ "(permission mode: {mode})."
56
+ )
57
+
58
+ _TEXT_PROTOCOL_INSTRUCTIONS = (
59
+ " To use a tool, emit a fenced block exactly like:\n"
60
+ '```tool\n{{"tool": "<name>", "args": {{...}}}}\n```\n'
61
+ "Rules: any number of tool blocks per reply; results arrive in the next "
62
+ "message as [TOOL RESULTS]; when the task is complete, reply WITHOUT tool "
63
+ "blocks — that is your final answer. Never invent tool results. If a call "
64
+ "fails, read the error and correct your next call.\n\n"
65
+ "Available tools:\n{manifest}"
66
+ )
67
+
68
+ _NATIVE_INSTRUCTIONS = (
69
+ " Use the provided tools to inspect and change the project. Rules: tool "
70
+ "results arrive as tool messages; when the task is complete, answer "
71
+ "WITHOUT calling tools — that is your final answer. Never invent tool "
72
+ "results. If a call fails, read the error and correct your next call. "
73
+ "Prefer editing existing files over rewriting them."
74
+ )
75
+
76
+ _DESKTOP_PROMPT_HEADER = (
77
+ "\n\nCOMPUTER ENGINE is available: you can control this computer, but you "
78
+ "are the PLANNER, not the hands. A deterministic engine does the work; you "
79
+ "only decide WHAT to do. Rules:\n"
80
+ "1. You NEVER produce coordinates, keystrokes, click sequences, or wait "
81
+ "loops. You choose a skill (computer_run) or a semantic UI action "
82
+ "(ui_click, ui_type, …) and describe the target in words — the engine "
83
+ "resolves it, executes it, verifies the result, and recovers from failures "
84
+ "on its own.\n"
85
+ "2. Prefer a named skill from the catalog below; it already knows the full "
86
+ "procedure. Use the ui_* actions only for UI a skill doesn't cover.\n"
87
+ "3. BROWSER WORK IS ALWAYS A SKILL, NEVER A CLICK. State the goal once — "
88
+ "computer_run(youtube_play, {\"query\": \"Love Me Thoda Aur\"}) — and the "
89
+ "engine performs the whole workflow: opens the page, dismisses cookie / "
90
+ "translate / sign-in popups, selects the right result, starts playback, "
91
+ "and verifies it. Do NOT search and then click a result, and do NOT use "
92
+ "ui_click / ui_type on a browser window — those are refused. When no "
93
+ "browser skill fits, use open_url with an address you construct.\n"
94
+ "4. Read computer_state instead of re-inspecting the screen — the engine "
95
+ "remembers the focused app, pointer, clipboard, terminal directory, "
96
+ "current project, and recent actions for you.\n"
97
+ "5. Each tool result already reflects VERIFIED reality (the engine checked "
98
+ "before reporting). Trust it; never claim success it didn't confirm.\n"
99
+ "6. Call the engine again only when it reports failure with a replan hint, "
100
+ "or when computer_see shows unexpected UI. Some actions need the user's "
101
+ "confirmation and may be denied — respect denials, do not retry them.\n\n"
102
+ "Available skills:\n"
103
+ )
104
+
105
+
106
+ def _desktop_prompt_section(max_level) -> str:
107
+ """The Computer Engine prompt block, including the live skill catalog."""
108
+ try:
109
+ from ..computer.skills import REGISTRY
110
+ from ..computer import catalog as _catalog # noqa: F401 — populate registry
111
+
112
+ manifest = REGISTRY.manifest(max_level=max_level)
113
+ except Exception:
114
+ manifest = "(catalog unavailable)"
115
+ return _DESKTOP_PROMPT_HEADER + (manifest or "(no skills at this level)")
116
+
117
+
118
+ @dataclass(slots=True)
119
+ class ToolCall:
120
+ """One parsed tool invocation from the model's reply."""
121
+
122
+ tool: str
123
+ args: dict
124
+ error: str = "" # parse/validation error, fed back to the model
125
+ call_id: str = "" # provider call id ("" for text-protocol calls)
126
+
127
+
128
+ def parse_tool_calls(text: str) -> list[ToolCall]:
129
+ """Extract tool calls from a model reply (malformed ones carry .error)."""
130
+ calls: list[ToolCall] = []
131
+ for match in _TOOL_BLOCK.finditer(text):
132
+ raw = match.group(1).strip()
133
+ try:
134
+ data = json.loads(raw)
135
+ except json.JSONDecodeError as exc:
136
+ calls.append(ToolCall("", {}, error=f"Invalid JSON in tool block: {exc}"))
137
+ continue
138
+ if not isinstance(data, dict) or not isinstance(data.get("tool"), str):
139
+ calls.append(
140
+ ToolCall("", {}, error='Tool block must be {"tool": "<name>", "args": {...}}.')
141
+ )
142
+ continue
143
+ args = data.get("args") or {}
144
+ if not isinstance(args, dict):
145
+ calls.append(ToolCall(data["tool"], {}, error='"args" must be a JSON object.'))
146
+ continue
147
+ calls.append(ToolCall(data["tool"].strip().lower(), args))
148
+ return calls
149
+
150
+
151
+ def strip_tool_blocks(text: str) -> str:
152
+ """Reply text with the tool blocks removed (what the user should see)."""
153
+ return _TOOL_BLOCK.sub("", text).strip()
154
+
155
+
156
+ def _downgrade_for_text(messages: list[Message]) -> list[Message]:
157
+ """Render native-era history into text-protocol form.
158
+
159
+ Providers' plain ``stream_chat``/``to_api()`` never learned the tool
160
+ roles, so before any text-protocol request: assistant ``tool_calls``
161
+ become appended ```` ```tool ```` blocks, and consecutive role=="tool"
162
+ results merge into one ``[TOOL RESULTS]`` user message.
163
+ """
164
+ out: list[Message] = []
165
+ pending_results: list[str] = []
166
+
167
+ def flush_results() -> None:
168
+ if pending_results:
169
+ out.append(
170
+ Message(
171
+ role="user",
172
+ content="[TOOL RESULTS]\n" + "\n\n".join(pending_results),
173
+ )
174
+ )
175
+ pending_results.clear()
176
+
177
+ for message in messages:
178
+ if message.role == "tool":
179
+ pending_results.append(f"{message.tool_name} -> {message.content}")
180
+ continue
181
+ flush_results()
182
+ if message.role == "assistant" and message.tool_calls:
183
+ blocks = "\n".join(
184
+ "```tool\n"
185
+ + json.dumps(
186
+ {"tool": call.name, "args": call.arguments}, ensure_ascii=False
187
+ )
188
+ + "\n```"
189
+ for call in message.tool_calls
190
+ )
191
+ content = (message.content + "\n" + blocks).strip()
192
+ out.append(
193
+ Message(role="assistant", content=content, images=message.images)
194
+ )
195
+ else:
196
+ out.append(message)
197
+ flush_results()
198
+ return out
199
+
200
+
201
+ class AgentEngine(ChatEngine):
202
+ """ChatEngine that runs the detect → execute → validate → retry loop.
203
+
204
+ ``on_event(kind, detail)`` reports progress ('call', 'result', 'error',
205
+ 'limit') so the UI can narrate tool activity without this module
206
+ importing any UI code.
207
+ """
208
+
209
+ def __init__(
210
+ self,
211
+ config: AppConfig,
212
+ permissions: PermissionManager,
213
+ on_event: Callable[[str, str], None] | None = None,
214
+ ) -> None:
215
+ super().__init__(config)
216
+ self.permissions = permissions
217
+ self._on_event = on_event or (lambda kind, detail: None)
218
+ # Native tool calling: None = untried, False = fell back to the text
219
+ # protocol for this session, True = at least one native step worked.
220
+ self._native: bool | None = None
221
+ self.messages[0] = Message(role="system", content=self._system_prompt())
222
+
223
+ def _system_prompt(self) -> str:
224
+ desktop_section = ""
225
+ if self._desktop_active():
226
+ desktop_section = _desktop_prompt_section(self.permissions.level)
227
+
228
+ # Build Seed Code identity + agent instructions
229
+ base_identity = build_system_prompt(
230
+ provider_label(self.config.provider),
231
+ self.config.model or "unspecified"
232
+ )
233
+ preamble = _AGENT_PREAMBLE.format(
234
+ workspace=self.permissions.workspace,
235
+ mode=self.permissions.mode.label,
236
+ )
237
+ if self._native_active():
238
+ # The API carries the tool schemas; no manifest needed.
239
+ instructions = _NATIVE_INSTRUCTIONS
240
+ else:
241
+ instructions = _TEXT_PROTOCOL_INSTRUCTIONS.format(
242
+ manifest=tool_manifest(self._groups())
243
+ )
244
+ return (
245
+ base_identity
246
+ + preamble
247
+ + instructions
248
+ + desktop_section
249
+ + self._project_context()
250
+ )
251
+
252
+ def _project_context(self) -> str:
253
+ """Ambient project summary; detection must never break construction."""
254
+ try:
255
+ info = detect_project(self.permissions.workspace)
256
+ return f"\n\nPROJECT CONTEXT:\n{info.summary}" if info.summary else ""
257
+ except Exception:
258
+ _log.exception("project detection failed")
259
+ return ""
260
+
261
+ def _groups(self) -> tuple[str, ...]:
262
+ return ("core", "desktop") if self._desktop_active() else ("core",)
263
+
264
+ def _desktop_active(self) -> bool:
265
+ """Desktop tools are advertised only when enabled AND runnable here."""
266
+ desktop = self.permissions.desktop
267
+ if desktop is None or not desktop.enabled:
268
+ return False
269
+ from ..computer import is_available
270
+
271
+ return is_available()[0]
272
+
273
+ def _native_active(self) -> bool:
274
+ """Whether this session should use native tool calling right now."""
275
+ if self._native is False:
276
+ return False
277
+ try:
278
+ from .providers import get_provider
279
+
280
+ return get_provider(self.config.provider).supports_tools(self.config)
281
+ except Exception:
282
+ return False
283
+
284
+ def refresh_system_prompt(self) -> None:
285
+ """Re-render the system prompt (after a permission-mode change)."""
286
+ self.messages[0] = Message(role="system", content=self._system_prompt())
287
+
288
+ # --- one whole agent turn ------------------------------------------------
289
+ def run_turn(self, user_text: str) -> str:
290
+ """Run the full agent loop for one user request; returns final text.
291
+
292
+ Raises :class:`ChatError` only when the provider itself fails; tool
293
+ failures are fed back to the model as retryable results.
294
+ """
295
+ self.add_user(user_text)
296
+ failures = 0
297
+
298
+ step = 0
299
+ while step < MAX_STEPS:
300
+ step += 1
301
+ if self._native_active():
302
+ outcome, payload = self._native_step(failures)
303
+ if outcome == "fallback":
304
+ # Downgrade once for the session and retry the SAME step
305
+ # through the text protocol.
306
+ _log.warning("native tool calling failed; falling back to text protocol")
307
+ self._native = False
308
+ self.refresh_system_prompt()
309
+ step -= 1
310
+ continue
311
+ else:
312
+ outcome, payload = self._text_step(failures)
313
+
314
+ if outcome == "final":
315
+ return payload or "Done."
316
+ failures = failures + 1 if outcome == "failed" else 0
317
+
318
+ self._on_event("limit", f"step budget ({MAX_STEPS}) reached")
319
+ return (
320
+ "I hit the agent step limit before finishing. Progress so far is "
321
+ "applied; ask me to continue to keep going."
322
+ )
323
+
324
+ # --- native path ---------------------------------------------------------
325
+ def _native_step(self, failures: int) -> tuple[str, str | None]:
326
+ """One step over the native tool-calling API.
327
+
328
+ Returns ("final", text) | ("ok", None) | ("failed", None) |
329
+ ("fallback", None).
330
+ """
331
+ specs = [ToolSpec(**s) for s in tool_specs(self._groups())]
332
+ text_parts: list[str] = []
333
+ calls: list[ToolCall] = []
334
+ try:
335
+ for event in self.stream_reply_events(specs):
336
+ if isinstance(event, TextDelta):
337
+ text_parts.append(event.text)
338
+ elif isinstance(event, ToolCallEvent):
339
+ calls.append(
340
+ ToolCall(
341
+ tool=(event.name or "").strip().lower(),
342
+ args=event.arguments,
343
+ error=event.error,
344
+ call_id=event.id,
345
+ )
346
+ )
347
+ except ChatError as exc:
348
+ if self._native is None:
349
+ # Never succeeded natively — treat any failure on the first
350
+ # attempt as "tools unsupported" and fall back gracefully.
351
+ _log.info("first native step failed (%s)", exc)
352
+ return ("fallback", None)
353
+ self.drop_last_user()
354
+ raise
355
+
356
+ reply = "".join(text_parts)
357
+
358
+ if not calls:
359
+ # Belt-and-braces: some models emit the TEXT protocol even when
360
+ # given native tools — honour it rather than ending the turn.
361
+ text_calls = parse_tool_calls(reply)
362
+ if not text_calls:
363
+ self._native = True
364
+ self.add_assistant(reply)
365
+ final = reply.strip()
366
+ return ("final", final if final else "Done.")
367
+ self._native = True
368
+ return self._execute_text_style(reply, text_calls, failures)
369
+
370
+ self._native = True
371
+ executed = calls[:_MAX_CALLS_PER_STEP]
372
+ deferred = calls[_MAX_CALLS_PER_STEP:]
373
+
374
+ self.messages.append(
375
+ Message(
376
+ role="assistant",
377
+ content=reply,
378
+ tool_calls=[
379
+ ToolCallRecord(id=c.call_id, name=c.tool, arguments=c.args)
380
+ for c in calls
381
+ ],
382
+ )
383
+ )
384
+ shown = reply.strip()
385
+ if shown:
386
+ self._on_event("say", shown)
387
+
388
+ results, step_failed = self._execute_calls(executed)
389
+ images = self._drain_images()
390
+ for call, result_text in zip(executed, results):
391
+ self.messages.append(
392
+ Message(
393
+ role="tool",
394
+ content=result_text,
395
+ tool_call_id=call.call_id,
396
+ tool_name=call.tool,
397
+ images=images,
398
+ )
399
+ )
400
+ images = [] # attach pending screenshots to the first result only
401
+ # Every issued call id must be answered (strict APIs 400 otherwise).
402
+ for call in deferred:
403
+ self.messages.append(
404
+ Message(
405
+ role="tool",
406
+ content=(
407
+ f"(not executed: only the first {_MAX_CALLS_PER_STEP} tool "
408
+ "calls run per step; issue this again next step)"
409
+ ),
410
+ tool_call_id=call.call_id,
411
+ tool_name=call.tool,
412
+ )
413
+ )
414
+
415
+ if self._limit_reached(step_failed, failures):
416
+ # Fold the nudge into the last tool result rather than adding a
417
+ # user turn — strict APIs require every tool_use answered by
418
+ # tool_results in one block, with no interleaved user text.
419
+ last = self.messages[-1]
420
+ last.content += (
421
+ "\n\n[SYSTEM] Multiple consecutive steps failed. Stop calling "
422
+ "tools and summarise the problem for the user."
423
+ )
424
+ _log.info("agent native step: %d call(s), failed=%s", len(calls), step_failed)
425
+ return ("failed" if step_failed else "ok", None)
426
+
427
+ # --- text-protocol path --------------------------------------------------
428
+ def _text_step(self, failures: int) -> tuple[str, str | None]:
429
+ """One step over the text protocol (fenced ```tool blocks)."""
430
+ # Providers' plain path never learned the tool roles; render any
431
+ # native-era messages into text form for this request.
432
+ original = self.messages
433
+ self.messages = _downgrade_for_text(original)
434
+ try:
435
+ reply = "".join(self.stream_reply())
436
+ except ChatError:
437
+ self.messages = original
438
+ self.drop_last_user()
439
+ raise
440
+ self.messages = original
441
+
442
+ calls = parse_tool_calls(reply)
443
+ if not calls:
444
+ self.add_assistant(reply)
445
+ final = reply.strip()
446
+ # Some models complete tool work without emitting a summary line.
447
+ # Return a minimal acknowledgement so the UI never shows "(no response)".
448
+ return ("final", final if final else "Done.")
449
+
450
+ return self._execute_text_style(reply, calls, failures)
451
+
452
+ def _execute_text_style(
453
+ self, reply: str, calls: list[ToolCall], failures: int
454
+ ) -> tuple[str, str | None]:
455
+ """Execute calls and append text-protocol style feedback messages."""
456
+ self.add_assistant(reply)
457
+ shown = strip_tool_blocks(reply)
458
+ if shown:
459
+ self._on_event("say", shown)
460
+
461
+ results, step_failed = self._execute_calls(calls[:_MAX_CALLS_PER_STEP])
462
+ if len(calls) > _MAX_CALLS_PER_STEP:
463
+ results.append(
464
+ f"(only the first {_MAX_CALLS_PER_STEP} tool calls were run; "
465
+ "issue the rest next step)"
466
+ )
467
+
468
+ if self._limit_reached(step_failed, failures):
469
+ results.append(
470
+ "[SYSTEM] Multiple consecutive steps failed. Stop calling tools "
471
+ "and summarise the problem for the user."
472
+ )
473
+
474
+ feedback = "[TOOL RESULTS]\n" + "\n\n".join(results)
475
+ _log.info("agent text step: %d call(s), failed=%s", len(calls), step_failed)
476
+ self.messages.append(
477
+ Message(role="user", content=feedback, images=self._drain_images())
478
+ )
479
+ return ("failed" if step_failed else "ok", None)
480
+
481
+ def _limit_reached(self, step_failed: bool, failures: int) -> bool:
482
+ """True when this failure crosses the consecutive-failure bound."""
483
+ if not step_failed:
484
+ return False
485
+ if failures + 1 >= _MAX_CONSECUTIVE_FAILURES:
486
+ self._on_event("limit", "too many consecutive tool failures")
487
+ return True
488
+ return False
489
+
490
+ def _drain_images(self) -> list[str]:
491
+ """Pending desktop screenshots — attached only for vision providers.
492
+
493
+ Screenshots are queued by desktop_see/desktop_screenshot; when the
494
+ active provider cannot take images they are simply dropped (the UIA
495
+ text snapshot in the tool result carries the information instead).
496
+ """
497
+ desktop = self.permissions.desktop
498
+ if desktop is None or not desktop.pending_images:
499
+ return []
500
+ images = list(desktop.pending_images)
501
+ desktop.pending_images.clear()
502
+ try:
503
+ from .providers import get_provider
504
+
505
+ if get_provider(self.config.provider).supports_images(self.config):
506
+ return images
507
+ except Exception: # provider lookup must never break the loop
508
+ pass
509
+ return []
510
+
511
+ def _execute_calls(self, calls: list[ToolCall]) -> tuple[list[str], bool]:
512
+ """Execute parsed calls; returns (results-for-model, any_failed)."""
513
+ results: list[str] = []
514
+ any_failed = False
515
+ for call in calls:
516
+ if call.error:
517
+ any_failed = True
518
+ results.append(f"[ERROR] {call.error}")
519
+ self._on_event("error", call.error)
520
+ continue
521
+ label = f"{call.tool}({json.dumps(call.args, ensure_ascii=False)[:120]})"
522
+ self._on_event("call", label)
523
+ try:
524
+ result = get_tool(call.tool).run(self.permissions, call.args)
525
+ except (ToolError, PermissionError_) as exc:
526
+ result = ToolResult(False, str(exc))
527
+ except Exception as exc: # a tool bug must not kill the loop
528
+ _log.exception("tool crashed: %s", call.tool)
529
+ result = ToolResult(False, f"Tool crashed: {exc}")
530
+ if not result.ok:
531
+ any_failed = True
532
+ self._on_event("result" if result.ok else "error", result.output[:200])
533
+ results.append(f"{call.tool} -> {result.for_model()}")
534
+ return results, any_failed
seedcode/core/chat.py ADDED
@@ -0,0 +1,128 @@
1
+ """Chat engine: routes conversations through the active provider.
2
+
3
+ The engine owns conversation state and retry policy. Which vendor actually
4
+ answers is decided per request by ``config.provider`` + ``config.model``, so
5
+ switching providers or models mid-session takes effect on the next turn.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from collections.abc import Callable, Iterator
12
+ from typing import Any
13
+
14
+ from .identity import build_system_prompt
15
+ from .models import AppConfig, Message
16
+ from .providers import ProviderError, get_provider, provider_label
17
+ from .providers.base import StreamEvent, ToolSpec
18
+ from ..utils.logger import get_logger
19
+
20
+ _log = get_logger("chat")
21
+
22
+ # Transparent retry for transient failures before any output has streamed.
23
+ _MAX_RETRIES = 2
24
+ _RETRY_BACKOFF_S = 1.5
25
+
26
+
27
+ class ChatError(Exception):
28
+ """Raised with a user-friendly message when a request cannot complete."""
29
+
30
+
31
+ class ChatEngine:
32
+ """Stateful conversation manager delegating requests to providers."""
33
+
34
+ def __init__(self, config: AppConfig) -> None:
35
+ self.config = config
36
+ # Build identity-aware system prompt with current provider + model
37
+ system_content = build_system_prompt(
38
+ provider_label(config.provider), config.model or "unspecified"
39
+ )
40
+ self.messages: list[Message] = [Message(role="system", content=system_content)]
41
+
42
+ # --- history management ------------------------------------------------
43
+ def add_user(self, content: str) -> None:
44
+ self.messages.append(Message(role="user", content=content))
45
+
46
+ def add_assistant(self, content: str) -> None:
47
+ self.messages.append(Message(role="assistant", content=content))
48
+
49
+ def drop_last_user(self) -> None:
50
+ """Remove a trailing unanswered user turn (after a failed request).
51
+
52
+ Keeps the transcript alternating so the next attempt never sends two
53
+ consecutive user messages, which strict APIs reject.
54
+ """
55
+ if self.messages and self.messages[-1].role == "user":
56
+ self.messages.pop()
57
+
58
+ def reset(self) -> None:
59
+ """Clear the conversation but keep the system prompt."""
60
+ self.messages = [self.messages[0]]
61
+
62
+ @property
63
+ def transcript(self) -> list[Message]:
64
+ return self.messages
65
+
66
+ # --- requests ----------------------------------------------------------
67
+ def stream_reply(self) -> Iterator[str]:
68
+ """Stream a reply via the current provider + current model.
69
+
70
+ Transient provider failures are retried with a short backoff — but
71
+ never once output has started, since a retry would replay the reply.
72
+ All failures surface as :class:`ChatError` with friendly text.
73
+ """
74
+ provider = self._resolve_provider()
75
+ yield from self._stream_with_retry(
76
+ lambda: provider.stream_chat(self.config, self.messages)
77
+ )
78
+
79
+ def stream_reply_events(self, tools: list[ToolSpec]) -> Iterator[StreamEvent]:
80
+ """Stream a reply as events (text deltas + native tool calls).
81
+
82
+ Same retry policy as :meth:`stream_reply` — one shared helper owns
83
+ it, so the two paths can never drift.
84
+ """
85
+ provider = self._resolve_provider()
86
+ yield from self._stream_with_retry(
87
+ lambda: provider.stream_chat_with_tools(self.config, self.messages, tools)
88
+ )
89
+
90
+ def _resolve_provider(self) -> Any:
91
+ if not self.config.model:
92
+ raise ChatError("No model selected. Pick one with /model first.")
93
+ try:
94
+ return get_provider(self.config.provider)
95
+ except ProviderError as exc:
96
+ raise ChatError(str(exc)) from exc
97
+
98
+ def _stream_with_retry(self, request: Callable[[], Iterator[Any]]) -> Iterator[Any]:
99
+ """Run a provider stream with transient retry before first output."""
100
+ attempt = 0
101
+ while True:
102
+ yielded = False
103
+ _log.info(
104
+ "request: provider=%s model=%s turns=%d attempt=%d",
105
+ self.config.provider,
106
+ self.config.model,
107
+ len(self.messages),
108
+ attempt,
109
+ )
110
+ try:
111
+ for piece in request():
112
+ yielded = True
113
+ yield piece
114
+ _log.info("request complete: provider=%s", self.config.provider)
115
+ return
116
+ except ProviderError as exc:
117
+ if exc.transient and not yielded and attempt < _MAX_RETRIES:
118
+ attempt += 1
119
+ _log.warning("transient failure, retry %d: %s", attempt, exc)
120
+ time.sleep(_RETRY_BACKOFF_S * attempt)
121
+ continue
122
+ _log.error("request failed: %s", exc)
123
+ raise ChatError(str(exc)) from exc
124
+ except ChatError:
125
+ raise
126
+ except Exception as exc: # last-resort guard: never crash the REPL
127
+ _log.exception("unexpected error during request")
128
+ raise ChatError(f"Unexpected error: {exc}") from exc
@@ -0,0 +1,9 @@
1
+ """REMOVED. The deprecated OpenRouter client shim is gone.
2
+
3
+ This file is scheduled for deletion; nothing in Seed Code imports it.
4
+ Providers live in :mod:`seedcode.core.providers`.
5
+ """
6
+
7
+ raise ImportError(
8
+ "seedcode.core.client was removed. Use seedcode.core.providers instead."
9
+ )