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 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.0"
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.0 artifact).
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 _handle_chat(ui: UI, engine: ChatEngine, history: HistoryStore, text: str) -> None:
249
- """Send a user turn to the model and stream the reply to screen."""
250
- engine.add_user(text)
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
- """Dangerous-action gate wired to the interactive permission dialog.
933
+ """The session-scoped dangerous-action gate (created once per process).
880
934
 
881
- Note: the Assist engine (and thus this gate) is rebuilt on permission-mode
882
- changes, so session "Always" grants reset then — conservative on purpose.
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
- return ActionGate(confirm=confirm)
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
- if config.agent_mode:
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
- _handle_chat(self.ui, self.engine, self.history, text)
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.
@@ -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 += self._terminal_context()
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
- return (
357
- base_identity
358
- + preamble
359
- + instructions
360
- + desktop_section
361
- + self._project_context()
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
- return ("core", "desktop") if self._desktop_active() else ("core",)
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 _switch_provider(self, candidate: Candidate) -> None:
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
- self.event(
234
- "switching_provider",
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)