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.
- seedcode/__init__.py +14 -0
- seedcode/__main__.py +12 -0
- seedcode/app.py +508 -0
- seedcode/apps/__init__.py +32 -0
- seedcode/apps/discovery.py +241 -0
- seedcode/apps/installer.py +164 -0
- seedcode/apps/launcher.py +156 -0
- seedcode/apps/verifier.py +119 -0
- seedcode/assets/logo.txt +15 -0
- seedcode/cli.py +95 -0
- seedcode/commands/__init__.py +81 -0
- seedcode/commands/about.py +34 -0
- seedcode/commands/agent.py +94 -0
- seedcode/commands/assist.py +201 -0
- seedcode/commands/clear.py +20 -0
- seedcode/commands/desktop.py +104 -0
- seedcode/commands/doctor.py +152 -0
- seedcode/commands/help.py +61 -0
- seedcode/commands/history.py +365 -0
- seedcode/commands/palette.py +100 -0
- seedcode/commands/provider.py +451 -0
- seedcode/commands/theme.py +76 -0
- seedcode/computer/__init__.py +98 -0
- seedcode/computer/browser.py +276 -0
- seedcode/computer/browser_cdp.py +567 -0
- seedcode/computer/browser_engine.py +546 -0
- seedcode/computer/browser_extract.py +301 -0
- seedcode/computer/browser_popups.py +329 -0
- seedcode/computer/browser_selenium.py +209 -0
- seedcode/computer/browser_skills.py +245 -0
- seedcode/computer/catalog.py +200 -0
- seedcode/computer/controller.py +324 -0
- seedcode/computer/dispatcher.py +272 -0
- seedcode/computer/dpi.py +185 -0
- seedcode/computer/engine.py +105 -0
- seedcode/computer/keyboard.py +101 -0
- seedcode/computer/logbook.py +104 -0
- seedcode/computer/mouse.py +48 -0
- seedcode/computer/ocr.py +213 -0
- seedcode/computer/operator_skills.py +577 -0
- seedcode/computer/permissions.py +203 -0
- seedcode/computer/recovery.py +115 -0
- seedcode/computer/registry.py +107 -0
- seedcode/computer/resolver.py +434 -0
- seedcode/computer/screen.py +130 -0
- seedcode/computer/screen_state.py +412 -0
- seedcode/computer/selfguard.py +197 -0
- seedcode/computer/semantic.py +100 -0
- seedcode/computer/skills.py +139 -0
- seedcode/computer/state.py +199 -0
- seedcode/computer/verifier.py +177 -0
- seedcode/computer/vision.py +327 -0
- seedcode/computer/windows.py +217 -0
- seedcode/config/__init__.py +8 -0
- seedcode/config/defaults.py +22 -0
- seedcode/config/manager.py +62 -0
- seedcode/core/__init__.py +31 -0
- seedcode/core/agent.py +534 -0
- seedcode/core/chat.py +128 -0
- seedcode/core/client.py +9 -0
- seedcode/core/errors.py +199 -0
- seedcode/core/identity.py +66 -0
- seedcode/core/identity_store.py +119 -0
- seedcode/core/lifecycle.py +240 -0
- seedcode/core/limits.py +35 -0
- seedcode/core/models.py +347 -0
- seedcode/core/project.py +96 -0
- seedcode/core/providers/__init__.py +58 -0
- seedcode/core/providers/aerolink.py +324 -0
- seedcode/core/providers/base.py +230 -0
- seedcode/core/providers/freemodel.py +931 -0
- seedcode/core/providers/ollama.py +262 -0
- seedcode/core/providers/openrouter.py +393 -0
- seedcode/core/streaming.py +21 -0
- seedcode/memory/__init__.py +8 -0
- seedcode/memory/manager.py +47 -0
- seedcode/memory/storage.py +38 -0
- seedcode/memory/store.py +257 -0
- seedcode/tools/__init__.py +35 -0
- seedcode/tools/base.py +179 -0
- seedcode/tools/desktop.py +371 -0
- seedcode/tools/filesystem.py +309 -0
- seedcode/tools/git.py +72 -0
- seedcode/tools/patch.py +170 -0
- seedcode/tools/permissions.py +288 -0
- seedcode/tools/search.py +137 -0
- seedcode/tools/terminal.py +200 -0
- seedcode/tools/textio.py +59 -0
- seedcode/ui/__init__.py +164 -0
- seedcode/ui/badges.py +64 -0
- seedcode/ui/banner.py +78 -0
- seedcode/ui/dashboard.py +197 -0
- seedcode/ui/dialog.py +62 -0
- seedcode/ui/fuzzy.py +128 -0
- seedcode/ui/layout.py +54 -0
- seedcode/ui/menu.py +61 -0
- seedcode/ui/palette.py +40 -0
- seedcode/ui/progress.py +41 -0
- seedcode/ui/prompts.py +16 -0
- seedcode/ui/renderer.py +36 -0
- seedcode/ui/searchbox.py +70 -0
- seedcode/ui/selector.py +514 -0
- seedcode/ui/statusbar.py +38 -0
- seedcode/ui/textbox.py +61 -0
- seedcode/ui/theme.py +204 -0
- seedcode/ui/tree.py +91 -0
- seedcode/utils/__init__.py +22 -0
- seedcode/utils/helpers.py +97 -0
- seedcode/utils/logger.py +65 -0
- seedcode_cli-6.1.5.dist-info/METADATA +368 -0
- seedcode_cli-6.1.5.dist-info/RECORD +114 -0
- seedcode_cli-6.1.5.dist-info/WHEEL +4 -0
- seedcode_cli-6.1.5.dist-info/entry_points.txt +2 -0
- seedcode_cli-6.1.5.dist-info/licenses/LICENSE +21 -0
seedcode/core/errors.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Structured error taxonomy for the SeedCode operator stack.
|
|
2
|
+
|
|
3
|
+
Machine-readable errors are the contract between the deterministic layers
|
|
4
|
+
(screen, apps, web, memory) and the agent loop: every failure carries a
|
|
5
|
+
stable ``code``, a human-readable message, and whether a retry/recovery is
|
|
6
|
+
plausible. The planner reads ``code`` + ``recoverable``; the user reads
|
|
7
|
+
``message``. Nothing raises a bare string anywhere below the tool surface.
|
|
8
|
+
|
|
9
|
+
All operator errors derive from :class:`OperatorError` so callers can catch
|
|
10
|
+
the whole family; the historical leaf exceptions (``ComputerError``,
|
|
11
|
+
``BrowserError``, ``SkillError``...) keep their names via aliases where
|
|
12
|
+
existing code depends on them.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# NOTE: deliberately NOT ``slots=True``. A slotted dataclass replaces the
|
|
22
|
+
# class object, which breaks zero-arg ``super()`` inside ``__post_init__`` for
|
|
23
|
+
# Exception subclasses (``super(type, obj): obj must be an instance or subtype
|
|
24
|
+
# of type``) - every structured error would be unraisable.
|
|
25
|
+
@dataclass
|
|
26
|
+
class OperatorError(Exception):
|
|
27
|
+
"""Base class for every structured operator failure."""
|
|
28
|
+
|
|
29
|
+
code: str = "OPERATOR_ERROR"
|
|
30
|
+
message: str = ""
|
|
31
|
+
recoverable: bool = False
|
|
32
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
def __post_init__(self) -> None:
|
|
35
|
+
if not self.message:
|
|
36
|
+
object.__setattr__(self, "message", self.code)
|
|
37
|
+
# Explicit base call: cooperative super() is fragile across the
|
|
38
|
+
# dataclass/Exception metaclass boundary; Exception.__init__ sets args.
|
|
39
|
+
Exception.__init__(self, self.message)
|
|
40
|
+
|
|
41
|
+
def __str__(self) -> str: # the model-facing text
|
|
42
|
+
return self.message
|
|
43
|
+
|
|
44
|
+
def to_dict(self) -> dict[str, Any]:
|
|
45
|
+
"""Machine-readable form (planner / error handler / logs)."""
|
|
46
|
+
return {
|
|
47
|
+
"error_type": self.code,
|
|
48
|
+
"message": self.message,
|
|
49
|
+
"recoverable": self.recoverable,
|
|
50
|
+
**({"details": self.details} if self.details else {}),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# --- element / screen -----------------------------------------------------------
|
|
55
|
+
class ElementNotFoundError(OperatorError):
|
|
56
|
+
def __init__(self, message: str = "", **kw: Any) -> None:
|
|
57
|
+
super().__init__(
|
|
58
|
+
code="ELEMENT_NOT_FOUND", message=message or "Element not found on screen.",
|
|
59
|
+
recoverable=True, **kw,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class StaleElementError(OperatorError):
|
|
64
|
+
"""A cached element id no longer resolves — the UI changed under us."""
|
|
65
|
+
|
|
66
|
+
def __init__(self, element_id: str = "", **kw: Any) -> None:
|
|
67
|
+
super().__init__(
|
|
68
|
+
code="STALE_ELEMENT",
|
|
69
|
+
message=(
|
|
70
|
+
f"Element '{element_id or 'reference'}' is stale (the UI changed). "
|
|
71
|
+
"Re-run the screen query to get a fresh element id."
|
|
72
|
+
),
|
|
73
|
+
recoverable=True,
|
|
74
|
+
details={"element_id": element_id},
|
|
75
|
+
**kw,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ScreenUnavailableError(OperatorError):
|
|
80
|
+
def __init__(self, message: str = "", **kw: Any) -> None:
|
|
81
|
+
super().__init__(
|
|
82
|
+
code="SCREEN_UNAVAILABLE",
|
|
83
|
+
message=message or "Screen state is unavailable on this machine.",
|
|
84
|
+
recoverable=False, **kw,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# --- windows / applications -------------------------------------------------------
|
|
89
|
+
class WindowNotFoundError(OperatorError):
|
|
90
|
+
def __init__(self, message: str = "", **kw: Any) -> None:
|
|
91
|
+
super().__init__(
|
|
92
|
+
code="WINDOW_NOT_FOUND", message=message or "No matching window.",
|
|
93
|
+
recoverable=True, **kw,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class ApplicationNotFoundError(OperatorError):
|
|
98
|
+
def __init__(self, message: str = "", **kw: Any) -> None:
|
|
99
|
+
super().__init__(
|
|
100
|
+
code="APPLICATION_NOT_FOUND",
|
|
101
|
+
message=message or "Application not found on this machine.",
|
|
102
|
+
recoverable=True, **kw,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class ApplicationLaunchError(OperatorError):
|
|
107
|
+
def __init__(self, message: str = "", **kw: Any) -> None:
|
|
108
|
+
super().__init__(
|
|
109
|
+
code="APPLICATION_LAUNCH_FAILED",
|
|
110
|
+
message=message or "Application did not launch.",
|
|
111
|
+
recoverable=True, **kw,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# --- web ---------------------------------------------------------------------------
|
|
116
|
+
class NavigationError(OperatorError):
|
|
117
|
+
def __init__(self, message: str = "", **kw: Any) -> None:
|
|
118
|
+
super().__init__(
|
|
119
|
+
code="NAVIGATION_FAILED", message=message or "Navigation failed.",
|
|
120
|
+
recoverable=True, **kw,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ExtractionError(OperatorError):
|
|
125
|
+
def __init__(self, message: str = "", **kw: Any) -> None:
|
|
126
|
+
super().__init__(
|
|
127
|
+
code="EXTRACTION_FAILED",
|
|
128
|
+
message=message or "Page extraction failed.",
|
|
129
|
+
recoverable=True, **kw,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class WebPageNotConnectedError(OperatorError):
|
|
134
|
+
"""No DOM surface (no DevTools) for a requested web extraction."""
|
|
135
|
+
|
|
136
|
+
def __init__(self, message: str = "", **kw: Any) -> None:
|
|
137
|
+
super().__init__(
|
|
138
|
+
code="WEB_PAGE_NOT_CONNECTED",
|
|
139
|
+
message=message or (
|
|
140
|
+
"No DevTools connection to the browser; DOM extraction is "
|
|
141
|
+
"unavailable. Open the page via open_url first."
|
|
142
|
+
),
|
|
143
|
+
recoverable=True, **kw,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class DownloadBlockedError(OperatorError):
|
|
148
|
+
"""A download was refused by policy (type/size/host)."""
|
|
149
|
+
|
|
150
|
+
def __init__(self, message: str = "", **kw: Any) -> None:
|
|
151
|
+
super().__init__(
|
|
152
|
+
code="DOWNLOAD_BLOCKED", message=message or "Download refused by policy.",
|
|
153
|
+
recoverable=False, **kw,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# --- verification / lifecycle / security ---------------------------------------------
|
|
158
|
+
class VerificationError(OperatorError):
|
|
159
|
+
def __init__(self, message: str = "", **kw: Any) -> None:
|
|
160
|
+
super().__init__(
|
|
161
|
+
code="VERIFICATION_FAILED",
|
|
162
|
+
message=message or "The action did not have the expected effect.",
|
|
163
|
+
recoverable=True, **kw,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class SecurityError(OperatorError):
|
|
168
|
+
def __init__(self, message: str = "", **kw: Any) -> None:
|
|
169
|
+
super().__init__(
|
|
170
|
+
code="SECURITY_DENIED",
|
|
171
|
+
message=message or "The action was denied by policy.",
|
|
172
|
+
recoverable=False, **kw,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# --- historical aliases (existing modules keep their exception names) ---------------
|
|
177
|
+
# These subclass the structured base so both worlds interoperate.
|
|
178
|
+
from ..computer.controller import ComputerError as _ComputerError # noqa: E402
|
|
179
|
+
from ..computer.browser import BrowserError as _BrowserError # noqa: E402
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class ComputerError(OperatorError, _ComputerError): # type: ignore[misc]
|
|
183
|
+
"""Structured-compatible alias of the controller error."""
|
|
184
|
+
|
|
185
|
+
def __init__(self, message: str = "", **kw: Any) -> None:
|
|
186
|
+
OperatorError.__init__(
|
|
187
|
+
self, code="COMPUTER_ERROR", message=message, recoverable=True, **kw
|
|
188
|
+
)
|
|
189
|
+
Exception.__init__(self, self.message)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class BrowserError(OperatorError, _BrowserError): # type: ignore[misc]
|
|
193
|
+
"""Structured-compatible alias of the browser driver error."""
|
|
194
|
+
|
|
195
|
+
def __init__(self, message: str = "", **kw: Any) -> None:
|
|
196
|
+
OperatorError.__init__(
|
|
197
|
+
self, code="BROWSER_ERROR", message=message, recoverable=True, **kw
|
|
198
|
+
)
|
|
199
|
+
Exception.__init__(self, self.message)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Seed Code identity layer.
|
|
2
|
+
|
|
3
|
+
Constructs the system prompt that distinguishes the Seed Code application
|
|
4
|
+
identity from the underlying reasoning engine (Claude, GPT, etc.). Every
|
|
5
|
+
provider must call :func:`build_system_prompt` to prepend the identity layer
|
|
6
|
+
before any conversation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
# Centralized identity — edit this ONE place to change how Seed Code introduces itself
|
|
12
|
+
_SEED_CODE_IDENTITY = """You are Seed Code, an AI coding assistant created by Al Shahriar Sowan.
|
|
13
|
+
|
|
14
|
+
Your purpose is to help users write code, edit projects, debug software, automate development tasks, use local tools, and assist with software engineering.
|
|
15
|
+
|
|
16
|
+
**Important identity distinctions:**
|
|
17
|
+
|
|
18
|
+
- You are Seed Code (the application), not the underlying AI model
|
|
19
|
+
- The reasoning engine powering you may be Claude, GPT, Codex, Gemini, Qwen, or another model
|
|
20
|
+
- Always identify yourself as Seed Code first, then mention your current reasoning engine when relevant
|
|
21
|
+
- Never claim to BE Claude, ChatGPT, Gemini, etc. — those are reasoning engines, not your identity
|
|
22
|
+
|
|
23
|
+
**When asked about your identity:**
|
|
24
|
+
|
|
25
|
+
- "Who are you?" → "I am Seed Code, an AI coding assistant created by Al Shahriar Sowan. My current reasoning engine is [model]."
|
|
26
|
+
- "Who created you?" or "Who made you?" → "Seed Code was created by Al Shahriar Sowan."
|
|
27
|
+
- "What is Seed Code?" → "Seed Code is an AI coding assistant designed for software development, project automation, and intelligent coding workflows. It supports multiple AI providers while presenting a single unified Seed Code experience."
|
|
28
|
+
- "What model are you using?" → Be truthful: "My current reasoning engine is [model]."
|
|
29
|
+
- "Are you Claude?" or "Are you ChatGPT?" → "I'm Seed Code. For this conversation I'm powered by [model]."
|
|
30
|
+
- "Who owns Seed Code?" → "Seed Code is created and maintained by Al Shahriar Sowan."
|
|
31
|
+
|
|
32
|
+
Always be truthful about your reasoning engine while maintaining your Seed Code application identity."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_system_prompt(provider_label: str, model_id: str) -> str:
|
|
36
|
+
"""Build the complete system prompt with Seed Code identity + reasoning engine context.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
provider_label: Human-readable provider name (e.g., "OpenRouter", "FreeModel Claude")
|
|
40
|
+
model_id: The model identifier (e.g., "claude-opus-4-8", "gpt-5.5")
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Complete system prompt starting with Seed Code identity layer
|
|
44
|
+
|
|
45
|
+
Example:
|
|
46
|
+
>>> build_system_prompt("FreeModel Claude", "claude-sonnet-5")
|
|
47
|
+
'You are Seed Code...\\n\\nYour current reasoning engine: claude-sonnet-5 via FreeModel Claude...'
|
|
48
|
+
"""
|
|
49
|
+
reasoning_context = (
|
|
50
|
+
f"\n\nYour current reasoning engine: {model_id} via {provider_label}.\n"
|
|
51
|
+
f"When users ask about your model or capabilities, mention this truthfully."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Seed Code task instructions (provider-agnostic)
|
|
55
|
+
task_prompt = """
|
|
56
|
+
|
|
57
|
+
Be concise and professional. Prefer clear, correct code with short explanations.
|
|
58
|
+
Use markdown fenced code blocks with language hints. Focus on practical, working solutions."""
|
|
59
|
+
|
|
60
|
+
# Owner-persisted personality overrides (identity.json). Never model-
|
|
61
|
+
# written; a model switch re-renders only the reasoning-engine line.
|
|
62
|
+
from .identity_store import load_identity
|
|
63
|
+
|
|
64
|
+
overrides = load_identity().render()
|
|
65
|
+
|
|
66
|
+
return _SEED_CODE_IDENTITY + overrides + reasoning_context + task_prompt
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""SeedCode-owned identity: persistent personality independent of the model.
|
|
2
|
+
|
|
3
|
+
The identity layer (:mod:`.identity`) hardcodes the baseline personality.
|
|
4
|
+
This module lets the *owner* persist overrides in
|
|
5
|
+
``~/.seedcode/identity.json`` so personality belongs to SeedCode, not to
|
|
6
|
+
any model or prompt-in-flight:
|
|
7
|
+
|
|
8
|
+
.. code-block:: json
|
|
9
|
+
|
|
10
|
+
{
|
|
11
|
+
"identity": "You are SeedCode, ...",
|
|
12
|
+
"tone": "concise and professional",
|
|
13
|
+
"behavior_rules": ["Never claim unverified success."],
|
|
14
|
+
"interaction_style": ["Answer in the user's language."]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
Rules:
|
|
18
|
+
|
|
19
|
+
* Overrides are loaded at every prompt build (cheap file read, cached by
|
|
20
|
+
mtime), so a change takes effect on the next turn without a restart.
|
|
21
|
+
* The model can NEVER write this file: it is owner/user-edited
|
|
22
|
+
configuration. Nothing in the tool surface exposes it.
|
|
23
|
+
* Model/provider choice has no path into identity: switching models
|
|
24
|
+
re-renders only the "reasoning engine" line, produced by
|
|
25
|
+
:func:`build_system_prompt` from live config — personality constants are
|
|
26
|
+
untouched (pinned by tests).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import time
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from ..utils.helpers import app_dir
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class IdentityProfile:
|
|
42
|
+
"""The SeedCode-owned personality configuration."""
|
|
43
|
+
|
|
44
|
+
identity: str = "" # replaces the baseline identity block
|
|
45
|
+
tone: str = "" # appended as a tone directive
|
|
46
|
+
behavior_rules: list[str] = field(default_factory=list)
|
|
47
|
+
interaction_style: list[str] = field(default_factory=list)
|
|
48
|
+
|
|
49
|
+
def is_empty(self) -> bool:
|
|
50
|
+
return not (self.identity or self.tone or self.behavior_rules
|
|
51
|
+
or self.interaction_style)
|
|
52
|
+
|
|
53
|
+
def render(self) -> str:
|
|
54
|
+
"""The override text injected after the baseline identity."""
|
|
55
|
+
if self.is_empty():
|
|
56
|
+
return ""
|
|
57
|
+
lines: list[str] = []
|
|
58
|
+
if self.identity:
|
|
59
|
+
lines.append(self.identity.strip())
|
|
60
|
+
if self.tone:
|
|
61
|
+
lines.append(f"\nTone: {self.tone.strip()}")
|
|
62
|
+
if self.behavior_rules:
|
|
63
|
+
lines.append("\nBehavior rules:")
|
|
64
|
+
lines += [f"- {rule}" for rule in self.behavior_rules]
|
|
65
|
+
if self.interaction_style:
|
|
66
|
+
lines.append("\nInteraction style:")
|
|
67
|
+
lines += [f"- {item}" for item in self.interaction_style]
|
|
68
|
+
return "\n".join(lines).strip()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def identity_path() -> Path:
|
|
72
|
+
"""Where the owner's identity overrides live."""
|
|
73
|
+
return app_dir() / "identity.json"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load_identity() -> IdentityProfile:
|
|
77
|
+
"""Read ``identity.json`` (empty profile when absent/corrupt).
|
|
78
|
+
|
|
79
|
+
A corrupt file must never break startup: it degrades to the baseline
|
|
80
|
+
identity and the problem is logged once.
|
|
81
|
+
"""
|
|
82
|
+
path = identity_path()
|
|
83
|
+
try:
|
|
84
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
85
|
+
except (OSError, ValueError):
|
|
86
|
+
return IdentityProfile()
|
|
87
|
+
if not isinstance(data, dict):
|
|
88
|
+
return IdentityProfile()
|
|
89
|
+
rules = data.get("behavior_rules")
|
|
90
|
+
style = data.get("interaction_style")
|
|
91
|
+
return IdentityProfile(
|
|
92
|
+
identity=str(data.get("identity", "") or ""),
|
|
93
|
+
tone=str(data.get("tone", "") or ""),
|
|
94
|
+
behavior_rules=[str(r) for r in rules] if isinstance(rules, list) else [],
|
|
95
|
+
interaction_style=[str(s) for s in style] if isinstance(style, list) else [],
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def save_identity(profile: IdentityProfile) -> bool:
|
|
100
|
+
"""Persist the profile atomically; False when the disk refuses."""
|
|
101
|
+
path = identity_path()
|
|
102
|
+
payload = {
|
|
103
|
+
"identity": profile.identity,
|
|
104
|
+
"tone": profile.tone,
|
|
105
|
+
"behavior_rules": profile.behavior_rules,
|
|
106
|
+
"interaction_style": profile.interaction_style,
|
|
107
|
+
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
108
|
+
}
|
|
109
|
+
try:
|
|
110
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
tmp = path.with_suffix(".json.tmp")
|
|
112
|
+
tmp.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
113
|
+
tmp.replace(path)
|
|
114
|
+
return True
|
|
115
|
+
except OSError:
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
__all__ = ["IdentityProfile", "identity_path", "load_identity", "save_identity"]
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Agent lifecycle: a single, explicit state machine for the whole app.
|
|
2
|
+
|
|
3
|
+
The auto-exit bug was architectural, not cosmetic: nothing in the codebase
|
|
4
|
+
distinguished "a task finished" from "the application should terminate", so
|
|
5
|
+
termination could be triggered from deep inside a tool run (a window-close
|
|
6
|
+
that hit our own terminal, an unhandled exception escaping a turn, a cleanup
|
|
7
|
+
path that escalated). This module makes that structurally impossible:
|
|
8
|
+
|
|
9
|
+
* The REPL is always the owner of process lifetime. A turn is a *function
|
|
10
|
+
call* into the engine; whatever the turn does — succeed, fail, crash, get
|
|
11
|
+
cancelled — control returns to the prompt. Nothing below the REPL may end
|
|
12
|
+
the process.
|
|
13
|
+
* Shutdown is a *decision*, made in exactly one place: :func:`request_exit`,
|
|
14
|
+
called only when the user explicitly asks to quit (the /exit command, the
|
|
15
|
+
menu's Exit item, or Ctrl+D). No tool, task outcome, or error path can
|
|
16
|
+
reach it.
|
|
17
|
+
* The state machine records where the app is (IDLE → PLANNING → EXECUTING →
|
|
18
|
+
VERIFYING → RESPONDING → IDLE) and enforces that the only legal transition
|
|
19
|
+
out of the task cycle is back to IDLE. Entering SHUTDOWN is validated:
|
|
20
|
+
it is legal *only* from IDLE, and only when an exit was explicitly
|
|
21
|
+
requested. A stray attempt (a bug somewhere deep calling shutdown after a
|
|
22
|
+
task) raises :class:`LifecycleError` loudly instead of silently dying.
|
|
23
|
+
|
|
24
|
+
Keep this module dependency-free: everything in the app should be able to
|
|
25
|
+
import it.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import enum
|
|
31
|
+
import threading
|
|
32
|
+
from typing import Callable
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LifecycleError(RuntimeError):
|
|
36
|
+
"""An illegal lifecycle transition was attempted (a real bug somewhere)."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@enum.unique
|
|
40
|
+
class Phase(str, enum.Enum):
|
|
41
|
+
"""Where the application currently is in the task cycle."""
|
|
42
|
+
|
|
43
|
+
IDLE = "idle" # at the prompt, waiting for the user
|
|
44
|
+
PLANNING = "planning" # a turn started; the model is thinking
|
|
45
|
+
EXECUTING = "executing" # tools / desktop actions are running
|
|
46
|
+
VERIFYING = "verifying" # outcomes are being checked
|
|
47
|
+
RESPONDING = "responding" # the final answer is being produced
|
|
48
|
+
SHUTDOWN = "shutdown" # the app is exiting (terminal state)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Legal transitions. The task cycle is a strict forward chain that must end
|
|
52
|
+
# back at IDLE; IDLE may also go straight to SHUTDOWN (explicit exit).
|
|
53
|
+
_ALLOWED: dict[Phase, frozenset[Phase]] = {
|
|
54
|
+
Phase.IDLE: frozenset({Phase.PLANNING, Phase.SHUTDOWN}),
|
|
55
|
+
Phase.PLANNING: frozenset({Phase.EXECUTING, Phase.IDLE}),
|
|
56
|
+
Phase.EXECUTING: frozenset({Phase.VERIFYING, Phase.IDLE}),
|
|
57
|
+
Phase.VERIFYING: frozenset({Phase.RESPONDING, Phase.IDLE}),
|
|
58
|
+
Phase.RESPONDING: frozenset({Phase.IDLE}), # NEVER SHUTDOWN from here
|
|
59
|
+
# SHUTDOWN is terminal: no transitions out (validated implicitly because
|
|
60
|
+
# nothing lists SHUTDOWN as a source).
|
|
61
|
+
Phase.SHUTDOWN: frozenset(),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# Phases a user turn passes through, in order.
|
|
65
|
+
_TASK_PATH = (Phase.PLANNING, Phase.EXECUTING, Phase.VERIFYING, Phase.RESPONDING)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Lifecycle:
|
|
69
|
+
"""The app-wide lifecycle state machine (one instance per process).
|
|
70
|
+
|
|
71
|
+
The REPL advances it around each user turn; the exit decision is the only
|
|
72
|
+
path to SHUTDOWN. Thread-safe because desktop work can run on helper
|
|
73
|
+
threads.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
def __init__(self) -> None:
|
|
77
|
+
self._phase = Phase.IDLE
|
|
78
|
+
# Set only by :meth:`request_exit` — the explicit user decision.
|
|
79
|
+
self._exit_requested = False
|
|
80
|
+
self._lock = threading.RLock()
|
|
81
|
+
self._on_shutdown: list[Callable[[], None]] = []
|
|
82
|
+
|
|
83
|
+
# --- introspection -------------------------------------------------------
|
|
84
|
+
@property
|
|
85
|
+
def phase(self) -> Phase:
|
|
86
|
+
with self._lock:
|
|
87
|
+
return self._phase
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def exit_requested(self) -> bool:
|
|
91
|
+
"""Whether the user has explicitly asked to quit."""
|
|
92
|
+
with self._lock:
|
|
93
|
+
return self._exit_requested
|
|
94
|
+
|
|
95
|
+
def is_running(self) -> bool:
|
|
96
|
+
"""Whether the app should keep running (the REPL's loop condition)."""
|
|
97
|
+
with self._lock:
|
|
98
|
+
return not self._exit_requested and self._phase is not Phase.SHUTDOWN
|
|
99
|
+
|
|
100
|
+
# --- turn transitions ----------------------------------------------------
|
|
101
|
+
def begin_turn(self) -> None:
|
|
102
|
+
"""IDLE → PLANNING. A user turn starts (raises unless at IDLE)."""
|
|
103
|
+
with self._lock:
|
|
104
|
+
self._transition(Phase.PLANNING)
|
|
105
|
+
|
|
106
|
+
def to_executing(self) -> None:
|
|
107
|
+
with self._lock:
|
|
108
|
+
self._transition(Phase.EXECUTING)
|
|
109
|
+
|
|
110
|
+
def to_verifying(self) -> None:
|
|
111
|
+
with self._lock:
|
|
112
|
+
self._transition(Phase.VERIFYING)
|
|
113
|
+
|
|
114
|
+
def to_responding(self) -> None:
|
|
115
|
+
with self._lock:
|
|
116
|
+
self._transition(Phase.RESPONDING)
|
|
117
|
+
|
|
118
|
+
def end_turn(self) -> None:
|
|
119
|
+
"""Whatever phase the turn reached → IDLE. Always call in ``finally``.
|
|
120
|
+
|
|
121
|
+
Every path out of a turn — success, tool failure, provider error,
|
|
122
|
+
cancellation, even a crash inside the engine — funnels here, which is
|
|
123
|
+
precisely the guarantee the auto-exit bug violated.
|
|
124
|
+
"""
|
|
125
|
+
with self._lock:
|
|
126
|
+
if self._phase is Phase.IDLE:
|
|
127
|
+
return # a nested/sub-turn already closed out
|
|
128
|
+
self._transition(Phase.IDLE)
|
|
129
|
+
|
|
130
|
+
# --- the one exit path ---------------------------------------------------
|
|
131
|
+
def request_exit(self, reason: str = "") -> None:
|
|
132
|
+
"""Record the user's explicit decision to quit.
|
|
133
|
+
|
|
134
|
+
Only interactive, unambiguous user actions may call this: the /exit
|
|
135
|
+
command, the menu's Exit item, or Ctrl+D at the prompt. A task's
|
|
136
|
+
completion — however it ends — must never reach this method.
|
|
137
|
+
"""
|
|
138
|
+
with self._lock:
|
|
139
|
+
self._exit_requested = True
|
|
140
|
+
if reason:
|
|
141
|
+
import logging
|
|
142
|
+
|
|
143
|
+
logging.getLogger("seedcode.lifecycle").info(
|
|
144
|
+
"exit requested: %s", reason
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def shutdown(self) -> None:
|
|
148
|
+
"""Enter SHUTDOWN and run registered teardown hooks.
|
|
149
|
+
|
|
150
|
+
Validates the transition: legal only from IDLE with an explicit exit
|
|
151
|
+
request. A cleanup path or tool that tries to shut the app down after
|
|
152
|
+
(or during) a task fails loudly instead of killing the process.
|
|
153
|
+
"""
|
|
154
|
+
with self._lock:
|
|
155
|
+
if self._phase is Phase.SHUTDOWN:
|
|
156
|
+
return
|
|
157
|
+
if self._phase is not Phase.IDLE:
|
|
158
|
+
raise LifecycleError(
|
|
159
|
+
f"shutdown attempted from {self._phase.value} — a task is "
|
|
160
|
+
"still running. Shutdown is only legal from IDLE after an "
|
|
161
|
+
"explicit exit request."
|
|
162
|
+
)
|
|
163
|
+
if not self._exit_requested:
|
|
164
|
+
raise LifecycleError(
|
|
165
|
+
"shutdown attempted without an explicit exit request — "
|
|
166
|
+
"normal task completion must never terminate the app."
|
|
167
|
+
)
|
|
168
|
+
self._transition(Phase.SHUTDOWN)
|
|
169
|
+
hooks = list(self._on_shutdown)
|
|
170
|
+
for hook in hooks:
|
|
171
|
+
try:
|
|
172
|
+
hook()
|
|
173
|
+
except Exception:
|
|
174
|
+
pass # teardown is best-effort by contract
|
|
175
|
+
|
|
176
|
+
def on_shutdown(self, hook: Callable[[], None]) -> None:
|
|
177
|
+
"""Register a best-effort teardown hook (flush logs, save state...)."""
|
|
178
|
+
with self._lock:
|
|
179
|
+
self._on_shutdown.append(hook)
|
|
180
|
+
|
|
181
|
+
def task_span(self) -> "_TaskSpan":
|
|
182
|
+
"""Context manager for one full user turn: begin → … → end, always.
|
|
183
|
+
|
|
184
|
+
Usage in the REPL::
|
|
185
|
+
|
|
186
|
+
with lifecycle().task_span():
|
|
187
|
+
run_the_whole_turn()
|
|
188
|
+
|
|
189
|
+
Whatever the turn does — succeed, fail, raise, get cancelled — the
|
|
190
|
+
``finally`` returns the machine to IDLE so the REPL prompts again.
|
|
191
|
+
"""
|
|
192
|
+
return _TaskSpan(self)
|
|
193
|
+
|
|
194
|
+
# --- internals -----------------------------------------------------------
|
|
195
|
+
def _transition(self, target: Phase) -> None:
|
|
196
|
+
if target not in _ALLOWED[self._phase]:
|
|
197
|
+
raise LifecycleError(
|
|
198
|
+
f"Illegal lifecycle transition {self._phase.value} -> "
|
|
199
|
+
f"{target.value}. Legal: "
|
|
200
|
+
f"{', '.join(p.value for p in _ALLOWED[self._phase])}."
|
|
201
|
+
)
|
|
202
|
+
self._phase = target
|
|
203
|
+
|
|
204
|
+
def reset_for_tests(self) -> None:
|
|
205
|
+
"""Back to IDLE with no exit request (test isolation only)."""
|
|
206
|
+
with self._lock:
|
|
207
|
+
self._phase = Phase.IDLE
|
|
208
|
+
self._exit_requested = False
|
|
209
|
+
self._on_shutdown.clear()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# The process-wide lifecycle. The REPL owns it; everything else may read it.
|
|
213
|
+
_lifecycle = Lifecycle()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def lifecycle() -> Lifecycle:
|
|
217
|
+
"""The process-wide :class:`Lifecycle` instance."""
|
|
218
|
+
return _lifecycle
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class _TaskSpan:
|
|
222
|
+
"""One full user turn on a :class:`Lifecycle` (see :meth:`Lifecycle.task_span`)."""
|
|
223
|
+
|
|
224
|
+
def __init__(self, lc: Lifecycle) -> None:
|
|
225
|
+
self._lc = lc
|
|
226
|
+
|
|
227
|
+
def __enter__(self) -> "_TaskSpan":
|
|
228
|
+
self._lc.begin_turn()
|
|
229
|
+
return self
|
|
230
|
+
|
|
231
|
+
def __exit__(self, exc_type, exc, tb) -> bool:
|
|
232
|
+
# Swallow nothing: the exception (if any) propagates to the REPL's
|
|
233
|
+
# own handler; the lifecycle just guarantees the return to IDLE.
|
|
234
|
+
self._lc.end_turn()
|
|
235
|
+
return False
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def task_span() -> "_TaskSpan":
|
|
239
|
+
"""One full user turn on the process-wide lifecycle."""
|
|
240
|
+
return _lifecycle.task_span()
|
seedcode/core/limits.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Central retry/step limits for the operator stack.
|
|
2
|
+
|
|
3
|
+
One module owns every bound so a confused loop can never spin forever and
|
|
4
|
+
the numbers are tunable in one place:
|
|
5
|
+
|
|
6
|
+
* ``MAX_ACTION_RETRIES`` — one action re-tried after failure
|
|
7
|
+
* ``MAX_RECOVERY_ATTEMPTS`` — recovery-strategy applications per failure
|
|
8
|
+
* ``MAX_STALE_REQUERY`` — stale-element re-query cycles per action
|
|
9
|
+
* ``MAX_PLAN_STEPS`` — planner steps per goal (the agent loop has its
|
|
10
|
+
own unrelated ``MAX_STEPS`` for tool calls; this bounds plan expansion)
|
|
11
|
+
* ``MAX_TASK_DURATION_S`` — wall-clock ceiling for one orchestrated task
|
|
12
|
+
* ``MAX_WAIT_FOR_*`` — state-based wait ceilings (no blind sleeps)
|
|
13
|
+
|
|
14
|
+
All values are plain ints/floats; a config layer may clamp them downward but
|
|
15
|
+
the defaults are the safety floor.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
MAX_ACTION_RETRIES = 2
|
|
21
|
+
MAX_RECOVERY_ATTEMPTS = 2
|
|
22
|
+
MAX_STALE_REQUERY = 2
|
|
23
|
+
MAX_PLAN_STEPS = 12
|
|
24
|
+
MAX_TASK_DURATION_S = 300.0
|
|
25
|
+
|
|
26
|
+
# State-based waits (used instead of blind sleeps everywhere).
|
|
27
|
+
MAX_WAIT_WINDOW_S = 10.0 # a window/app to appear
|
|
28
|
+
MAX_WAIT_ELEMENT_S = 8.0 # a UI/DOM element to appear
|
|
29
|
+
MAX_WAIT_NAVIGATION_S = 15.0 # a page navigation to settle
|
|
30
|
+
MAX_WAIT_POLL_S = 0.25 # poll cadence for state waits
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def clamp_wait(seconds: float, ceiling: float) -> float:
|
|
34
|
+
"""Clamp a wait to [0, ceiling]; negative becomes 0."""
|
|
35
|
+
return max(0.0, min(float(seconds), ceiling))
|