lc-agent-app 0.2.1__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.
Files changed (54) hide show
  1. lc_agent/__init__.py +21 -0
  2. lc_agent/__main__.py +4 -0
  3. lc_agent/app.py +194 -0
  4. lc_agent/config/__init__.py +3 -0
  5. lc_agent/config/loader.py +86 -0
  6. lc_agent/config/schema.py +48 -0
  7. lc_agent/core/__init__.py +5 -0
  8. lc_agent/core/chat_model.py +48 -0
  9. lc_agent/core/engine.py +328 -0
  10. lc_agent/core/http_trace.py +228 -0
  11. lc_agent/core/http_trace_httpx.py +121 -0
  12. lc_agent/core/models.py +39 -0
  13. lc_agent/db/__init__.py +13 -0
  14. lc_agent/db/engine.py +76 -0
  15. lc_agent/db/migrations/README +1 -0
  16. lc_agent/db/migrations/env.py +72 -0
  17. lc_agent/db/migrations/script.py.mako +29 -0
  18. lc_agent/db/migrations/versions/20260623_add_http_traces_to_chat_ui_messages.py +25 -0
  19. lc_agent/db/migrations/versions/a342dc61a740_initial_schema.py +75 -0
  20. lc_agent/db/models.py +52 -0
  21. lc_agent/db/repository.py +164 -0
  22. lc_agent/main.py +24 -0
  23. lc_agent/mcp/__init__.py +3 -0
  24. lc_agent/mcp/manager.py +323 -0
  25. lc_agent/mcp/tool_adapter.py +98 -0
  26. lc_agent/server/__init__.py +4 -0
  27. lc_agent/server/app.py +57 -0
  28. lc_agent/server/dependencies.py +26 -0
  29. lc_agent/server/routes/__init__.py +1 -0
  30. lc_agent/server/routes/agents.py +249 -0
  31. lc_agent/server/routes/health.py +17 -0
  32. lc_agent/server/routes/mcp.py +49 -0
  33. lc_agent/server/routes/models.py +20 -0
  34. lc_agent/server/routes/sessions.py +136 -0
  35. lc_agent/server/routes/skills.py +63 -0
  36. lc_agent/server/routes/tools.py +71 -0
  37. lc_agent/server/websocket.py +485 -0
  38. lc_agent/skills/__init__.py +3 -0
  39. lc_agent/skills/scanner.py +111 -0
  40. lc_agent/tools/__init__.py +4 -0
  41. lc_agent/tools/builtin.py +10 -0
  42. lc_agent/tools/registry.py +139 -0
  43. lc_agent/utils/loggers.py +6 -0
  44. lc_agent/web/dist/assets/TestSegments-B24ou3vL.css +1 -0
  45. lc_agent/web/dist/assets/TestSegments-DoB1Aned.js +5 -0
  46. lc_agent/web/dist/assets/index-Bc9lZYfU.js +107 -0
  47. lc_agent/web/dist/assets/index-CQJEFcQz.css +1 -0
  48. lc_agent/web/dist/favicon.svg +22 -0
  49. lc_agent/web/dist/index.html +14 -0
  50. lc_agent_app-0.2.1.dist-info/METADATA +28 -0
  51. lc_agent_app-0.2.1.dist-info/RECORD +54 -0
  52. lc_agent_app-0.2.1.dist-info/WHEEL +4 -0
  53. lc_agent_app-0.2.1.dist-info/entry_points.txt +2 -0
  54. lc_agent_app-0.2.1.dist-info/licenses/LICENSE +21 -0
