lughus 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.
lughus/__init__.py ADDED
@@ -0,0 +1,71 @@
1
+ """lughus — micro-framework for building A2A agents with LiteLLM."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
4
+ from typing import TYPE_CHECKING, Any
5
+
6
+ try:
7
+ __version__ = _pkg_version("lughus")
8
+ except PackageNotFoundError:
9
+ __version__ = "0.0.0-dev"
10
+
11
+ if TYPE_CHECKING:
12
+ from .llm import GenerateLLM as GenerateLLM, LLM as LLM, StreamingLLM as StreamingLLM
13
+
14
+ from .config import BaseSettings
15
+ from .errors import (
16
+ LoopLimitError,
17
+ LughusError,
18
+ ToolExecutionError,
19
+ ToolTimeoutError,
20
+ ToolValidationError,
21
+ )
22
+ from .events import Artifact, CompletionEvent, ProgressEvent
23
+ from .gateway import BaseGateway
24
+ from .loop import LoopResult, ToolExecutionConfig, agent_loop, agent_loop_stream
25
+ from .server import BoundedInMemoryTaskStore, ProductionGuardMiddleware, build_app, serve
26
+ from .telemetry import setup_telemetry
27
+ from .tools import ToolDef, ToolRegistry
28
+
29
+
30
+ def __getattr__(name: str) -> Any:
31
+ if name == "LLM":
32
+ from .llm import LLM
33
+
34
+ return LLM
35
+ if name == "GenerateLLM":
36
+ from .llm import GenerateLLM
37
+
38
+ return GenerateLLM
39
+ if name == "StreamingLLM":
40
+ from .llm import StreamingLLM
41
+
42
+ return StreamingLLM
43
+ raise AttributeError(f"module 'lughus' has no attribute {name!r}")
44
+
45
+
46
+ __all__ = [
47
+ "BaseSettings",
48
+ "LughusError",
49
+ "ToolValidationError",
50
+ "ToolExecutionError",
51
+ "ToolTimeoutError",
52
+ "LoopLimitError",
53
+ "LLM",
54
+ "GenerateLLM",
55
+ "StreamingLLM",
56
+ "agent_loop",
57
+ "agent_loop_stream",
58
+ "LoopResult",
59
+ "ToolExecutionConfig",
60
+ "ToolRegistry",
61
+ "ToolDef",
62
+ "BaseGateway",
63
+ "BoundedInMemoryTaskStore",
64
+ "ProductionGuardMiddleware",
65
+ "ProgressEvent",
66
+ "CompletionEvent",
67
+ "Artifact",
68
+ "build_app",
69
+ "serve",
70
+ "setup_telemetry",
71
+ ]
lughus/_threading.py ADDED
@@ -0,0 +1,72 @@
1
+ """Small asyncio/thread bridge used for blocking framework work."""
2
+ from __future__ import annotations
3
+
4
+ import atexit
5
+ import asyncio
6
+ import threading
7
+ import weakref
8
+ from concurrent.futures import ThreadPoolExecutor
9
+ from collections.abc import Callable
10
+ from typing import Any
11
+
12
+ DEFAULT_THREAD_WORKERS = 32
13
+
14
+ _process_executor: ThreadPoolExecutor | None = None
15
+ _process_executor_lock = threading.Lock()
16
+
17
+ _SYNC_SEMAPHORES: dict[tuple[weakref.ref[asyncio.AbstractEventLoop], int], asyncio.Semaphore] = {}
18
+ _SYNC_SEMAPHORES_LOCK = threading.Lock()
19
+
20
+
21
+ def _get_sync_semaphore(loop: asyncio.AbstractEventLoop, limit: int) -> asyncio.Semaphore:
22
+ with _SYNC_SEMAPHORES_LOCK:
23
+ # Clean up dead loops to prevent memory leaks
24
+ dead_keys = [k for k in _SYNC_SEMAPHORES if k[0]() is None]
25
+ for dk in dead_keys:
26
+ _SYNC_SEMAPHORES.pop(dk, None)
27
+
28
+ loop_ref = weakref.ref(loop)
29
+ key = (loop_ref, limit)
30
+ sem = _SYNC_SEMAPHORES.get(key)
31
+ if sem is None:
32
+ sem = asyncio.Semaphore(limit)
33
+ _SYNC_SEMAPHORES[key] = sem
34
+ return sem
35
+
36
+
37
+ def _executor(max_workers: int | None) -> ThreadPoolExecutor:
38
+ global _process_executor
39
+ workers = max_workers if max_workers and max_workers > 0 else DEFAULT_THREAD_WORKERS
40
+ with _process_executor_lock:
41
+ if _process_executor is None:
42
+ _process_executor = ThreadPoolExecutor(
43
+ max_workers=max(workers, DEFAULT_THREAD_WORKERS),
44
+ thread_name_prefix="lughus-worker",
45
+ )
46
+ return _process_executor
47
+
48
+
49
+ def _shutdown_executors() -> None:
50
+ global _process_executor
51
+ with _process_executor_lock:
52
+ if _process_executor is not None:
53
+ _process_executor.shutdown(wait=False)
54
+ _process_executor = None
55
+
56
+
57
+ atexit.register(_shutdown_executors)
58
+
59
+
60
+ async def run_sync_in_thread(
61
+ call: Callable[[], Any],
62
+ *,
63
+ max_workers: int | None = None,
64
+ ) -> Any:
65
+ """Run ``call`` on a bounded process-wide executor."""
66
+ loop = asyncio.get_running_loop()
67
+ executor = _executor(max_workers)
68
+ if max_workers and max_workers > 0:
69
+ sem = _get_sync_semaphore(loop, max_workers)
70
+ async with sem:
71
+ return await loop.run_in_executor(executor, call)
72
+ return await loop.run_in_executor(executor, call)
lughus/cli.py ADDED
@@ -0,0 +1,506 @@
1
+ """Command line interface for lughus."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import re
6
+ from pathlib import Path
7
+
8
+
9
+ def _package_name(value: str) -> str:
10
+ name = re.sub(r"\W+", "_", value.strip().lower()).strip("_")
11
+ if not name:
12
+ raise ValueError("Agent name must contain at least one letter or digit")
13
+ if name[0].isdigit():
14
+ name = f"agent_{name}"
15
+ return name
16
+
17
+
18
+ def _class_prefix(package_name: str) -> str:
19
+ return "".join(part.capitalize() for part in package_name.split("_"))
20
+
21
+
22
+ def _display_name(package_name: str) -> str:
23
+ return package_name.replace("_", "-")
24
+
25
+
26
+ def _write_file(path: Path, content: str) -> None:
27
+ path.parent.mkdir(parents=True, exist_ok=True)
28
+ path.write_text(content, encoding="utf-8")
29
+
30
+
31
+ def _pyproject(project_name: str, package_name: str) -> str:
32
+ return f'''[project]
33
+ name = "{project_name}"
34
+ version = "0.1.0"
35
+ description = "A lughus agent."
36
+ requires-python = ">=3.11"
37
+ dependencies = [
38
+ "lughus",
39
+ ]
40
+
41
+ [project.optional-dependencies]
42
+ dev = [
43
+ "pytest>=8.0",
44
+ "pytest-asyncio>=0.23",
45
+ ]
46
+
47
+ [build-system]
48
+ requires = ["setuptools>=68.0"]
49
+ build-backend = "setuptools.build_meta"
50
+
51
+ [tool.setuptools.packages.find]
52
+ include = ["{package_name}*"]
53
+
54
+ [project.scripts]
55
+ {package_name} = "{package_name}.__main__:main"
56
+ '''
57
+
58
+
59
+ def _readme(project_name: str, package_name: str, display_name: str) -> str:
60
+ return f'''# {display_name}
61
+
62
+ A lughus agent scaffold generated with:
63
+
64
+ ```bash
65
+ lughus new {project_name}
66
+ ```
67
+
68
+ ## Install
69
+
70
+ ```bash
71
+ python -m pip install -e ".[dev]"
72
+ ```
73
+
74
+ ## Configure
75
+
76
+ ```bash
77
+ cp .env.example .env
78
+ export AGENT_MODEL="openai/gpt-4o"
79
+ export OPENAI_API_KEY="sk-..."
80
+ ```
81
+
82
+ ## Test
83
+
84
+ ```bash
85
+ pytest -q
86
+ ```
87
+
88
+ The default test uses `lughus.testing.MockLLM`, so it does not call a real LLM provider.
89
+
90
+ ## Run
91
+
92
+ ```bash
93
+ python -m {package_name}
94
+ # or
95
+ {package_name}
96
+ ```
97
+
98
+ The agent exposes:
99
+
100
+ - `POST /` — A2A JSON-RPC endpoint
101
+ - `GET /.well-known/agent-card.json`
102
+ - `GET /health`
103
+ - `GET /healthz`
104
+ '''
105
+
106
+
107
+ def _env_example() -> str:
108
+ import os
109
+ from dataclasses import fields
110
+ from lughus.config import BaseSettings
111
+
112
+ # Temporarily isolate environment variables to get true defaults
113
+ old_env = dict(os.environ)
114
+ os.environ.clear()
115
+ try:
116
+ settings = BaseSettings()
117
+ finally:
118
+ os.environ.update(old_env)
119
+
120
+ lines = [
121
+ "AGENT_MODEL=openai/gpt-4o",
122
+ "OPENAI_API_KEY=sk-...",
123
+ ]
124
+
125
+ custom_names = {
126
+ "environment": "LUGHUS_ENV",
127
+ }
128
+
129
+ for f in fields(BaseSettings):
130
+ if f.name == "model":
131
+ continue
132
+ env_name = custom_names.get(f.name, f.name.upper())
133
+ val = getattr(settings, f.name)
134
+ if val is None:
135
+ val = ""
136
+ elif isinstance(val, bool):
137
+ val = str(val).lower()
138
+ lines.append(f"{env_name}={val}")
139
+
140
+ return "\n".join(lines) + "\n"
141
+
142
+
143
+ def _config() -> str:
144
+ return '''"""Agent settings."""
145
+ from __future__ import annotations
146
+
147
+ from dataclasses import dataclass
148
+
149
+ from lughus import BaseSettings
150
+
151
+
152
+ @dataclass(frozen=True)
153
+ class Settings(BaseSettings):
154
+ """Extend BaseSettings with agent-specific configuration when needed."""
155
+ '''
156
+
157
+
158
+ def _tools() -> str:
159
+ return '''"""Agent tools."""
160
+ from __future__ import annotations
161
+
162
+ import json
163
+ from dataclasses import dataclass
164
+
165
+ from lughus import ToolRegistry
166
+
167
+ registry = ToolRegistry()
168
+
169
+
170
+ @dataclass
171
+ class AgentState:
172
+ greeting: str = ""
173
+
174
+
175
+ @registry.tool(
176
+ "greet",
177
+ "Greet a person by name.",
178
+ {
179
+ "type": "object",
180
+ "properties": {
181
+ "name": {"type": "string", "description": "Name to greet."},
182
+ },
183
+ "required": ["name"],
184
+ "additionalProperties": False,
185
+ },
186
+ )
187
+ def greet(*, name: str, state: AgentState) -> str:
188
+ state.greeting = f"Hello {name}!"
189
+ return json.dumps({"greeting": state.greeting})
190
+ '''
191
+
192
+
193
+ def _workspace() -> str:
194
+ return '''"""Request orchestration."""
195
+ from __future__ import annotations
196
+
197
+ from collections.abc import AsyncIterator
198
+ from typing import Any
199
+
200
+ from lughus import CompletionEvent, ProgressEvent, ToolExecutionConfig, agent_loop
201
+
202
+ from .tools import AgentState, registry
203
+
204
+ SYSTEM_PROMPT = "You are a concise assistant. Use the greet tool when a name is provided."
205
+
206
+
207
+ class Workspace:
208
+ def __init__(self, objective: str, llm: Any, tool_config: ToolExecutionConfig):
209
+ self.objective = objective
210
+ self.llm = llm
211
+ self.tool_config = tool_config
212
+ self.state = AgentState()
213
+
214
+ async def run(self) -> AsyncIterator[ProgressEvent | CompletionEvent]:
215
+ yield ProgressEvent("Processing request")
216
+ result = await agent_loop(
217
+ self.llm,
218
+ system=SYSTEM_PROMPT,
219
+ context=self.objective,
220
+ registry=registry,
221
+ tool_names=["greet"],
222
+ state=self.state,
223
+ tool_config=self.tool_config,
224
+ )
225
+ yield CompletionEvent(text=result)
226
+ '''
227
+
228
+
229
+ def _gateway(class_prefix: str) -> str:
230
+ return f'''"""A2A gateway."""
231
+ from __future__ import annotations
232
+
233
+ from collections.abc import AsyncIterator
234
+
235
+ from lughus import BaseGateway, CompletionEvent, ProgressEvent, ToolExecutionConfig
236
+
237
+ from .workspace import Workspace
238
+
239
+
240
+ class {class_prefix}Gateway(BaseGateway):
241
+ async def handle(
242
+ self,
243
+ objective: str,
244
+ files: list[tuple[bytes, str, str]],
245
+ ) -> AsyncIterator[ProgressEvent | CompletionEvent]:
246
+ tool_config = ToolExecutionConfig(
247
+ max_parallel_tools=self.settings.max_parallel_tools,
248
+ max_global_tools=self.settings.max_global_tools,
249
+ max_sync_thread_workers=self.settings.max_sync_thread_workers,
250
+ tool_timeout=self.settings.tool_timeout,
251
+ tool_queue_timeout=self.settings.tool_queue_timeout,
252
+ max_tool_args_chars=self.settings.max_tool_args_chars,
253
+ max_tool_output_chars=self.settings.max_tool_output_chars,
254
+ max_message_history_chars=self.settings.max_message_history_chars,
255
+ compact_tool_schemas=self.settings.compact_tool_schemas,
256
+ )
257
+ workspace = Workspace(objective, self.llm, tool_config)
258
+ async for event in workspace.run():
259
+ yield event
260
+ '''
261
+
262
+
263
+ def _task_store() -> str:
264
+ return '''"""Production task store hook.
265
+
266
+ Replace this placeholder with a Redis, SQL, or other persistent TaskStore for
267
+ high-volume production deployments.
268
+ """
269
+ from __future__ import annotations
270
+
271
+ from lughus import BoundedInMemoryTaskStore
272
+
273
+
274
+ class AgentTaskStore(BoundedInMemoryTaskStore):
275
+ """Placeholder custom TaskStore used by the scaffold."""
276
+ '''
277
+
278
+
279
+ def _main(
280
+ *,
281
+ package_name: str,
282
+ class_prefix: str,
283
+ display_name: str,
284
+ description: str,
285
+ skill_id: str,
286
+ skill_name: str,
287
+ ) -> str:
288
+ return f'''"""Run the {display_name} lughus agent."""
289
+ from __future__ import annotations
290
+
291
+ from a2a.types import AgentCapabilities, AgentCard, AgentSkill
292
+
293
+ from lughus import LLM, build_app, serve
294
+
295
+ from .config import Settings
296
+ from .gateway import {class_prefix}Gateway
297
+ from .task_store import AgentTaskStore
298
+
299
+ settings = Settings()
300
+
301
+ agent_card = AgentCard(
302
+ name="{display_name}",
303
+ version="0.1.0",
304
+ url=settings.public_url or f"http://{{settings.host}}:{{settings.port}}",
305
+ description="{description}",
306
+ default_input_modes=["text/plain"],
307
+ default_output_modes=["text/plain"],
308
+ skills=[
309
+ AgentSkill(
310
+ id="{skill_id}",
311
+ name="{skill_name}",
312
+ description="Greets a named person.",
313
+ tags=["example"],
314
+ )
315
+ ],
316
+ capabilities=AgentCapabilities(streaming=True),
317
+ )
318
+
319
+ llm = LLM.from_settings(settings)
320
+ gateway = {class_prefix}Gateway(llm=llm, settings=settings)
321
+ task_store = AgentTaskStore()
322
+
323
+ # ASGI entrypoint for uvicorn/gunicorn:
324
+ app = build_app(
325
+ agent_card,
326
+ gateway,
327
+ task_store=task_store,
328
+ enable_test_ui=settings.enable_test_ui,
329
+ )
330
+
331
+
332
+ def main() -> None:
333
+ serve(
334
+ agent_card,
335
+ gateway,
336
+ host=settings.host,
337
+ port=settings.port,
338
+ log_level=settings.log_level,
339
+ task_store=task_store,
340
+ setup_otel=False,
341
+ enable_test_ui=settings.enable_test_ui,
342
+ )
343
+
344
+
345
+ if __name__ == "__main__":
346
+ main()
347
+ '''
348
+
349
+
350
+ def _test_workspace(package_name: str) -> str:
351
+ return f'''"""Offline tests for the generated agent."""
352
+ from __future__ import annotations
353
+
354
+ import pytest
355
+
356
+ from {package_name}.workspace import Workspace
357
+ from lughus import CompletionEvent, ProgressEvent, ToolExecutionConfig
358
+ from lughus.testing import MockLLM
359
+
360
+
361
+ @pytest.mark.asyncio
362
+ async def test_workspace_runs_with_mock_llm() -> None:
363
+ llm = MockLLM([
364
+ [{{"id": "call_1", "name": "greet", "arguments": {{"name": "Ada"}}}}],
365
+ "Greeting complete.",
366
+ ])
367
+ workspace = Workspace(
368
+ "Greet Ada",
369
+ llm,
370
+ ToolExecutionConfig(max_parallel_tools=2, tool_timeout=1.0),
371
+ )
372
+
373
+ events = [event async for event in workspace.run()]
374
+
375
+ assert isinstance(events[0], ProgressEvent)
376
+ assert isinstance(events[-1], CompletionEvent)
377
+ assert events[-1].text == "Greeting complete."
378
+ '''
379
+
380
+
381
+ def _pytest_ini() -> str:
382
+ return '''[pytest]
383
+ asyncio_mode = auto
384
+ pythonpath = .
385
+ addopts = --import-mode=importlib
386
+ '''
387
+
388
+
389
+ def create_agent(
390
+ *,
391
+ target: Path,
392
+ project_name: str,
393
+ package_name: str,
394
+ display_name: str,
395
+ description: str,
396
+ skill_id: str,
397
+ skill_name: str,
398
+ ) -> list[Path]:
399
+ if target.exists() and any(target.iterdir()):
400
+ raise FileExistsError(f"Target directory is not empty: {target}")
401
+
402
+ class_prefix = _class_prefix(package_name)
403
+ files = {
404
+ "pyproject.toml": _pyproject(project_name, package_name),
405
+ "README.md": _readme(project_name, package_name, display_name),
406
+ ".env.example": _env_example(),
407
+ "pytest.ini": _pytest_ini(),
408
+ f"{package_name}/__init__.py": f'"""Generated {display_name} agent."""\n',
409
+ f"{package_name}/config.py": _config(),
410
+ f"{package_name}/tools.py": _tools(),
411
+ f"{package_name}/workspace.py": _workspace(),
412
+ f"{package_name}/gateway.py": _gateway(class_prefix),
413
+ f"{package_name}/task_store.py": _task_store(),
414
+ f"{package_name}/__main__.py": _main(
415
+ package_name=package_name,
416
+ class_prefix=class_prefix,
417
+ display_name=display_name,
418
+ description=description,
419
+ skill_id=skill_id,
420
+ skill_name=skill_name,
421
+ ),
422
+ "tests/test_workspace.py": _test_workspace(package_name),
423
+ }
424
+
425
+ written: list[Path] = []
426
+ for relative, content in files.items():
427
+ path = target / relative
428
+ _write_file(path, content)
429
+ written.append(path)
430
+ return written
431
+
432
+
433
+ def _build_parser() -> argparse.ArgumentParser:
434
+ parser = argparse.ArgumentParser(prog="lughus", description="lughus command line tools")
435
+ subparsers = parser.add_subparsers(dest="command", required=True)
436
+
437
+ new_parser = subparsers.add_parser("new", help="Create a new lughus agent project")
438
+ new_parser.add_argument("name", nargs="?", help="Project directory name, e.g. agent_test")
439
+ new_parser.add_argument("--dir", dest="directory", help="Target directory. Defaults to NAME.")
440
+ new_parser.add_argument("--display-name", help="AgentCard display name.")
441
+ new_parser.add_argument("--description", help="AgentCard description.")
442
+ new_parser.add_argument("--skill-id", default="greet", help="Initial AgentSkill id.")
443
+ new_parser.add_argument("--skill-name", default="Greet", help="Initial AgentSkill name.")
444
+ new_parser.add_argument(
445
+ "--interactive",
446
+ action="store_true",
447
+ help="Prompt for missing display metadata before creating files.",
448
+ )
449
+ return parser
450
+
451
+
452
+ def _prompt(current: str, label: str) -> str:
453
+ value = input(f"{label} [{current}]: ").strip()
454
+ return value or current
455
+
456
+
457
+ def main(argv: list[str] | None = None) -> int:
458
+ parser = _build_parser()
459
+ args = parser.parse_args(argv)
460
+
461
+ if args.command == "new":
462
+ raw_name = args.name
463
+ if not raw_name:
464
+ raw_name = input("Project name [my_agent]: ").strip() or "my_agent"
465
+
466
+ package_name = _package_name(raw_name)
467
+ target = Path(args.directory or raw_name)
468
+ display_name = args.display_name or _display_name(package_name)
469
+ description = args.description or f"{display_name} agent."
470
+ skill_id = args.skill_id
471
+ skill_name = args.skill_name
472
+
473
+ if args.interactive:
474
+ display_name = _prompt(display_name, "Agent display name")
475
+ description = _prompt(description, "Agent description")
476
+ skill_id = _prompt(skill_id, "Initial skill id")
477
+ skill_name = _prompt(skill_name, "Initial skill name")
478
+
479
+ try:
480
+ create_agent(
481
+ target=target,
482
+ project_name=raw_name,
483
+ package_name=package_name,
484
+ display_name=display_name,
485
+ description=description,
486
+ skill_id=skill_id,
487
+ skill_name=skill_name,
488
+ )
489
+ except (OSError, ValueError) as exc:
490
+ parser.error(str(exc))
491
+
492
+ print(f"Created lughus agent in {target}")
493
+ print("")
494
+ print("Next steps:")
495
+ print(f" cd {target}")
496
+ print(' python -m pip install -e ".[dev]"')
497
+ print(" pytest -q")
498
+ print(f" python -m {package_name}")
499
+ return 0
500
+
501
+ parser.error(f"Unknown command: {args.command}")
502
+ return 2
503
+
504
+
505
+ if __name__ == "__main__":
506
+ raise SystemExit(main())