Ltera-ai 1.0__tar.gz
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.
- ltera_ai-1.0/.gitignore +10 -0
- ltera_ai-1.0/.python-version +1 -0
- ltera_ai-1.0/Lterax/__init__.py +12 -0
- ltera_ai-1.0/Lterax/__main__.py +261 -0
- ltera_ai-1.0/Lterax/agent.py +224 -0
- ltera_ai-1.0/Lterax/config.py +75 -0
- ltera_ai-1.0/Lterax/gemini_backend.py +345 -0
- ltera_ai-1.0/Lterax/llm.py +152 -0
- ltera_ai-1.0/Lterax/prompts.py +124 -0
- ltera_ai-1.0/Lterax/sandbox.py +91 -0
- ltera_ai-1.0/Lterax/server.py +66 -0
- ltera_ai-1.0/Lterax/tools/__init__.py +142 -0
- ltera_ai-1.0/Lterax/tools/file_tools.py +234 -0
- ltera_ai-1.0/PKG-INFO +68 -0
- ltera_ai-1.0/README.md +46 -0
- ltera_ai-1.0/pyproject.toml +36 -0
- ltera_ai-1.0/uv.lock +315 -0
ltera_ai-1.0/.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.11
|
|
@@ -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__"]
|
|
@@ -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())
|
|
@@ -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()
|
|
@@ -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
|