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 +71 -0
- lughus/_threading.py +72 -0
- lughus/cli.py +506 -0
- lughus/config.py +177 -0
- lughus/errors.py +22 -0
- lughus/events.py +27 -0
- lughus/gateway.py +355 -0
- lughus/llm.py +223 -0
- lughus/loop/__init__.py +16 -0
- lughus/loop/_config.py +30 -0
- lughus/loop/_execute.py +371 -0
- lughus/loop/_loop.py +358 -0
- lughus/loop/_result.py +63 -0
- lughus/py.typed +0 -0
- lughus/retry.py +27 -0
- lughus/server.py +407 -0
- lughus/telemetry.py +76 -0
- lughus/testing.py +224 -0
- lughus/tools.py +167 -0
- lughus/ui/test_ui.css +735 -0
- lughus/ui/test_ui.html +104 -0
- lughus/ui/test_ui.js +540 -0
- lughus/ui_server.py +502 -0
- lughus-0.1.0.dist-info/METADATA +1043 -0
- lughus-0.1.0.dist-info/RECORD +29 -0
- lughus-0.1.0.dist-info/WHEEL +5 -0
- lughus-0.1.0.dist-info/entry_points.txt +2 -0
- lughus-0.1.0.dist-info/licenses/LICENSE +21 -0
- lughus-0.1.0.dist-info/top_level.txt +1 -0
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())
|