limbo-code 0.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.
limbo/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Limbo: a minimal terminal AI coding agent."""
2
+
3
+ __version__ = "0.1.0"
limbo/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for `python -m limbo`."""
2
+
3
+ from limbo.app import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
limbo/agent.py ADDED
@@ -0,0 +1,456 @@
1
+ """Agent conversation loop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import platform
8
+ import secrets
9
+ import time
10
+ import traceback
11
+ import warnings
12
+ from collections.abc import AsyncIterator
13
+ from dataclasses import dataclass
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from limbo import __version__
19
+ from limbo.config import Config
20
+ from limbo.history import ToolHistory
21
+ from limbo.history import repair as repair_history
22
+ from limbo.llm.client import LLMClient
23
+ from limbo.models import (
24
+ CompletionMeta,
25
+ Message,
26
+ TextChunk,
27
+ ThinkingChunk,
28
+ ToolCallEvent,
29
+ ToolResult,
30
+ )
31
+ from limbo.sessions import SessionMeta, derive_title, load_session, save_session
32
+ from limbo.tools.registry import ToolRegistry
33
+ from limbo.trace import TraceLogger, trace_path_for
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class TextDelta:
38
+ text: str
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class ThinkingDelta:
43
+ """A streamed reasoning/thinking delta (reasoning models only)."""
44
+
45
+ text: str
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class ToolCallRequest:
50
+ id: str
51
+ name: str
52
+ arguments: dict[str, Any]
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class ToolResultEvent:
57
+ id: str
58
+ name: str
59
+ result: ToolResult
60
+ arguments: dict[str, Any]
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class ErrorEvent:
65
+ message: str
66
+
67
+
68
+ AgentEvent = TextDelta | ThinkingDelta | ToolCallRequest | ToolResultEvent | ErrorEvent
69
+
70
+
71
+ def _extract_cached_tokens(usage: dict[str, Any] | None) -> int | None:
72
+ """Normalize provider-specific prompt cache-hit counters.
73
+
74
+ DeepSeek reports ``prompt_cache_hit_tokens``, OpenAI nests
75
+ ``prompt_tokens_details.cached_tokens``, Anthropic reports
76
+ ``cache_read_input_tokens``.
77
+ """
78
+ if not usage:
79
+ return None
80
+ hit = usage.get("prompt_cache_hit_tokens")
81
+ if isinstance(hit, int):
82
+ return hit
83
+ details = usage.get("prompt_tokens_details")
84
+ if isinstance(details, dict):
85
+ cached = details.get("cached_tokens")
86
+ if isinstance(cached, int):
87
+ return cached
88
+ read = usage.get("cache_read_input_tokens")
89
+ return read if isinstance(read, int) else None
90
+
91
+
92
+ class Agent:
93
+ """Orchestrates the conversation between user, LLM, and tools."""
94
+
95
+ def __init__(
96
+ self,
97
+ config: Config,
98
+ llm_client: LLMClient,
99
+ workdir: Path,
100
+ session_dir: Path | None = None,
101
+ resume: Path | None = None,
102
+ ):
103
+ self.config = config
104
+ self.llm_client = llm_client
105
+ self.workdir = workdir
106
+ self.registry = ToolRegistry(workdir=workdir, config=config)
107
+ self._history = ToolHistory([])
108
+ self._iteration_count = 0
109
+ self._init_system_message()
110
+
111
+ self._session_dir = session_dir or Path.home() / ".limbo" / "sessions"
112
+ if resume is not None:
113
+ self._meta, history = load_session(resume)
114
+ self._session_file = resume
115
+ self.messages.extend(repair_history(history))
116
+ else:
117
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S-%f")
118
+ # Random suffix avoids collisions when sessions are created in
119
+ # quick succession within the same process (e.g. `/new`).
120
+ self._session_file = (
121
+ self._session_dir
122
+ / f"{timestamp}-{os.getpid()}-{secrets.token_hex(2)}.jsonl"
123
+ )
124
+ self._meta = SessionMeta(
125
+ id=self._session_file.stem,
126
+ workdir=str(workdir.resolve()),
127
+ model=config.llm.model,
128
+ created_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
129
+ )
130
+
131
+ # Trace log: full-fidelity JSONL record of the run, kept separate from
132
+ # the resumable session file. Created lazily-tolerant: tracing must
133
+ # never break the agent.
134
+ self.trace = TraceLogger(trace_path_for(self._session_file))
135
+ self._turn_count = 0
136
+ self._turn_start: float | None = None
137
+ self.trace.log(
138
+ "session_start",
139
+ session_id=self._meta.id,
140
+ workdir=str(workdir.resolve()),
141
+ resumed=resume is not None,
142
+ restored_messages=(len(self.messages) - 1) if resume is not None else 0,
143
+ limbo_version=__version__,
144
+ python=platform.python_version(),
145
+ platform=platform.platform(),
146
+ config={
147
+ "model": config.llm.model,
148
+ "base_url": config.llm.base_url,
149
+ "temperature": config.llm.temperature,
150
+ "max_tokens": config.llm.max_tokens,
151
+ "max_iterations": config.llm.max_iterations,
152
+ "thinking_effort": config.llm.thinking_effort,
153
+ "bash_enabled": config.tools.bash_enabled,
154
+ },
155
+ )
156
+
157
+ @property
158
+ def session_id(self) -> str:
159
+ """Stable id of the current session (the session file stem)."""
160
+ return self._meta.id
161
+
162
+ @property
163
+ def session_meta(self) -> SessionMeta:
164
+ """Metadata of the current session (title filled in on first save)."""
165
+ if not self._meta.title:
166
+ self._meta.title = derive_title(self.messages)
167
+ return self._meta
168
+
169
+ def _init_system_message(self) -> None:
170
+ content_parts = [
171
+ (
172
+ "You are an expert coding assistant operating inside limbo, "
173
+ "a coding agent harness. You help users by reading files, "
174
+ "executing commands, editing code, and writing new files.\n\n"
175
+ "Available tools:\n"
176
+ "- read: Read file contents\n"
177
+ "- bash: Execute bash commands\n"
178
+ "- edit: Make surgical edits to files (find exact text and replace)\n"
179
+ "- write: Create or overwrite files\n"
180
+ "- grep: Search file contents for patterns (respects root .gitignore)\n"
181
+ "- find: Find files by glob pattern (respects root .gitignore only)\n"
182
+ "- ls: List directory contents\n\n"
183
+ "Guidelines:\n"
184
+ "- Prefer grep/find/ls tools over bash for file exploration\n"
185
+ "- Use read to examine files before editing\n"
186
+ "- Use edit for precise changes (old_text must match exactly)\n"
187
+ "- Use write only for new files or complete rewrites\n"
188
+ "- Be concise in your responses\n"
189
+ "- Show file paths clearly when working with files"
190
+ ),
191
+ ]
192
+ # Load optional project-level AGENTS.md for extra context.
193
+ project_md = self.workdir / "AGENTS.md"
194
+ if project_md.is_file():
195
+ try:
196
+ text = project_md.read_text(encoding="utf-8")
197
+ content_parts.append(
198
+ f"\n\n## Project context from AGENTS.md\n\n{text}"
199
+ )
200
+ except OSError:
201
+ pass
202
+ # Load optional global AGENTS.md for personal preferences.
203
+ global_md = Path.home() / ".limbo" / "AGENTS.md"
204
+ if global_md.is_file():
205
+ try:
206
+ text = global_md.read_text(encoding="utf-8")
207
+ content_parts.append(
208
+ f"\n\n## User preferences from ~/.limbo/AGENTS.md\n\n{text}"
209
+ )
210
+ except OSError:
211
+ pass
212
+ self.messages.append(
213
+ Message(
214
+ role="system",
215
+ content="".join(content_parts),
216
+ )
217
+ )
218
+
219
+ @property
220
+ def messages(self) -> list[Message]:
221
+ """The conversation history (owned by the tool-call bookkeeper)."""
222
+ return self._history.messages
223
+
224
+ @messages.setter
225
+ def messages(self, value: list[Message]) -> None:
226
+ self._history.messages = value
227
+
228
+ def _log_turn_end(self) -> None:
229
+ if self._turn_start is None:
230
+ return
231
+ self.trace.log(
232
+ "turn_end",
233
+ turn=self._turn_count,
234
+ duration=time.monotonic() - self._turn_start,
235
+ iterations=self._iteration_count,
236
+ status="completed",
237
+ )
238
+ self._turn_start = None
239
+
240
+ async def run(self, user_input: str) -> AsyncIterator[AgentEvent]:
241
+ # Reset per-user-turn state.
242
+ self._iteration_count = 0
243
+ self._turn_count += 1
244
+ self._turn_start = time.monotonic()
245
+
246
+ self.messages.append(Message(role="user", content=user_input))
247
+ self.trace.log(
248
+ "user_message", turn=self._turn_count, content=user_input
249
+ )
250
+
251
+ try:
252
+ async for event in self._conversation_loop():
253
+ yield event
254
+ finally:
255
+ self._log_turn_end()
256
+ try:
257
+ await self._save_session()
258
+ except Exception as e: # noqa: BLE001
259
+ self.trace.log(
260
+ "session_save_error", turn=self._turn_count, error=str(e)
261
+ )
262
+ warnings.warn(f"Failed to save session: {e}", stacklevel=2)
263
+
264
+ async def _execute_tool(
265
+ self,
266
+ call_id: str,
267
+ name: str,
268
+ arguments: dict[str, Any],
269
+ ) -> ToolResult:
270
+ """Execute a tool with full trace logging. Re-raises on crash."""
271
+ self.trace.log(
272
+ "tool_call",
273
+ turn=self._turn_count,
274
+ iteration=self._iteration_count,
275
+ id=call_id,
276
+ name=name,
277
+ arguments=arguments,
278
+ )
279
+ start = time.monotonic()
280
+ try:
281
+ result = await self.registry.execute(name, arguments)
282
+ except Exception as e: # noqa: BLE001
283
+ self.trace.log(
284
+ "tool_result",
285
+ turn=self._turn_count,
286
+ iteration=self._iteration_count,
287
+ id=call_id,
288
+ name=name,
289
+ success=False,
290
+ error=f"Tool error: {e}",
291
+ exception_type=type(e).__name__,
292
+ traceback=traceback.format_exc(),
293
+ duration=time.monotonic() - start,
294
+ )
295
+ raise
296
+ self.trace.log(
297
+ "tool_result",
298
+ turn=self._turn_count,
299
+ iteration=self._iteration_count,
300
+ id=call_id,
301
+ name=name,
302
+ success=result.success,
303
+ output=result.output,
304
+ error=result.error,
305
+ duration=time.monotonic() - start,
306
+ )
307
+ return result
308
+
309
+ async def _conversation_loop(self) -> AsyncIterator[AgentEvent]:
310
+ while self._iteration_count < self.config.llm.max_iterations:
311
+ self._iteration_count += 1
312
+ try:
313
+ async for event in self._call_llm():
314
+ yield event
315
+ except Exception as e: # noqa: BLE001
316
+ self.trace.log(
317
+ "llm_error",
318
+ turn=self._turn_count,
319
+ iteration=self._iteration_count,
320
+ error=str(e),
321
+ exception_type=type(e).__name__,
322
+ traceback=traceback.format_exc(),
323
+ )
324
+ yield ErrorEvent(message=f"LLM error: {e}")
325
+ return
326
+
327
+ last = self.messages[-1]
328
+ if not last.tool_calls:
329
+ break
330
+
331
+ # If we've reached the iteration limit on an assistant message that
332
+ # requests tool calls, cancel the calls instead of executing them.
333
+ # OpenAI requires a matching ``role="tool"`` result for every
334
+ # ``tool_call_id`` referenced by the assistant.
335
+ if self._iteration_count >= self.config.llm.max_iterations:
336
+ for tc in last.tool_calls:
337
+ self.messages.append(
338
+ Message(
339
+ role="tool",
340
+ content="Maximum iteration count reached; tool not executed.",
341
+ tool_call_id=tc["id"],
342
+ )
343
+ )
344
+ self.trace.log(
345
+ "error",
346
+ turn=self._turn_count,
347
+ kind="max_iterations",
348
+ message="Maximum iteration count reached; remaining tool calls were cancelled.",
349
+ cancelled_tool_call_ids=[tc["id"] for tc in last.tool_calls],
350
+ )
351
+ yield ErrorEvent(
352
+ message="Maximum iteration count reached; remaining tool calls were cancelled."
353
+ )
354
+ break
355
+
356
+ for idx, tc in enumerate(last.tool_calls):
357
+ name = tc["function"]["name"]
358
+ arguments = tc["function"]["arguments"]
359
+ yield ToolCallRequest(id=tc["id"], name=name, arguments=arguments)
360
+ try:
361
+ result = await self._execute_tool(tc["id"], name, arguments)
362
+ except Exception as e: # noqa: BLE001
363
+ error_message = f"Tool error: {e}"
364
+ yield ErrorEvent(message=error_message)
365
+ self._history.record_error(
366
+ last, idx, tc["id"], error_message
367
+ )
368
+ return
369
+ yield ToolResultEvent(
370
+ id=tc["id"], name=name, result=result, arguments=arguments
371
+ )
372
+ self._history.record_result(
373
+ tc["id"], result.output or result.error or ""
374
+ )
375
+
376
+ async def _call_llm(self) -> AsyncIterator[AgentEvent]:
377
+ tool_definitions = self.registry.definitions()
378
+ assistant_content = ""
379
+ assistant_reasoning = ""
380
+ assistant_signature: str | None = None
381
+ tool_calls: list[dict[str, Any]] = []
382
+ meta: CompletionMeta | None = None
383
+
384
+ def _trace_request(body: dict[str, Any]) -> None:
385
+ self.trace.log(
386
+ "llm_request",
387
+ turn=self._turn_count,
388
+ iteration=self._iteration_count,
389
+ body=body,
390
+ )
391
+
392
+ async for event in self.llm_client.chat(
393
+ self.messages, tools=tool_definitions, on_request=_trace_request
394
+ ):
395
+ if isinstance(event, TextChunk):
396
+ assistant_content += event.text
397
+ yield TextDelta(text=event.text)
398
+ elif isinstance(event, ThinkingChunk):
399
+ assistant_reasoning += event.text
400
+ if event.signature is not None:
401
+ assistant_signature = event.signature
402
+ yield ThinkingDelta(text=event.text)
403
+ elif isinstance(event, ToolCallEvent):
404
+ tool_calls.append(
405
+ {
406
+ "id": event.id,
407
+ "type": "function",
408
+ "function": {
409
+ "name": event.name,
410
+ "arguments": event.arguments,
411
+ },
412
+ }
413
+ )
414
+ yield ToolCallRequest(
415
+ id=event.id, name=event.name, arguments=event.arguments
416
+ )
417
+ elif isinstance(event, CompletionMeta):
418
+ meta = event
419
+
420
+ usage = meta.usage if meta else None
421
+ self.trace.log(
422
+ "llm_response",
423
+ turn=self._turn_count,
424
+ iteration=self._iteration_count,
425
+ duration=meta.duration if meta else None,
426
+ ttft=meta.ttft if meta else None,
427
+ finish_reason=meta.finish_reason if meta else None,
428
+ usage=usage,
429
+ cached_tokens=_extract_cached_tokens(usage),
430
+ content_chars=len(assistant_content),
431
+ reasoning_chars=len(assistant_reasoning),
432
+ tool_calls=[
433
+ {"id": tc["id"], "name": tc["function"]["name"]}
434
+ for tc in tool_calls
435
+ ],
436
+ )
437
+
438
+ self.messages.append(
439
+ Message(
440
+ role="assistant",
441
+ content=assistant_content or None,
442
+ tool_calls=tool_calls if tool_calls else None,
443
+ reasoning=assistant_reasoning or None,
444
+ reasoning_signature=assistant_signature,
445
+ )
446
+ )
447
+
448
+ def _save_session_sync(self) -> None:
449
+ # Rewrite the whole session on every save. It is cheap for MVP-sized
450
+ # conversations.
451
+ if not self._meta.title:
452
+ self._meta.title = derive_title(self.messages)
453
+ save_session(self._session_file, self._meta, self.messages)
454
+
455
+ async def _save_session(self) -> None:
456
+ await asyncio.to_thread(self._save_session_sync)
limbo/app.py ADDED
@@ -0,0 +1,107 @@
1
+ """CLI entry point for Limbo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from limbo.config import load_config
10
+ from limbo.llm.catalog import resolve_api_key, resolve_model
11
+ from limbo.sessions import (
12
+ AmbiguousSessionError,
13
+ SessionNotFoundError,
14
+ find_session,
15
+ latest_session,
16
+ load_session,
17
+ )
18
+ from limbo.ui.app import LimboApp
19
+
20
+ DEFAULT_SESSION_DIR = Path.home() / ".limbo" / "sessions"
21
+
22
+
23
+ def main() -> int:
24
+ parser = argparse.ArgumentParser(description="Limbo TUI coding agent")
25
+ parser.add_argument(
26
+ "--workdir",
27
+ type=Path,
28
+ default=Path.cwd(),
29
+ help="Working directory (default: current directory)",
30
+ )
31
+ parser.add_argument(
32
+ "--session-dir",
33
+ type=Path,
34
+ default=None,
35
+ help="Directory for session JSONL files (default: ~/.limbo/sessions)",
36
+ )
37
+ resume_group = parser.add_mutually_exclusive_group()
38
+ resume_group.add_argument(
39
+ "--continue",
40
+ dest="continue_session",
41
+ action="store_true",
42
+ help="Resume the most recent session for the working directory",
43
+ )
44
+ resume_group.add_argument(
45
+ "--resume",
46
+ metavar="SESSION_ID",
47
+ default=None,
48
+ help="Resume a session by id (or unique id prefix)",
49
+ )
50
+ args = parser.parse_args()
51
+
52
+ config = load_config()
53
+ spec = resolve_model(config.llm.model)
54
+ if not resolve_api_key(spec, config.llm.api_key):
55
+ env_hint = (
56
+ f" or ${spec.provider.api_key_env}"
57
+ if spec.provider.api_key_env
58
+ else ""
59
+ )
60
+ print(
61
+ "Error: No API key configured. Set it in ~/.limbo/config.toml\n"
62
+ f" [llm]\n api_key = 'your-key'{env_hint}",
63
+ file=sys.stderr,
64
+ )
65
+ return 1
66
+
67
+ session_dir = args.session_dir or DEFAULT_SESSION_DIR
68
+ workdir = args.workdir
69
+ resume: Path | None = None
70
+
71
+ if args.continue_session:
72
+ resume = latest_session(session_dir, workdir=workdir)
73
+ if resume is None:
74
+ print(
75
+ f"Error: No session to continue for {workdir.resolve()}",
76
+ file=sys.stderr,
77
+ )
78
+ return 1
79
+ elif args.resume is not None:
80
+ try:
81
+ resume = find_session(session_dir, args.resume)
82
+ except SessionNotFoundError as e:
83
+ print(f"Error: {e}", file=sys.stderr)
84
+ return 1
85
+ except AmbiguousSessionError as e:
86
+ print(f"Error: {e}", file=sys.stderr)
87
+ return 1
88
+
89
+ if resume is not None:
90
+ # The session's recorded workdir wins when it still exists — sessions
91
+ # are bound to the project they were created in.
92
+ meta, _ = load_session(resume)
93
+ if meta.workdir and Path(meta.workdir).is_dir():
94
+ workdir = Path(meta.workdir)
95
+
96
+ app = LimboApp(
97
+ workdir=workdir,
98
+ config=config,
99
+ session_dir=args.session_dir,
100
+ resume=resume,
101
+ )
102
+ app.run()
103
+ return 0
104
+
105
+
106
+ if __name__ == "__main__":
107
+ raise SystemExit(main())
limbo/config.py ADDED
@@ -0,0 +1,99 @@
1
+ """Configuration loading for Limbo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import toml # type: ignore[import-untyped]
10
+ from pydantic import BaseModel, Field, ValidationError, field_validator
11
+ from toml import TomlDecodeError
12
+
13
+ DEFAULT_CONFIG_PATH = Path.home() / ".limbo" / "config.toml"
14
+
15
+ # Single source of truth for safety defaults; tools fall back to these when
16
+ # constructed without explicit values.
17
+ DEFAULT_DANGEROUS_COMMANDS = ["rm", "git reset --hard"]
18
+ DEFAULT_SENSITIVE_FILES = [".env", "id_rsa", "id_ed25519", ".ssh"]
19
+
20
+
21
+ class LLMConfig(BaseModel):
22
+ api_key: str | None = None
23
+ base_url: str = "https://api.deepseek.com/v1"
24
+ model: str = "deepseek-chat"
25
+ temperature: float = 0.2
26
+ max_iterations: int = 10
27
+ # Thinking control for reasoning models (e.g. kimi-k3: low|high|max,
28
+ # deepseek-format Kimi models: on value or "off"). None = provider default.
29
+ thinking_effort: str | None = None
30
+ # Per-request output token cap; None = use the model catalog default.
31
+ max_tokens: int | None = None
32
+
33
+ @field_validator("max_iterations")
34
+ @classmethod
35
+ def _max_iterations_must_be_positive(cls, value: int) -> int:
36
+ if value < 1:
37
+ raise ValueError("max_iterations must be at least 1")
38
+ return value
39
+
40
+ @field_validator("max_tokens")
41
+ @classmethod
42
+ def _max_tokens_must_be_positive(cls, value: int | None) -> int | None:
43
+ if value is not None and value < 1:
44
+ raise ValueError("max_tokens must be at least 1")
45
+ return value
46
+
47
+
48
+ class UIConfig(BaseModel):
49
+ # Textual built-in theme name, e.g. "textual-dark", "dracula", "nord".
50
+ theme: str | None = None
51
+
52
+
53
+ class SafetyConfig(BaseModel):
54
+ dangerous_commands: list[str] = Field(
55
+ default_factory=lambda: list(DEFAULT_DANGEROUS_COMMANDS)
56
+ )
57
+ sensitive_files: list[str] = Field(
58
+ default_factory=lambda: list(DEFAULT_SENSITIVE_FILES)
59
+ )
60
+
61
+
62
+ class ToolsConfig(BaseModel):
63
+ bash_enabled: bool = True
64
+
65
+
66
+ class Config(BaseModel):
67
+ llm: LLMConfig = Field(default_factory=LLMConfig)
68
+ ui: UIConfig = Field(default_factory=UIConfig)
69
+ safety: SafetyConfig = Field(default_factory=SafetyConfig)
70
+ tools: ToolsConfig = Field(default_factory=ToolsConfig)
71
+
72
+
73
+ def load_config(path: Path | None = None) -> Config:
74
+ """Load config from TOML file, falling back to defaults."""
75
+ path = path or DEFAULT_CONFIG_PATH
76
+ if not path.exists():
77
+ return Config()
78
+ try:
79
+ data: dict[str, Any] = toml.load(path)
80
+ except TomlDecodeError as e:
81
+ warnings.warn(
82
+ f"Malformed config file {path}: {e}. Using defaults.",
83
+ stacklevel=2,
84
+ )
85
+ return Config()
86
+ except OSError as e:
87
+ warnings.warn(
88
+ f"Could not read config file {path}: {e}. Using defaults.",
89
+ stacklevel=2,
90
+ )
91
+ return Config()
92
+ try:
93
+ return Config.model_validate(data)
94
+ except ValidationError as e:
95
+ warnings.warn(
96
+ f"Invalid config file {path}: {e}. Using defaults.",
97
+ stacklevel=2,
98
+ )
99
+ return Config()