lc_agent/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ # lc_agent/__init__.py
2
+ """lc_agent — LangChain Agent framework with built-in Web UI."""
3
+
4
+ from importlib.metadata import version, PackageNotFoundError
5
+
6
+ try:
7
+ __version__ = version("lc-agent-app")
8
+ except PackageNotFoundError:
9
+ __version__ = "0.0.0-dev"
10
+
11
+ from lc_agent.app import LcAgentApp
12
+ from lc_agent.config.loader import load_config
13
+ from lc_agent.tools.registry import ToolRegistry, tool
14
+
15
+ __all__ = [
16
+ "LcAgentApp",
17
+ "load_config",
18
+ "ToolRegistry",
19
+ "tool",
20
+ "__version__",
21
+ ]
lc_agent/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from lc_agent.main import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
lc_agent/app.py ADDED
@@ -0,0 +1,194 @@
1
+ # lc_agent/app.py
2
+ from __future__ import annotations
3
+
4
+ from contextlib import asynccontextmanager
5
+
6
+ import uvicorn
7
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
8
+
9
+ from lc_agent.core.engine import AgentEngine
10
+ from lc_agent.db.engine import init_db
11
+ from lc_agent.server.app import create_app, mount_static_files
12
+ from lc_agent.server.websocket import ChatWebSocketHandler
13
+
14
+
15
+ class LcAgentApp:
16
+ """Main application orchestrator — creates engine, server, and runs."""
17
+
18
+ def __init__(self, config: dict, host: str = "127.0.0.1", port: int = 8000):
19
+ self.config = config
20
+ self.host = host
21
+ self.port = port
22
+ self._db_url = config.get("database", {}).get("url", "sqlite+aiosqlite:///./lc_agent_data.db")
23
+ self._checkpoint_path = config.get("database", {}).get("checkpoint_path", "./lc_agent_checkpoints.db")
24
+ self.engine = AgentEngine(config)
25
+ from lc_agent.skills.scanner import SkillScanner
26
+ skills_dir = config.get("skills", {}).get("directory", "./skills")
27
+ self.skill_scanner = SkillScanner(skills_dir)
28
+ self.skill_scanner.scan()
29
+ from lc_agent.mcp.manager import McpManager
30
+ mcp_config = config.get("mcp_servers", {})
31
+ self.mcp_manager = McpManager(mcp_config, on_state_change=self._on_mcp_state_change)
32
+ self.fastapi_app = create_app(config, lifespan=self._lifespan)
33
+ self.fastapi_app.state.mcp_manager = self.mcp_manager
34
+ self.fastapi_app.state.skill_scanner = self.skill_scanner
35
+ self.engine._skill_scanner = self.skill_scanner
36
+ self.engine._mcp_manager = self.mcp_manager
37
+ self.fastapi_app.state.engine = self.engine
38
+ self._ws_handler = ChatWebSocketHandler(self.engine, db_url=self._db_url)
39
+ self._setup_websocket_route()
40
+ mount_static_files(self.fastapi_app)
41
+
42
+ def _on_mcp_state_change(self):
43
+ self.engine._mcp_generation += 1
44
+
45
+ @asynccontextmanager
46
+ async def _lifespan(self, app: FastAPI):
47
+ """FastAPI lifespan: startup and shutdown logic."""
48
+ import asyncio
49
+
50
+ await init_db(self._db_url)
51
+ try:
52
+ from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
53
+ import aiosqlite
54
+ conn = await aiosqlite.connect(self._checkpoint_path)
55
+ saver = AsyncSqliteSaver(conn)
56
+ await saver.setup()
57
+ self.engine._checkpointer = saver
58
+ except Exception as e:
59
+ print(f"[Warning] Checkpoint saver setup failed, using None: {e}")
60
+
61
+ await self._load_presets_from_db()
62
+
63
+ async def _connect_mcp_background():
64
+ try:
65
+ await self.mcp_manager.connect_all()
66
+ connected = [s for s in self.mcp_manager.servers if s.status == "connected"]
67
+ if connected:
68
+ print(f"[MCP] Connected: {[s.name for s in connected]}")
69
+ except Exception as e:
70
+ print(f"[MCP] Background connection error: {e}")
71
+
72
+ asyncio.create_task(_connect_mcp_background())
73
+ try:
74
+ yield
75
+ finally:
76
+ await self.mcp_manager.shutdown()
77
+
78
+ def _setup_websocket_route(self):
79
+ import asyncio
80
+
81
+ async def _ws_loop(websocket: WebSocket, tid: str):
82
+ streaming_task: asyncio.Task | None = None
83
+ try:
84
+ while True:
85
+ if streaming_task and not streaming_task.done():
86
+ recv_coro = websocket.receive_json()
87
+ recv_task = asyncio.ensure_future(recv_coro)
88
+ done, _ = await asyncio.wait(
89
+ [recv_task, streaming_task],
90
+ return_when=asyncio.FIRST_COMPLETED,
91
+ )
92
+ if recv_task in done:
93
+ data = recv_task.result()
94
+ if data.get("type") == "cancel":
95
+ self._ws_handler._cancel_flags[tid] = True
96
+ if streaming_task in done:
97
+ streaming_task = None
98
+ if recv_task not in done:
99
+ recv_task.cancel()
100
+ try:
101
+ await recv_task
102
+ except (asyncio.CancelledError, Exception):
103
+ pass
104
+ else:
105
+ data = await websocket.receive_json()
106
+ msg_type = data.get("type", "message")
107
+ if msg_type == "cancel":
108
+ continue
109
+ streaming_task = asyncio.create_task(
110
+ self._ws_handler.handle_message(websocket, tid, data)
111
+ )
112
+ except WebSocketDisconnect:
113
+ if streaming_task and not streaming_task.done():
114
+ streaming_task.cancel()
115
+ await self._ws_handler.disconnect(tid)
116
+ except Exception as e:
117
+ print(f"[WS] Loop error: {e}")
118
+ if streaming_task and not streaming_task.done():
119
+ streaming_task.cancel()
120
+ await self._ws_handler.disconnect(tid)
121
+
122
+ @self.fastapi_app.websocket("/ws/chat/{thread_id}")
123
+ async def websocket_chat(websocket: WebSocket, thread_id: str):
124
+ tid = await self._ws_handler.connect(websocket, thread_id)
125
+ await _ws_loop(websocket, tid)
126
+
127
+ @self.fastapi_app.websocket("/ws/chat")
128
+ async def websocket_chat_auto(websocket: WebSocket):
129
+ tid = await self._ws_handler.connect(websocket)
130
+ await _ws_loop(websocket, tid)
131
+
132
+ async def _load_presets_from_db(self):
133
+ """Load user-created presets from database on startup."""
134
+ from lc_agent.db.engine import get_async_session
135
+ from lc_agent.db.models import AgentPresetDB
136
+ from lc_agent.core.models import AgentPreset
137
+ from sqlalchemy import select
138
+
139
+ session = get_async_session(self._db_url)
140
+ try:
141
+ stmt = select(AgentPresetDB)
142
+ result = await session.execute(stmt)
143
+ for row in result.scalars().all():
144
+ preset = AgentPreset(
145
+ id=row.id,
146
+ name=row.name,
147
+ system_prompt=row.system_prompt,
148
+ default_model=row.default_model,
149
+ allowed_tool_groups=row.allowed_tool_groups,
150
+ allowed_mcp_servers=row.allowed_mcp_servers,
151
+ allowed_skills=row.allowed_skills,
152
+ dangerous_tools=row.dangerous_tools,
153
+ )
154
+ self.engine._presets[preset.id] = preset
155
+ loaded = len(self.engine._presets)
156
+ if loaded:
157
+ print(f"[Agents] Loaded {loaded} user presets from database")
158
+ except Exception as e:
159
+ print(f"[Warning] Failed to load presets from DB: {e}")
160
+ finally:
161
+ await session.close()
162
+
163
+ def add_agent(self, name: str, graph, description: str = ""):
164
+ """Register a pre-built CompiledStateGraph as a named agent.
165
+
166
+ Args:
167
+ name: Unique agent identifier
168
+ graph: A compiled LangGraph (must have ainvoke and astream_events)
169
+ description: Human-readable description
170
+ """
171
+ if name in self.engine._agents:
172
+ raise ValueError(f"Agent '{name}' already registered")
173
+
174
+ from lc_agent.core.models import AgentPreset
175
+
176
+ self.engine._agents[name] = graph
177
+ self.engine._agent_mcp_gen[name] = self.engine._mcp_generation
178
+ preset = AgentPreset(
179
+ id=name,
180
+ name=name,
181
+ system_prompt=description or f"Custom agent: {name}",
182
+ default_model="custom",
183
+ source="code",
184
+ )
185
+ self.engine._custom_presets[name] = preset
186
+
187
+ def run(self):
188
+ """Start the server (blocking)."""
189
+ from lc_agent import __version__
190
+
191
+ print(f"\n lc_agent v{__version__}")
192
+ print(f" Web UI: http://{self.host}:{self.port}")
193
+ print(f" API Docs: http://{self.host}:{self.port}/api/docs\n")
194
+ uvicorn.run(self.fastapi_app, host=self.host, port=self.port)
@@ -0,0 +1,3 @@
1
+ from lc_agent.config.loader import load_config, load_config_from_file, substitute_env_vars
2
+
3
+ __all__ = ["load_config", "load_config_from_file", "substitute_env_vars"]
@@ -0,0 +1,86 @@
1
+ import os
2
+ import re
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import commentjson
7
+ from dotenv import load_dotenv
8
+
9
+ ENV_PATTERN = re.compile(r"\{env:([^}]+)\}")
10
+
11
+
12
+ def substitute_env_vars(data: Any) -> Any:
13
+ """Recursively replace {env:VAR_NAME} patterns with environment variable values."""
14
+ if isinstance(data, str):
15
+ match = ENV_PATTERN.fullmatch(data)
16
+ if match:
17
+ var_name = match.group(1)
18
+ value = os.environ.get(var_name)
19
+ if value is None:
20
+ raise ValueError(f"Environment variable '{var_name}' not found")
21
+ return value
22
+ def replacer(m):
23
+ var_name = m.group(1)
24
+ value = os.environ.get(var_name)
25
+ if value is None:
26
+ raise ValueError(f"Environment variable '{var_name}' not found")
27
+ return value
28
+ return ENV_PATTERN.sub(replacer, data)
29
+ elif isinstance(data, dict):
30
+ return {k: substitute_env_vars(v) for k, v in data.items()}
31
+ elif isinstance(data, list):
32
+ return [substitute_env_vars(item) for item in data]
33
+ return data
34
+
35
+
36
+ def load_config_from_file(path: str) -> dict:
37
+ """Load a JSONC configuration file and apply env substitution."""
38
+ file_path = Path(path)
39
+ if not file_path.exists():
40
+ raise FileNotFoundError(f"Config file not found: {path}")
41
+
42
+ with open(file_path, "r", encoding="utf-8") as f:
43
+ raw = commentjson.load(f)
44
+
45
+ return substitute_env_vars(raw)
46
+
47
+
48
+ def load_config(
49
+ config_path: str | None = None,
50
+ dotenv_path: str | None = None,
51
+ ) -> dict:
52
+ """Load configuration with priority: explicit path > ./config.jsonc > ~/.lc_agent/config.jsonc > defaults."""
53
+ if dotenv_path:
54
+ load_dotenv(dotenv_path)
55
+ else:
56
+ load_dotenv()
57
+
58
+ search_paths = []
59
+ if config_path:
60
+ search_paths.append(Path(config_path))
61
+ search_paths.append(Path.cwd() / "config.jsonc")
62
+ search_paths.append(Path.home() / ".lc_agent" / "config.jsonc")
63
+
64
+ for p in search_paths:
65
+ if p.exists():
66
+ config = load_config_from_file(str(p))
67
+ config["_config_path"] = str(p)
68
+ config["_project_root"] = str(p.parent)
69
+ return config
70
+
71
+ return {
72
+ "provider": {},
73
+ "agent": {
74
+ "system_prompt": "You are a helpful assistant.",
75
+ "default_model": "",
76
+ "streaming": True,
77
+ },
78
+ "database": {
79
+ "url": "sqlite+aiosqlite:///./lc_agent_data.db",
80
+ "checkpoint_path": "./lc_agent_checkpoints.db",
81
+ },
82
+ "skills": {"directory": "./skills"},
83
+ "mcp_servers": {},
84
+ "_config_path": None,
85
+ "_project_root": str(Path.cwd()),
86
+ }
@@ -0,0 +1,48 @@
1
+ from pydantic import BaseModel, Field
2
+
3
+
4
+ class ModelConfig(BaseModel):
5
+ id: str
6
+ context_limit: int = 8000
7
+ max_output_tokens: int = 0
8
+
9
+
10
+ class ProviderConfig(BaseModel):
11
+ api_key: str = ""
12
+ base_url: str = ""
13
+ models: list[ModelConfig] = Field(default_factory=list)
14
+
15
+
16
+ class DatabaseConfig(BaseModel):
17
+ url: str = "sqlite+aiosqlite:///./lc_agent_data.db"
18
+ checkpoint_path: str = "./lc_agent_checkpoints.db"
19
+
20
+
21
+ class SkillsConfig(BaseModel):
22
+ directory: str = "./skills"
23
+
24
+
25
+ class McpServerConfig(BaseModel):
26
+ type: str = "local" # "local", "sse", "http"
27
+ command: str | list[str] = ""
28
+ args: list[str] = Field(default_factory=list)
29
+ env: dict[str, str] = Field(default_factory=dict)
30
+ url: str = ""
31
+ enabled: bool = True
32
+
33
+
34
+ class AppConfig(BaseModel):
35
+ """Application configuration schema."""
36
+
37
+ provider: dict[str, ProviderConfig | dict] = Field(default_factory=dict)
38
+ agent: dict = Field(default_factory=lambda: {
39
+ "system_prompt": "You are a helpful assistant.",
40
+ "default_model": "",
41
+ "streaming": True,
42
+ })
43
+ mcp: dict = Field(default_factory=dict)
44
+ database: DatabaseConfig = Field(default_factory=DatabaseConfig)
45
+ session: dict = Field(default_factory=lambda: {"db_path": ""})
46
+ ui: dict = Field(default_factory=dict)
47
+ skills: SkillsConfig = Field(default_factory=SkillsConfig)
48
+ mcp_servers: dict[str, McpServerConfig] = Field(default_factory=dict)
@@ -0,0 +1,5 @@
1
+ # lc_agent/core/__init__.py
2
+ from lc_agent.core.engine import AgentEngine
3
+ from lc_agent.core.models import AgentPreset, ModelInfo
4
+
5
+ __all__ = ["AgentEngine", "AgentPreset", "ModelInfo"]
@@ -0,0 +1,48 @@
1
+ """ChatOpenAI subclass that extracts reasoning_content from streaming deltas.
2
+
3
+ ChatOpenAI only supports official OpenAI API fields. Many providers (DeepSeek,
4
+ GLM, etc.) return a non-standard `reasoning_content` field in the streaming
5
+ delta for chain-of-thought / thinking content. This subclass captures it into
6
+ `additional_kwargs["reasoning_content"]` so downstream handlers can display it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from langchain_core.messages import AIMessageChunk
12
+ from langchain_core.outputs import ChatGenerationChunk
13
+ from langchain_openai import ChatOpenAI
14
+
15
+
16
+ class ChatOpenAIReasoning(ChatOpenAI):
17
+ """ChatOpenAI with reasoning_content extraction from streaming deltas.
18
+
19
+ Drop-in replacement for ChatOpenAI. Works with any provider that returns
20
+ `reasoning_content` or `reasoning` in the streaming delta dict (e.g.
21
+ DeepSeek, GLM with thinking mode, OpenRouter).
22
+ """
23
+
24
+ def _convert_chunk_to_generation_chunk(
25
+ self,
26
+ chunk: dict,
27
+ default_chunk_class: type,
28
+ base_generation_info: dict | None,
29
+ ) -> ChatGenerationChunk | None:
30
+ generation_chunk = super()._convert_chunk_to_generation_chunk(
31
+ chunk, default_chunk_class, base_generation_info,
32
+ )
33
+ if generation_chunk is None:
34
+ return None
35
+
36
+ choices = chunk.get("choices") or chunk.get("chunk", {}).get("choices") or []
37
+ if not choices:
38
+ return generation_chunk
39
+
40
+ delta = choices[0].get("delta") or {}
41
+ if isinstance(generation_chunk.message, AIMessageChunk):
42
+ reasoning = delta.get("reasoning_content")
43
+ if reasoning is None:
44
+ reasoning = delta.get("reasoning")
45
+ if reasoning is not None:
46
+ generation_chunk.message.additional_kwargs["reasoning_content"] = reasoning
47
+
48
+ return generation_chunk