seedcode-cli 9.1.0__py3-none-any.whl → 9.1.1__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.
- seedcode/__init__.py +1 -1
- seedcode/_default_key_template.py +1 -1
- seedcode/app.py +122 -10
- seedcode/commands/doctor.py +10 -0
- seedcode/core/agent.py +99 -10
- seedcode/core/chat.py +33 -9
- seedcode/core/intent.py +321 -0
- seedcode/core/internet.py +255 -0
- seedcode/core/providers/default.py +1 -1
- seedcode/default_api.py +77 -0
- seedcode/tools/__init__.py +1 -1
- seedcode/tools/permissions.py +18 -2
- seedcode/tools/web.py +81 -0
- seedcode/ui/codemode_header.py +2 -2
- seedcode/ui/dashboard.py +2 -2
- seedcode/ui/dialog.py +12 -4
- seedcode/ui/tui.py +12 -4
- {seedcode_cli-9.1.0.dist-info → seedcode_cli-9.1.1.dist-info}/METADATA +128 -25
- {seedcode_cli-9.1.0.dist-info → seedcode_cli-9.1.1.dist-info}/RECORD +22 -19
- {seedcode_cli-9.1.0.dist-info → seedcode_cli-9.1.1.dist-info}/WHEEL +0 -0
- {seedcode_cli-9.1.0.dist-info → seedcode_cli-9.1.1.dist-info}/entry_points.txt +0 -0
- {seedcode_cli-9.1.0.dist-info → seedcode_cli-9.1.1.dist-info}/licenses/LICENSE +0 -0
seedcode/__init__.py
CHANGED
|
@@ -6,7 +6,7 @@ Plant ideas. Grow code.
|
|
|
6
6
|
# Single source of truth for the application version. Every consumer reads it:
|
|
7
7
|
# the PyPI build (via [tool.hatch.version]), the CLI --version flag, the
|
|
8
8
|
# Windows installer metadata, the GitHub release tag, and the WinGet manifest.
|
|
9
|
-
__version__ = "9.1.
|
|
9
|
+
__version__ = "9.1.1"
|
|
10
10
|
__author__ = "Al Shahriar Sowan"
|
|
11
11
|
__publisher__ = "Eagox Studio"
|
|
12
12
|
# Current author/company links, surfaced by /about and the README credits.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Embedded built-in credential for the Default provider (v9.1.
|
|
1
|
+
"""Embedded built-in credential for the Default provider (v9.1.1 artifact).
|
|
2
2
|
|
|
3
3
|
This file is NOT committed to source control. The release build generates
|
|
4
4
|
``seedcode/_default_key.py`` from the local ``.env`` (see
|
seedcode/app.py
CHANGED
|
@@ -33,6 +33,7 @@ from .commands.theme import pick_theme
|
|
|
33
33
|
from .config import load_config
|
|
34
34
|
from .core.agent import AgentEngine, strip_tool_blocks
|
|
35
35
|
from .core.chat import ChatEngine, ChatError
|
|
36
|
+
from .core.intent import Intent, classify
|
|
36
37
|
from .core.lifecycle import LifecycleError, lifecycle
|
|
37
38
|
from .core.models import AppConfig
|
|
38
39
|
from .core.providers import (
|
|
@@ -245,9 +246,52 @@ def _guided_setup(ui: UI, config: AppConfig) -> bool:
|
|
|
245
246
|
return config.is_configured()
|
|
246
247
|
|
|
247
248
|
|
|
248
|
-
def
|
|
249
|
-
"""
|
|
250
|
-
|
|
249
|
+
def _retrieve_context(ui: UI, intent: Intent, text: str) -> str:
|
|
250
|
+
"""Fetch fresh external information for a research request (v9.1.1).
|
|
251
|
+
|
|
252
|
+
Returns a bounded context block, or '' when retrieval is disabled, fails,
|
|
253
|
+
or finds nothing. The caller shows a lightweight status and continues — a
|
|
254
|
+
failed lookup must never stop the answer. Nothing here is fatal.
|
|
255
|
+
"""
|
|
256
|
+
try:
|
|
257
|
+
from .core import internet
|
|
258
|
+
|
|
259
|
+
if internet.internet_disabled():
|
|
260
|
+
return ""
|
|
261
|
+
ui.dim("[Accessing internet...]")
|
|
262
|
+
result = internet.research(intent.target or text)
|
|
263
|
+
if not result.ok:
|
|
264
|
+
ui.dim("[No online sources available]")
|
|
265
|
+
return ""
|
|
266
|
+
if result.excerpts:
|
|
267
|
+
ui.dim("[Reading relevant sources...]")
|
|
268
|
+
return internet.context_block(result)
|
|
269
|
+
except Exception: # retrieval must never break a turn
|
|
270
|
+
_log.exception("internet retrieval failed")
|
|
271
|
+
return ""
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _handle_chat(
|
|
275
|
+
ui: UI,
|
|
276
|
+
engine: ChatEngine,
|
|
277
|
+
history: HistoryStore,
|
|
278
|
+
text: str,
|
|
279
|
+
intent: Intent | None = None,
|
|
280
|
+
) -> None:
|
|
281
|
+
"""Send a user turn to the model and stream the reply to screen.
|
|
282
|
+
|
|
283
|
+
When the request needs current information, only a lightweight status is
|
|
284
|
+
shown (``[Accessing internet...]`` / ``[Reading relevant sources...]``)
|
|
285
|
+
and the retrieved sources are prepended to the turn; the browsing itself
|
|
286
|
+
is never exposed line by line.
|
|
287
|
+
"""
|
|
288
|
+
resolved = intent or classify(text)
|
|
289
|
+
outgoing = text
|
|
290
|
+
if resolved.needs_internet:
|
|
291
|
+
block = _retrieve_context(ui, resolved, text)
|
|
292
|
+
if block:
|
|
293
|
+
outgoing = block + "\n\n" + text
|
|
294
|
+
engine.add_user(outgoing)
|
|
251
295
|
renderer = None
|
|
252
296
|
try:
|
|
253
297
|
chunks = engine.stream_reply()
|
|
@@ -875,12 +919,24 @@ def _make_agent(
|
|
|
875
919
|
return engine
|
|
876
920
|
|
|
877
921
|
|
|
922
|
+
# The process-wide dangerous-action gate (v9.1.1). It is created once, not
|
|
923
|
+
# once per engine: the agent engine is rebuilt whenever the permission level or
|
|
924
|
+
# workspace changes, and re-creating the gate there was resetting the user's
|
|
925
|
+
# "Allow" / "Allow All" answers mid-task — the repeated-permission-prompt bug.
|
|
926
|
+
# A grant now survives every tool call, every multi-step task and every engine
|
|
927
|
+
# rebuild, exactly as the session-scope answer promises.
|
|
928
|
+
_ACTION_GATE = None
|
|
929
|
+
_ACTION_GATE_LOCK = threading.Lock()
|
|
930
|
+
|
|
931
|
+
|
|
878
932
|
def _make_action_gate(ui: UI):
|
|
879
|
-
"""
|
|
933
|
+
"""The session-scoped dangerous-action gate (created once per process).
|
|
880
934
|
|
|
881
|
-
|
|
882
|
-
|
|
935
|
+
Answers stick for the rest of the session: ``Always Allow`` per category,
|
|
936
|
+
and ``Allow All`` across every category. Nothing is persisted to disk, so
|
|
937
|
+
a new process starts clean — the scope is deliberately session-only.
|
|
883
938
|
"""
|
|
939
|
+
global _ACTION_GATE
|
|
884
940
|
from .tools.permissions import ACTION_LABELS, ActionGate, ActionGrant
|
|
885
941
|
|
|
886
942
|
def confirm(category: str, description: str) -> ActionGrant:
|
|
@@ -889,9 +945,20 @@ def _make_action_gate(ui: UI):
|
|
|
889
945
|
return {
|
|
890
946
|
"y": ActionGrant.ONCE,
|
|
891
947
|
"a": ActionGrant.ALWAYS,
|
|
948
|
+
"A": ActionGrant.ALL,
|
|
892
949
|
}.get(answer, ActionGrant.DENY)
|
|
893
950
|
|
|
894
|
-
|
|
951
|
+
with _ACTION_GATE_LOCK:
|
|
952
|
+
if _ACTION_GATE is None:
|
|
953
|
+
_ACTION_GATE = ActionGate(confirm=confirm)
|
|
954
|
+
return _ACTION_GATE
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def reset_action_gate() -> None:
|
|
958
|
+
"""Forget all session grants (test isolation; also an explicit reset)."""
|
|
959
|
+
global _ACTION_GATE
|
|
960
|
+
with _ACTION_GATE_LOCK:
|
|
961
|
+
_ACTION_GATE = None
|
|
895
962
|
|
|
896
963
|
|
|
897
964
|
def _make_desktop_session(ui: UI):
|
|
@@ -1020,7 +1087,11 @@ def _chat_loop(
|
|
|
1020
1087
|
# span's ``finally`` returns the state machine to IDLE and the REPL
|
|
1021
1088
|
# prompts again. A task can never end the app.
|
|
1022
1089
|
with lifecycle().task_span():
|
|
1023
|
-
|
|
1090
|
+
intent = classify(text)
|
|
1091
|
+
if config.agent_mode or intent.executes:
|
|
1092
|
+
# Build (or rebuild) the Agent engine for the live config. A
|
|
1093
|
+
# temporary escalation builds exactly the same engine an
|
|
1094
|
+
# explicit Agent Mode turn would, so behaviour cannot diverge.
|
|
1024
1095
|
try:
|
|
1025
1096
|
from . import codemode_state as _cms
|
|
1026
1097
|
|
|
@@ -1035,11 +1106,21 @@ def _chat_loop(
|
|
|
1035
1106
|
agent = _make_agent(ui, config, presenter)
|
|
1036
1107
|
agent_perm = config.permission_mode
|
|
1037
1108
|
agent_codemode = codemode_now
|
|
1109
|
+
if not config.agent_mode:
|
|
1110
|
+
# Chat Mode escalation: one agent turn, then back to chat.
|
|
1111
|
+
# The persistent mode is untouched.
|
|
1112
|
+
ui.dim(f"[Agent assist - {intent.reason}]")
|
|
1113
|
+
try:
|
|
1114
|
+
agent.set_intent(intent) # type: ignore[union-attr]
|
|
1115
|
+
except Exception:
|
|
1116
|
+
pass
|
|
1038
1117
|
# One task flow per turn: the engine reports real activity into
|
|
1039
1118
|
# it, and the REPL keeps prompting when the turn ends.
|
|
1040
1119
|
_handle_agent(ui, agent, history, text, presenter)
|
|
1120
|
+
if not config.agent_mode:
|
|
1121
|
+
ui.dim("[Temporary agent task finished - back to Chat Mode]")
|
|
1041
1122
|
else:
|
|
1042
|
-
_handle_chat(ui, engine, history, text)
|
|
1123
|
+
_handle_chat(ui, engine, history, text, intent)
|
|
1043
1124
|
|
|
1044
1125
|
|
|
1045
1126
|
def _interactive() -> bool:
|
|
@@ -1200,12 +1281,43 @@ class _TuiController:
|
|
|
1200
1281
|
self._ensure_agent()
|
|
1201
1282
|
_handle_agent(self.ui, self.agent, self.history, text, self.presenter)
|
|
1202
1283
|
else:
|
|
1203
|
-
|
|
1284
|
+
intent = classify(text)
|
|
1285
|
+
if intent.executes:
|
|
1286
|
+
# Chat Mode detected a request needing real execution:
|
|
1287
|
+
# escalate to Agent for THIS turn only, then return to
|
|
1288
|
+
# Chat. The persistent mode is never changed.
|
|
1289
|
+
self._run_escalated_turn(text, intent)
|
|
1290
|
+
else:
|
|
1291
|
+
_handle_chat(self.ui, self.engine, self.history, text, intent)
|
|
1204
1292
|
try:
|
|
1205
1293
|
self.tui.sync_from_config(self.config)
|
|
1206
1294
|
except Exception:
|
|
1207
1295
|
pass
|
|
1208
1296
|
|
|
1297
|
+
def _run_escalated_turn(self, text: str, intent: Intent) -> None:
|
|
1298
|
+
"""Run one Agent turn from Chat Mode, then hand control back (v9.1.1).
|
|
1299
|
+
|
|
1300
|
+
This is the *temporary* escalation path: the user stays in Chat Mode,
|
|
1301
|
+
one request is executed with the full agent loop, and the session
|
|
1302
|
+
returns to conversation. Explicit Agent Mode is unaffected and stays
|
|
1303
|
+
active until the user changes it.
|
|
1304
|
+
"""
|
|
1305
|
+
self.ui.dim(f"[Agent assist - {intent.reason}]")
|
|
1306
|
+
try:
|
|
1307
|
+
self._ensure_agent()
|
|
1308
|
+
agent = self.agent
|
|
1309
|
+
if agent is None:
|
|
1310
|
+
_handle_chat(self.ui, self.engine, self.history, text, intent)
|
|
1311
|
+
return
|
|
1312
|
+
agent.set_intent(intent)
|
|
1313
|
+
_handle_agent(self.ui, agent, self.history, text, self.presenter)
|
|
1314
|
+
except Exception: # escalation must never break the session
|
|
1315
|
+
_log.exception("temporary agent escalation failed")
|
|
1316
|
+
self.ui.error("The agent step could not run; answering in Chat Mode.")
|
|
1317
|
+
_handle_chat(self.ui, self.engine, self.history, text, intent)
|
|
1318
|
+
finally:
|
|
1319
|
+
self.ui.dim("[Temporary agent task finished - back to Chat Mode]")
|
|
1320
|
+
|
|
1209
1321
|
# --- mode transitions ---------------------------------------------------
|
|
1210
1322
|
def _mode_command(self, text: str) -> None:
|
|
1211
1323
|
"""Handle a mode-switch command from the composer — *never* inline.
|
seedcode/commands/doctor.py
CHANGED
|
@@ -97,6 +97,16 @@ def _run_checks(config) -> list[tuple[str, str, str]]:
|
|
|
97
97
|
return rows # nothing else is checkable without a provider
|
|
98
98
|
|
|
99
99
|
provider.prepare(config) # bind checks to the configured sub-backend
|
|
100
|
+
if provider.id == "default":
|
|
101
|
+
# The built-in connection needs no user key, but it can still be
|
|
102
|
+
# missing, invalid, or unavailable in this build. Report which one, so
|
|
103
|
+
# a clean install is diagnosable instead of just "not configured".
|
|
104
|
+
from ..default_api import BUILTIN_AVAILABLE, builtin_status
|
|
105
|
+
|
|
106
|
+
code, reason = builtin_status()
|
|
107
|
+
rows.append(
|
|
108
|
+
(_OK if code == BUILTIN_AVAILABLE else _FAIL, "Built-in connection", reason)
|
|
109
|
+
)
|
|
100
110
|
if provider.requires_key:
|
|
101
111
|
key = config.get_api_key(provider.id).strip()
|
|
102
112
|
if not key:
|
seedcode/core/agent.py
CHANGED
|
@@ -34,6 +34,7 @@ from typing import Any, Callable
|
|
|
34
34
|
|
|
35
35
|
from .chat import ChatEngine, ChatError
|
|
36
36
|
from .identity import build_system_prompt
|
|
37
|
+
from .intent import Intent, IntentKind, classify
|
|
37
38
|
from .models import AppConfig, Message, ToolCallRecord, clean_values
|
|
38
39
|
from .project import detect_project
|
|
39
40
|
from .providers import provider_label
|
|
@@ -79,6 +80,25 @@ _AGENT_PREAMBLE = (
|
|
|
79
80
|
"(permission mode: {mode})."
|
|
80
81
|
)
|
|
81
82
|
|
|
83
|
+
# Injected for a conversational request so an Agent turn does not reach for
|
|
84
|
+
# tools (or the workspace) simply because they are available.
|
|
85
|
+
_CONVERSATION_PREAMBLE = (
|
|
86
|
+
"\n\nCONVERSATIONAL REQUEST: the user is asking a question, not posing a "
|
|
87
|
+
"task. Answer directly and concisely from your own knowledge. Do NOT read "
|
|
88
|
+
"the workspace, do NOT run commands, and do NOT call a tool unless the "
|
|
89
|
+
"request explicitly names something that must be read or changed. "
|
|
90
|
+
"Using a tool you do not need is a failure, not thoroughness."
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Injected for a browser/desktop request so the agent never conflates it with
|
|
94
|
+
# a coding task (and never scans the project to fulfil it).
|
|
95
|
+
_COMPUTER_PREAMBLE = (
|
|
96
|
+
"\n\nCOMPUTER TASK: this request is about acting on the user's computer or "
|
|
97
|
+
"browser, not about their project. Do NOT read, search or modify workspace "
|
|
98
|
+
"files, and do NOT run project commands for it. Use only the computer / "
|
|
99
|
+
"browser capability required, then report what actually happened."
|
|
100
|
+
)
|
|
101
|
+
|
|
82
102
|
_CODEMODE_PREAMBLE = (
|
|
83
103
|
"\n\nCODE MODE is ON: {workspace} is the project workspace and all work "
|
|
84
104
|
"stays inside it. You are a persistent software-engineering agent, not a "
|
|
@@ -315,6 +335,10 @@ class AgentEngine(ChatEngine):
|
|
|
315
335
|
# Code Mode session verifies tasks against this, never against the
|
|
316
336
|
# model's own claim of success.
|
|
317
337
|
self.last_evidence = TurnEvidence()
|
|
338
|
+
# The resolved need behind the turn in flight (v9.1.1). ``None`` until
|
|
339
|
+
# a turn (or an explicit :meth:`set_intent`) sets it, in which case the
|
|
340
|
+
# engine keeps the pre-9.1.1 behaviour of carrying full context.
|
|
341
|
+
self._intent: Intent | None = None
|
|
318
342
|
# Code Mode gets a generous per-turn budget (the session continues the
|
|
319
343
|
# task afterwards); callers may override it explicitly.
|
|
320
344
|
if max_steps is not None:
|
|
@@ -324,6 +348,7 @@ class AgentEngine(ChatEngine):
|
|
|
324
348
|
self.messages[0] = Message(role="system", content=self._system_prompt())
|
|
325
349
|
|
|
326
350
|
def _system_prompt(self) -> str:
|
|
351
|
+
intent = self._intent
|
|
327
352
|
desktop_section = ""
|
|
328
353
|
if self._desktop_active():
|
|
329
354
|
desktop_section = _desktop_prompt_section(self.permissions.level)
|
|
@@ -337,7 +362,7 @@ class AgentEngine(ChatEngine):
|
|
|
337
362
|
workspace=self.permissions.workspace,
|
|
338
363
|
mode=self.permissions.mode.label,
|
|
339
364
|
)
|
|
340
|
-
if self._codemode_active():
|
|
365
|
+
if self._workspace_allowed() and self._codemode_active():
|
|
341
366
|
# The behavioural instruction that makes the model a coding agent
|
|
342
367
|
# (not just a chat model with tools) — previously defined but
|
|
343
368
|
# never injected, so Code Mode lost its workflow guidance.
|
|
@@ -345,7 +370,17 @@ class AgentEngine(ChatEngine):
|
|
|
345
370
|
workspace=self.permissions.workspace
|
|
346
371
|
)
|
|
347
372
|
preamble += self._codemode_context()
|
|
348
|
-
preamble
|
|
373
|
+
# v9.1.1: an intent-aware preamble keeps a conversational question from
|
|
374
|
+
# turning into a workspace sweep, and a browser task from touching the
|
|
375
|
+
# project at all. The terminal hint is only useful when the task can
|
|
376
|
+
# actually run something.
|
|
377
|
+
if intent is not None:
|
|
378
|
+
if intent.is_conversation:
|
|
379
|
+
preamble += _CONVERSATION_PREAMBLE
|
|
380
|
+
elif intent.kind is IntentKind.COMPUTER:
|
|
381
|
+
preamble += _COMPUTER_PREAMBLE
|
|
382
|
+
if intent is None or intent.needs_terminal or intent.needs_workspace:
|
|
383
|
+
preamble += self._terminal_context()
|
|
349
384
|
if self._native_active():
|
|
350
385
|
# The API carries the tool schemas; no manifest needed.
|
|
351
386
|
instructions = _NATIVE_INSTRUCTIONS
|
|
@@ -353,13 +388,42 @@ class AgentEngine(ChatEngine):
|
|
|
353
388
|
instructions = _TEXT_PROTOCOL_INSTRUCTIONS.format(
|
|
354
389
|
manifest=tool_manifest(self._groups())
|
|
355
390
|
)
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
391
|
+
project = self._project_context() if self._workspace_allowed() else ""
|
|
392
|
+
return base_identity + preamble + instructions + desktop_section + project
|
|
393
|
+
|
|
394
|
+
# --- intent (v9.1.1) ------------------------------------------------------
|
|
395
|
+
def set_intent(self, intent: Intent | None) -> None:
|
|
396
|
+
"""Set the resolved need for the next turn and re-render the prompt.
|
|
397
|
+
|
|
398
|
+
A caller that already classified the request (the app layer, so Chat
|
|
399
|
+
escalation and Agent execution agree) passes the result here; otherwise
|
|
400
|
+
:meth:`run_turn` classifies the user text itself.
|
|
401
|
+
"""
|
|
402
|
+
self._intent = intent
|
|
403
|
+
self.refresh_system_prompt()
|
|
404
|
+
|
|
405
|
+
@property
|
|
406
|
+
def intent(self) -> Intent | None:
|
|
407
|
+
"""The intent of the turn in flight (``None`` before the first turn)."""
|
|
408
|
+
return self._intent
|
|
409
|
+
|
|
410
|
+
def _workspace_allowed(self) -> bool:
|
|
411
|
+
"""Whether this turn may read the project (the minimum-tool rule).
|
|
412
|
+
|
|
413
|
+
Unknown intent (no turn yet) preserves the previous behaviour, so a
|
|
414
|
+
caller that never classifies loses nothing. Once an intent is set, a
|
|
415
|
+
conversational or computer task is not allowed workspace context — the
|
|
416
|
+
request must not cause file access it does not need.
|
|
417
|
+
"""
|
|
418
|
+
if self._intent is None:
|
|
419
|
+
return True
|
|
420
|
+
return self._intent.needs_workspace
|
|
421
|
+
|
|
422
|
+
def _desktop_allowed(self) -> bool:
|
|
423
|
+
"""Whether this turn may be offered the Computer Engine."""
|
|
424
|
+
if self._intent is None:
|
|
425
|
+
return True
|
|
426
|
+
return self._intent.needs_desktop
|
|
363
427
|
|
|
364
428
|
def _codemode_active(self) -> bool:
|
|
365
429
|
"""Code Mode is a session-level toggle read lazily (no import cycle)."""
|
|
@@ -446,10 +510,27 @@ class AgentEngine(ChatEngine):
|
|
|
446
510
|
return ""
|
|
447
511
|
|
|
448
512
|
def _groups(self) -> tuple[str, ...]:
|
|
449
|
-
|
|
513
|
+
"""The tool groups this turn may use (minimum necessary, v9.1.1).
|
|
514
|
+
|
|
515
|
+
``core`` is always available; ``desktop`` only when the Computer Engine
|
|
516
|
+
is usable *and* the task needs it; ``web`` only when the task needs
|
|
517
|
+
current external information. A coding turn therefore never advertises
|
|
518
|
+
browser/desktop/web tools, and a browser turn never advertises web
|
|
519
|
+
search.
|
|
520
|
+
"""
|
|
521
|
+
groups: list[str] = ["core"]
|
|
522
|
+
if self._desktop_active():
|
|
523
|
+
groups.append("desktop")
|
|
524
|
+
extra = self._intent.tool_groups if self._intent is not None else ()
|
|
525
|
+
for group in extra:
|
|
526
|
+
if group not in groups and group != "desktop":
|
|
527
|
+
groups.append(group)
|
|
528
|
+
return tuple(groups)
|
|
450
529
|
|
|
451
530
|
def _desktop_active(self) -> bool:
|
|
452
531
|
"""Desktop tools are advertised only when enabled AND runnable here."""
|
|
532
|
+
if not self._desktop_allowed():
|
|
533
|
+
return False
|
|
453
534
|
desktop = self.permissions.desktop
|
|
454
535
|
if desktop is None or not desktop.enabled:
|
|
455
536
|
return False
|
|
@@ -484,6 +565,14 @@ class AgentEngine(ChatEngine):
|
|
|
484
565
|
evidence ``incomplete`` instead of ending a Code Mode task.
|
|
485
566
|
"""
|
|
486
567
|
self.last_evidence = TurnEvidence()
|
|
568
|
+
# v9.1.1: understand the request before choosing anything. The intent
|
|
569
|
+
# decides which context and which tools this turn may use, so the agent
|
|
570
|
+
# never scans the workspace for a song or a browser task. Classification
|
|
571
|
+
# is deterministic, so re-classifying here agrees with a caller (the app
|
|
572
|
+
# layer) that already classified for its own escalation decision.
|
|
573
|
+
resolved = classify(user_text)
|
|
574
|
+
if resolved != self._intent:
|
|
575
|
+
self.set_intent(resolved)
|
|
487
576
|
self.add_user(user_text)
|
|
488
577
|
failures = 0
|
|
489
578
|
|
seedcode/core/chat.py
CHANGED
|
@@ -68,6 +68,11 @@ class ChatEngine:
|
|
|
68
68
|
self.failover_enabled = bool(failover)
|
|
69
69
|
#: ``(from_provider, to_provider)`` of the last failover, or None.
|
|
70
70
|
self.last_switch: tuple[str, str] | None = None
|
|
71
|
+
#: Why the last failover happened (v9.1.1). A provider is never
|
|
72
|
+
#: switched silently: the classified reason is recorded here and can be
|
|
73
|
+
#: surfaced in the status/report so the user always knows what changed
|
|
74
|
+
#: and why.
|
|
75
|
+
self.last_switch_reason: str = ""
|
|
71
76
|
# Build identity-aware system prompt with current provider + model
|
|
72
77
|
system_content = build_system_prompt(
|
|
73
78
|
provider_label(config.provider), config.model or "unspecified"
|
|
@@ -197,9 +202,9 @@ class ChatEngine:
|
|
|
197
202
|
# try another healthy configuration, unless output already
|
|
198
203
|
# streamed (retrying elsewhere would duplicate the reply).
|
|
199
204
|
switch = None if yielded else self._next_provider(tried)
|
|
200
|
-
health_mod.tracker().record_failure(provider_id, exc)
|
|
205
|
+
state = health_mod.tracker().record_failure(provider_id, exc).state
|
|
201
206
|
if switch is not None:
|
|
202
|
-
self._switch_provider(switch)
|
|
207
|
+
self._switch_provider(switch, reason=self._failover_reason(exc, state))
|
|
203
208
|
attempt = 0
|
|
204
209
|
continue
|
|
205
210
|
_log.error("request failed: %s", exc)
|
|
@@ -220,18 +225,37 @@ class ChatEngine:
|
|
|
220
225
|
_log.exception("failover evaluation failed")
|
|
221
226
|
return None
|
|
222
227
|
|
|
223
|
-
def
|
|
228
|
+
def _failover_reason(self, exc: BaseException, state) -> str:
|
|
229
|
+
"""A short, user-safe reason for a provider switch (v9.1.1).
|
|
230
|
+
|
|
231
|
+
Distinguishes the failure classes the user cares about (rate limit,
|
|
232
|
+
network, authentication, unavailable) without leaking any credential
|
|
233
|
+
or request detail.
|
|
234
|
+
"""
|
|
235
|
+
label = {
|
|
236
|
+
"rate_limited": "rate limited",
|
|
237
|
+
"authentication_error": "authentication rejected",
|
|
238
|
+
"offline": "network unreachable",
|
|
239
|
+
"temporarily_unavailable": "provider unavailable",
|
|
240
|
+
"retrying": "repeated transient errors",
|
|
241
|
+
"unknown": "request failed",
|
|
242
|
+
}.get(getattr(state, "value", str(state)), "request failed")
|
|
243
|
+
return label
|
|
244
|
+
|
|
245
|
+
def _switch_provider(self, candidate: Candidate, *, reason: str = "") -> None:
|
|
224
246
|
"""Switch to another provider while preserving all session state."""
|
|
225
247
|
previous = self.config.provider
|
|
226
248
|
self.config.provider = candidate.provider_id
|
|
227
249
|
self.config.model = candidate.model
|
|
228
250
|
self.last_switch = (previous, candidate.provider_id)
|
|
251
|
+
self.last_switch_reason = reason
|
|
229
252
|
_log.warning(
|
|
230
|
-
"switch provider %s -> %s (model %s)",
|
|
231
|
-
previous, candidate.provider_id, candidate.model,
|
|
253
|
+
"switch provider %s -> %s (model %s) reason=%s",
|
|
254
|
+
previous, candidate.provider_id, candidate.model, reason or "unspecified",
|
|
232
255
|
)
|
|
233
|
-
|
|
234
|
-
"
|
|
235
|
-
f"{provider_label(previous)} → "
|
|
236
|
-
f"{provider_label(candidate.provider_id)}",
|
|
256
|
+
detail = (
|
|
257
|
+
f"{provider_label(previous)} → {provider_label(candidate.provider_id)}"
|
|
237
258
|
)
|
|
259
|
+
if reason:
|
|
260
|
+
detail += f" ({reason})"
|
|
261
|
+
self.event("switching_provider", detail)
|