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 +3 -0
- limbo/__main__.py +6 -0
- limbo/agent.py +456 -0
- limbo/app.py +107 -0
- limbo/config.py +99 -0
- limbo/history.py +90 -0
- limbo/llm/__init__.py +1 -0
- limbo/llm/anthropic_client.py +320 -0
- limbo/llm/catalog.py +234 -0
- limbo/llm/client.py +21 -0
- limbo/llm/factory.py +46 -0
- limbo/llm/openai_client.py +261 -0
- limbo/models.py +81 -0
- limbo/sessions.py +258 -0
- limbo/skills.py +89 -0
- limbo/tools/__init__.py +1 -0
- limbo/tools/base.py +102 -0
- limbo/tools/bash.py +172 -0
- limbo/tools/edit.py +70 -0
- limbo/tools/find.py +71 -0
- limbo/tools/grep.py +182 -0
- limbo/tools/ignore.py +111 -0
- limbo/tools/ls.py +41 -0
- limbo/tools/read.py +105 -0
- limbo/tools/registry.py +66 -0
- limbo/tools/write.py +34 -0
- limbo/trace.py +100 -0
- limbo/ui/__init__.py +1 -0
- limbo/ui/app.py +52 -0
- limbo/ui/app.tcss +174 -0
- limbo/ui/banner.py +83 -0
- limbo/ui/commands.py +61 -0
- limbo/ui/screens/__init__.py +1 -0
- limbo/ui/screens/game2048.py +213 -0
- limbo/ui/screens/main.py +369 -0
- limbo/ui/screens/session_picker.py +59 -0
- limbo/ui/widgets/__init__.py +1 -0
- limbo/ui/widgets/chat.py +149 -0
- limbo/ui/widgets/command_menu.py +45 -0
- limbo/ui/widgets/input.py +199 -0
- limbo/ui/widgets/status_bar.py +32 -0
- limbo/ui/widgets/tool_card.py +179 -0
- limbo_code-0.1.0.dist-info/METADATA +16 -0
- limbo_code-0.1.0.dist-info/RECORD +46 -0
- limbo_code-0.1.0.dist-info/WHEEL +4 -0
- limbo_code-0.1.0.dist-info/entry_points.txt +2 -0
limbo/__init__.py
ADDED
limbo/__main__.py
ADDED
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()
|