Ltera-ai 1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Lterax/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """
2
+ Ltera - An agent that works on your behalf.
3
+
4
+ Ltera is an open-source, pip-installable agent that works on your behalf, it uses an iterative agent loop to
5
+ accomplish tasks.
6
+ """
7
+
8
+ __version__ = "1.0"
9
+
10
+ from Lterax.agent import Agent
11
+
12
+ __all__ = ["Agent", "__version__"]
Lterax/__main__.py ADDED
@@ -0,0 +1,261 @@
1
+ """
2
+ CLI entry point for Ltera.
3
+
4
+ Supports two modes:
5
+
6
+ 1. **Single-shot**: ``python -m Ltera "your task"``
7
+ 2. **Interactive REPL**: ``python -m Ltera`` (no task argument)
8
+
9
+ All configuration can be set via CLI flags or environment variables.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import logging
16
+ import sys
17
+ import os
18
+ from importlib.metadata import version, PackageNotFoundError
19
+
20
+ from Lterax import __version__
21
+ from Lterax.agent import Agent, AgentError, MaxIterationsError
22
+ from Lterax.config import Config
23
+
24
+
25
+ _RESET = "\033[0m"
26
+ _BOLD = "\033[1m"
27
+ _DIM = "\033[2m"
28
+ _ITALIC = "\033[3m"
29
+
30
+ # Foreground colours
31
+ _WHITE = "\033[97m"
32
+ _CYAN = "\033[96m" # bright cyan
33
+ _BLUE = "\033[94m" # bright blue
34
+ _GREEN = "\033[92m" # bright green
35
+ _YELLOW = "\033[93m" # bright yellow
36
+ _RED = "\033[91m" # bright red
37
+ _MAGENTA = "\033[95m" # bright magenta
38
+ _GRAY = "\033[90m" # dark gray
39
+
40
+
41
+ def _supports_color() -> bool:
42
+ return sys.stderr.isatty() and "NO_COLOR" not in os.environ
43
+
44
+
45
+ def _c(text: str, *styles: str) -> str:
46
+ """Apply ANSI styles only when the terminal supports them."""
47
+ if not _supports_color():
48
+ return text
49
+ return f"{''.join(styles)}{text}{_RESET}"
50
+
51
+
52
+ def _installed_version() -> str:
53
+ try:
54
+ return version("Ltera")
55
+ except PackageNotFoundError:
56
+ return __version__
57
+
58
+ def main(argv: list | None = None) -> int:
59
+ """Main entry point for the Ltera CLI.
60
+
61
+ Returns 0 on success, 1 on error.
62
+ """
63
+ parser = argparse.ArgumentParser(
64
+ prog="Ltera",
65
+ description=(
66
+ "Ltera — an autonomous coding agent. "
67
+ "Give it a task and it will read, write, and explore files "
68
+ "to accomplish it."
69
+ ),
70
+ formatter_class=argparse.RawDescriptionHelpFormatter,
71
+ epilog=(
72
+ "examples:\n"
73
+ ' Ltera "List all Python files"\n'
74
+ ' Ltera --model gemini-3.5-flash-thinking "Explain main.py"\n'
75
+ " Ltera # starts interactive mode\n"
76
+ ),
77
+ )
78
+
79
+ parser.add_argument(
80
+ "task",
81
+ nargs="?",
82
+ default=None,
83
+ help="Task to execute. Omit for interactive mode.",
84
+ )
85
+ parser.add_argument(
86
+ "--base-url",
87
+ default=None,
88
+ help=(
89
+ "OpenAI-compatible API base URL. "
90
+ "Default: http://localhost:8081/v1 (env: LTERA_BASE_URL)"
91
+ ),
92
+ )
93
+ parser.add_argument(
94
+ "--model",
95
+ default=None,
96
+ help="Model name. Default: gemini-3.5-flash (env: LTERA_MODEL)",
97
+ )
98
+ parser.add_argument(
99
+ "--verbose", "-v",
100
+ action="store_true",
101
+ default=None,
102
+ help="Enable verbose/debug logging",
103
+ )
104
+ parser.add_argument(
105
+ "--version", "-V",
106
+ action="version",
107
+ version=f"Ltera {__version__}",
108
+ )
109
+
110
+ args = parser.parse_args(argv)
111
+
112
+ config_overrides = {}
113
+ if args.base_url is not None:
114
+ config_overrides["base_url"] = args.base_url
115
+ if args.model is not None:
116
+ config_overrides["model"] = args.model
117
+ if args.verbose is not None and args.verbose:
118
+ config_overrides["verbose"] = True
119
+
120
+ config = Config(**config_overrides, _explicit=set(config_overrides.keys()))
121
+
122
+ # -- Set up logging --
123
+ if config.verbose:
124
+ logging.basicConfig(
125
+ level=logging.DEBUG,
126
+ format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
127
+ stream=sys.stderr,
128
+ )
129
+ else:
130
+ logging.basicConfig(
131
+ level=logging.WARNING,
132
+ format="%(message)s",
133
+ stream=sys.stderr,
134
+ )
135
+
136
+ # -- Start built-in server if requested --
137
+ if config.base_url == "auto":
138
+ from Lterax.server import start_background_server
139
+ if config.verbose:
140
+ sys.stderr.write("Starting built-in gemini-web2api backend...\n")
141
+ new_url = start_background_server()
142
+ config.base_url = new_url
143
+
144
+ # -- Print banner --
145
+ _print_banner(config)
146
+
147
+ # -- Create agent --
148
+ try:
149
+ agent = Agent(config)
150
+ except Exception as exc:
151
+ sys.stderr.write(f"\n✗ Failed to initialise agent: {exc}\n")
152
+ return 1
153
+
154
+ # -- Single-shot or interactive --
155
+ if args.task:
156
+ return _run_single(agent, args.task)
157
+ else:
158
+ return _run_interactive(agent)
159
+
160
+
161
+ def _run_single(agent: Agent, task: str) -> int:
162
+ """Run a single task and exit."""
163
+ try:
164
+ answer = agent.run(task)
165
+ print(f"\n{answer}")
166
+ return 0
167
+ except MaxIterationsError as exc:
168
+ sys.stderr.write(f"\n✗ {exc}\n")
169
+ return 1
170
+ except AgentError as exc:
171
+ sys.stderr.write(f"\n✗ Agent error: {exc}\n")
172
+ return 1
173
+ except KeyboardInterrupt:
174
+ sys.stderr.write("\n\nInterrupted.\n")
175
+ return 130
176
+
177
+
178
+ def _run_interactive(agent: Agent) -> int:
179
+ """Run an interactive REPL loop."""
180
+ sys.stderr.write(
181
+ "\nInteractive mode — type your task and press Enter.\n"
182
+ "Type 'quit' or 'exit' to leave. Ctrl+C to cancel a task.\n\n"
183
+ )
184
+
185
+ while True:
186
+ try:
187
+ task = input("Ltera > ").strip()
188
+ except (EOFError, KeyboardInterrupt):
189
+ print("\nBye!")
190
+ return 0
191
+
192
+ if not task:
193
+ continue
194
+ if task.lower() in ("quit", "exit", "q"):
195
+ print("Tschau!")
196
+ return 0
197
+
198
+ try:
199
+ answer = agent.run(task)
200
+ print(f"\n{answer}\n")
201
+ except MaxIterationsError as exc:
202
+ sys.stderr.write(f"\n✗ {exc}\n\n")
203
+ except AgentError as exc:
204
+ sys.stderr.write(f"\n✗ Agent error: {exc}\n\n")
205
+ except KeyboardInterrupt:
206
+ sys.stderr.write("\n\nTask cancelled.\n\n")
207
+
208
+
209
+ import sys
210
+ from pathlib import Path
211
+
212
+ _BANNER = r"""
213
+
214
+ ooooo .
215
+ `888' .o8
216
+ 888 .o888oo .ooooo. oooo d8b .oooo.
217
+ 888 888 d88' `88b `888""8P `P )88b
218
+ 888 888 888ooo888 888 .oP"888
219
+ 888 o 888 . 888 .o 888 d8( 888
220
+ o888ooooood8 "888" `Y8bod8P' d888b `Y888""8o
221
+
222
+ """
223
+
224
+
225
+ def _print_banner(config) -> None:
226
+ ver = _installed_version()
227
+
228
+ # ---- ASCII banner ----
229
+ if _supports_color():
230
+ lines = _BANNER.splitlines()
231
+ colors = [_CYAN, _CYAN, _BLUE, _BLUE, _MAGENTA, _MAGENTA, _BLUE]
232
+
233
+ for i, line in enumerate(lines):
234
+ col = colors[i] if i < len(colors) else _CYAN
235
+ print(_c(line, col, _BOLD), file=sys.stderr)
236
+ else:
237
+ print(_BANNER, file=sys.stderr)
238
+
239
+ # ---- Tagline row ----
240
+ tag = _c(" is a Simple AI Agent (without any API Keys)", _DIM)
241
+ ver_badge = _c(f" v{ver} ", _BOLD, _CYAN)
242
+ by = _c(" by Abodx9", _YELLOW)
243
+
244
+ print(f"{tag}{ver_badge}{by}", file=sys.stderr)
245
+
246
+ # ---- Runtime info ----
247
+ model = f" {_c('Model:', _BLUE)} {_c(config.model, _WHITE, _BOLD)}"
248
+ backend = f" {_c('Backend:', _RED)} {_c(config.base_url, _WHITE, _BOLD)}"
249
+ root = f" {_c('Root:', _GREEN)} {_c(str(Path.cwd()), _WHITE, _BOLD)}"
250
+
251
+ print(model, file=sys.stderr)
252
+ print(backend, file=sys.stderr)
253
+ print(root, file=sys.stderr)
254
+
255
+ # ---- Separator ----
256
+ print(_c(" " + "─" * 52, _GRAY), file=sys.stderr)
257
+ print(file=sys.stderr)
258
+
259
+
260
+ if __name__ == "__main__":
261
+ sys.exit(main())
Lterax/agent.py ADDED
@@ -0,0 +1,224 @@
1
+ """
2
+ Agent loop for Ltera.
3
+
4
+ Usage::
5
+
6
+ from Ltera.agent import Agent
7
+ from Ltera.config import Config
8
+
9
+ agent = Agent(Config(project_root="."))
10
+ answer = agent.run("List all Python files in this project")
11
+ print(answer)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ import re
19
+ import sys
20
+ from typing import Any, Dict, List, Optional
21
+ from pathlib import Path
22
+
23
+ from Lterax.config import Config
24
+ from Lterax.llm import LLMClient, LLMError
25
+ from Lterax.prompts import build_system_prompt
26
+ from Lterax.sandbox import Sandbox, SandboxViolation
27
+ from Lterax.tools import ToolError, ToolRegistry, default_registry
28
+
29
+ logger = logging.getLogger("Ltera.agent")
30
+
31
+ class MaxIterationsError(Exception):
32
+ """Raised when the agent loop exceeds the configured iteration limit."""
33
+
34
+
35
+ class AgentError(Exception):
36
+ """General agent error."""
37
+
38
+ class Agent:
39
+ """Iterative tool-calling agent. """
40
+
41
+ def __init__(
42
+ self,
43
+ config: Optional[Config] = None,
44
+ registry: Optional[ToolRegistry] = None,
45
+ ) -> None:
46
+ self._config = config or Config()
47
+ self._registry = registry or default_registry
48
+
49
+ self._llm = LLMClient(
50
+ base_url=self._config.base_url,
51
+ model=self._config.model,
52
+ timeout=self._config.timeout,
53
+ )
54
+
55
+ self._sandbox = Sandbox(Path.cwd())
56
+
57
+ # Build the system prompt once (tools don't change mid-session).
58
+ self._system_prompt = build_system_prompt(
59
+ self._registry.list_tools()
60
+ )
61
+
62
+
63
+
64
+ def run(self, task: str) -> str:
65
+ """Execute *task* and return the agent's final answer. """
66
+ messages: List[Dict[str, Any]] = [
67
+ {"role": "system", "content": self._system_prompt},
68
+ {"role": "user", "content": task},
69
+ ]
70
+
71
+ max_iter = 7
72
+ consecutive_errors = 0
73
+ max_consecutive_errors = 3
74
+
75
+ for iteration in range(1, max_iter + 1):
76
+
77
+ try:
78
+ raw_reply = self._llm.chat(messages)
79
+ except LLMError as exc:
80
+ raise AgentError(f"LLM communication failed: {exc}")
81
+
82
+ if self._config.verbose:
83
+ logger.info("LLM reply: %s", raw_reply[:500])
84
+
85
+ action, parse_error = self._parse_action(raw_reply)
86
+
87
+ if parse_error is not None:
88
+ consecutive_errors += 1
89
+ if consecutive_errors >= max_consecutive_errors:
90
+ raise AgentError(
91
+ f"LLM failed to produce valid JSON "
92
+ f"{max_consecutive_errors} times in a row. "
93
+ f"Last response: {raw_reply[:300]}"
94
+ )
95
+ # Ask the LLM to fix its output.
96
+ messages.append({"role": "assistant", "content": raw_reply})
97
+ messages.append({
98
+ "role": "user",
99
+ "content": (
100
+ f"Error: {parse_error}\n\n"
101
+ "Please respond with a single valid JSON object. "
102
+ "Do NOT use markdown code fences."
103
+ ),
104
+ })
105
+ self._print_status("⚠ Parse error, asking LLM to retry...")
106
+ continue
107
+
108
+ consecutive_errors = 0
109
+
110
+ if action["action"] == "final":
111
+ answer = action.get("answer", "")
112
+ return answer
113
+
114
+ tool_name = action["action"]
115
+ self._print_status(f"⚙ {tool_name}({self._summarise_args(action)})")
116
+
117
+ try:
118
+ result = self._registry.execute(
119
+ tool_name, action, self._sandbox
120
+ )
121
+ except (ToolError, SandboxViolation) as exc:
122
+ result = f"Error: {exc}"
123
+ self._print_status(f" ✗ {result}")
124
+ except Exception as exc:
125
+ result = f"Unexpected error: {type(exc).__name__}: {exc}"
126
+ self._print_status(f" ✗ {result}")
127
+ logger.exception("Unexpected tool error")
128
+
129
+ messages.append({"role": "assistant", "content": raw_reply})
130
+ messages.append({
131
+ "role": "user",
132
+ "content": f"Tool result for `{tool_name}`:\n{result}",
133
+ })
134
+
135
+ # Exceeded iteration limit.
136
+ raise MaxIterationsError(
137
+ f"Agent did not finish within {max_iter} iterations."
138
+ )
139
+
140
+ @property
141
+ def config(self) -> Config:
142
+ """The agent's configuration."""
143
+ return self._config
144
+
145
+ @property
146
+ def registry(self) -> ToolRegistry:
147
+ """The agent's tool registry."""
148
+ return self._registry
149
+
150
+ def _parse_action(self, raw: str) -> tuple:
151
+ """Attempt to parse the LLM's response as a JSON action.
152
+
153
+ Returns ``(action_dict, None)`` on success, or
154
+ ``(None, error_message)`` on failure.
155
+ """
156
+ text = raw.strip()
157
+
158
+ # Strategy 1: Direct JSON parse.
159
+ action = self._try_parse_json(text)
160
+ if action is not None:
161
+ return self._validate_action(action)
162
+
163
+ # Strategy 2: Extract JSON from markdown code fences.
164
+ # ```json\n{...}\n``` or ```\n{...}\n```
165
+ fence_pattern = r"```(?:json)?\s*\n(\{.*?\})\s*\n```"
166
+ match = re.search(fence_pattern, text, re.DOTALL)
167
+ if match:
168
+ action = self._try_parse_json(match.group(1))
169
+ if action is not None:
170
+ return self._validate_action(action)
171
+
172
+ # Strategy 3: Find the first { ... } block.
173
+ brace_match = re.search(r"\{.*\}", text, re.DOTALL)
174
+ if brace_match:
175
+ action = self._try_parse_json(brace_match.group(0))
176
+ if action is not None:
177
+ return self._validate_action(action)
178
+
179
+ return None, (
180
+ "Could not parse your response as JSON. "
181
+ "Please respond with a single JSON object like: "
182
+ '{"action": "tool_name", ...} or {"action": "final", "answer": "..."}'
183
+ )
184
+
185
+ @staticmethod
186
+ def _try_parse_json(text: str) -> Optional[Dict[str, Any]]:
187
+ """Try to parse *text* as JSON. Return None on failure."""
188
+ try:
189
+ data = json.loads(text)
190
+ if isinstance(data, dict):
191
+ return data
192
+ except (json.JSONDecodeError, ValueError):
193
+ pass
194
+ return None
195
+
196
+ @staticmethod
197
+ def _validate_action(action: Dict[str, Any]) -> tuple:
198
+ """Check that *action* has the required ``"action"`` key."""
199
+ if "action" not in action:
200
+ return None, (
201
+ 'JSON object is missing the required "action" key. '
202
+ 'Every response must include "action".'
203
+ )
204
+ return action, None
205
+
206
+ @staticmethod
207
+ def _summarise_args(action: Dict[str, Any]) -> str:
208
+ """Create a short summary of tool arguments for display."""
209
+ skip = {"action"}
210
+ parts = []
211
+ for k, v in action.items():
212
+ if k in skip:
213
+ continue
214
+ s = str(v)
215
+ if len(s) > 60:
216
+ s = s[:57] + "..."
217
+ parts.append(f"{k}={s!r}")
218
+ return ", ".join(parts)
219
+
220
+ @staticmethod
221
+ def _print_status(message: str) -> None:
222
+ """Print a status line to stderr."""
223
+ sys.stderr.write(f" {message}\n")
224
+ sys.stderr.flush()
Lterax/config.py ADDED
@@ -0,0 +1,75 @@
1
+ """
2
+ Configuration management for Ltera.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import os
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Any, Dict
12
+
13
+
14
+ _DEFAULTS: Dict[str, Any] = {
15
+ "base_url": "auto",
16
+ "model": "gemini-3.5-flash",
17
+ "timeout": 120,
18
+ "verbose": False,
19
+ }
20
+
21
+ # Maps environment variable names to config keys.
22
+ _ENV_MAP: Dict[str, str] = {
23
+ "LTERA_BASE_URL": "base_url",
24
+ "LTERA_MODEL": "model",
25
+ "LTERA_TIMEOUT": "timeout",
26
+ "LTERA_VERBOSE": "verbose",
27
+ }
28
+
29
+ # Keys that should be coerced to int.
30
+ _INT_KEYS = {"timeout"}
31
+
32
+ # Keys that should be coerced to bool.
33
+ _BOOL_KEYS = {"verbose"}
34
+
35
+ @dataclass
36
+ class Config:
37
+ """Immutable-ish configuration container for a Ltera session.
38
+
39
+ All fields have sensible defaults. Override via constructor kwargs,
40
+ environment variables, or a JSON config file.
41
+ """
42
+
43
+ base_url: str = _DEFAULTS["base_url"]
44
+ model: str = _DEFAULTS["model"]
45
+ timeout: int = _DEFAULTS["timeout"]
46
+ verbose: bool = _DEFAULTS["verbose"]
47
+
48
+ _explicit: set = field(default_factory=set, repr=False, compare=False)
49
+
50
+ def __post_init__(self) -> None:
51
+ for env_var, key in _ENV_MAP.items():
52
+ if key in self._explicit:
53
+ continue
54
+ value = os.environ.get(env_var)
55
+ if value is not None:
56
+ value = _coerce(key, value)
57
+ object.__setattr__(self, key, value)
58
+
59
+
60
+
61
+ def __repr__(self) -> str:
62
+ return (
63
+ f"Config(base_url={self.base_url!r}, model={self.model!r}, "
64
+ f"timeout={self.timeout}, verbose={self.verbose})"
65
+ )
66
+
67
+
68
+
69
+ def _coerce(key: str, value: str) -> Any:
70
+ """Coerce a string environment variable to the correct Python type."""
71
+ if key in _INT_KEYS:
72
+ return int(value)
73
+ if key in _BOOL_KEYS:
74
+ return value.lower() in ("1", "true", "yes")
75
+ return value