jvserve 2.0.15__tar.gz → 2.1.1__tar.gz
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.
Potentially problematic release.
This version of jvserve might be problematic. Click here for more details.
- {jvserve-2.0.15 → jvserve-2.1.1}/PKG-INFO +4 -3
- {jvserve-2.0.15 → jvserve-2.1.1}/README.md +1 -1
- {jvserve-2.0.15 → jvserve-2.1.1}/jvserve/__init__.py +4 -2
- jvserve-2.1.1/jvserve/cli.py +256 -0
- jvserve-2.1.1/jvserve/lib/agent_interface.py +97 -0
- {jvserve-2.0.15 → jvserve-2.1.1}/jvserve/lib/file_interface.py +1 -1
- jvserve-2.1.1/jvserve/lib/jac_interface.py +222 -0
- {jvserve-2.0.15 → jvserve-2.1.1}/jvserve.egg-info/PKG-INFO +4 -3
- {jvserve-2.0.15 → jvserve-2.1.1}/jvserve.egg-info/SOURCES.txt +1 -1
- {jvserve-2.0.15 → jvserve-2.1.1}/jvserve.egg-info/requires.txt +2 -1
- {jvserve-2.0.15 → jvserve-2.1.1}/setup.py +5 -2
- {jvserve-2.0.15 → jvserve-2.1.1}/tests/test_file_interface.py +1 -1
- {jvserve-2.0.15 → jvserve-2.1.1}/tests/test_jvserve.py +36 -64
- jvserve-2.0.15/jvserve/cli.py +0 -270
- jvserve-2.0.15/jvserve/lib/agent_interface.py +0 -965
- jvserve-2.0.15/jvserve/lib/agent_pulse.py +0 -63
- {jvserve-2.0.15 → jvserve-2.1.1}/LICENSE +0 -0
- {jvserve-2.0.15 → jvserve-2.1.1}/jvserve/lib/__init__.py +0 -0
- {jvserve-2.0.15 → jvserve-2.1.1}/jvserve/lib/jvlogger.py +0 -0
- {jvserve-2.0.15 → jvserve-2.1.1}/jvserve.egg-info/dependency_links.txt +0 -0
- {jvserve-2.0.15 → jvserve-2.1.1}/jvserve.egg-info/entry_points.txt +0 -0
- {jvserve-2.0.15 → jvserve-2.1.1}/jvserve.egg-info/top_level.txt +0 -0
- {jvserve-2.0.15 → jvserve-2.1.1}/setup.cfg +0 -0
- {jvserve-2.0.15 → jvserve-2.1.1}/tests/test_jvlogger.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: jvserve
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.1
|
|
4
4
|
Summary: FastAPI webserver for loading and interaction with JIVAS agents.
|
|
5
5
|
Home-page: https://github.com/TrueSelph/jvserve
|
|
6
6
|
Author: TrueSelph Inc.
|
|
@@ -9,7 +9,8 @@ Keywords: jivas
|
|
|
9
9
|
Requires-Python: >=3.12.0
|
|
10
10
|
Description-Content-Type: text/markdown
|
|
11
11
|
License-File: LICENSE
|
|
12
|
-
Requires-Dist: jac-cloud
|
|
12
|
+
Requires-Dist: jac-cloud
|
|
13
|
+
Requires-Dist: jaclang==0.8.4
|
|
13
14
|
Requires-Dist: pyaml>=25.1.0
|
|
14
15
|
Requires-Dist: requests>=2.32.3
|
|
15
16
|
Requires-Dist: aiohttp>=3.10.10
|
|
@@ -80,7 +81,7 @@ jac jvfileserve ./static
|
|
|
80
81
|
### Supported Arguments
|
|
81
82
|
|
|
82
83
|
- **filename**: Path to your JAC file.
|
|
83
|
-
- **host**: Host address to bind the server (default: `
|
|
84
|
+
- **host**: Host address to bind the server (default: `localhost`).
|
|
84
85
|
- **port**: Port number to bind the server (default: `8000`).
|
|
85
86
|
- **loglevel**: Logging level (default: `INFO`).
|
|
86
87
|
- **workers**: Number of worker processes (optional).
|
|
@@ -45,7 +45,7 @@ jac jvfileserve ./static
|
|
|
45
45
|
### Supported Arguments
|
|
46
46
|
|
|
47
47
|
- **filename**: Path to your JAC file.
|
|
48
|
-
- **host**: Host address to bind the server (default: `
|
|
48
|
+
- **host**: Host address to bind the server (default: `localhost`).
|
|
49
49
|
- **port**: Port number to bind the server (default: `8000`).
|
|
50
50
|
- **loglevel**: Logging level (default: `INFO`).
|
|
51
51
|
- **workers**: Number of worker processes (optional).
|
|
@@ -4,5 +4,7 @@ jvserve package initialization.
|
|
|
4
4
|
This package provides the webserver for loading and interacting with JIVAS agents.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
from importlib.metadata import version
|
|
8
|
+
|
|
9
|
+
__version__ = version("jvserve")
|
|
10
|
+
__supported__jivas__versions__ = [__version__]
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Module for registering CLI plugins for jaseci."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from pickle import load
|
|
9
|
+
from typing import AsyncIterator, Optional
|
|
10
|
+
|
|
11
|
+
import aiohttp
|
|
12
|
+
import pymongo
|
|
13
|
+
from bson import ObjectId
|
|
14
|
+
from dotenv import load_dotenv
|
|
15
|
+
from fastapi import FastAPI, HTTPException, Response
|
|
16
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
17
|
+
from fastapi.responses import FileResponse, StreamingResponse
|
|
18
|
+
from jac_cloud.core.context import JaseciContext
|
|
19
|
+
from jac_cloud.jaseci.main import FastAPI as JaseciFastAPI # type: ignore
|
|
20
|
+
from jac_cloud.plugin.jaseci import NodeAnchor
|
|
21
|
+
from jaclang import JacMachine as Jac
|
|
22
|
+
from jaclang.cli.cmdreg import cmd_registry
|
|
23
|
+
from jaclang.runtimelib.machine import hookimpl
|
|
24
|
+
from watchfiles import Change, run_process
|
|
25
|
+
|
|
26
|
+
from jvserve.lib.agent_interface import AgentInterface
|
|
27
|
+
from jvserve.lib.file_interface import (
|
|
28
|
+
DEFAULT_FILES_ROOT,
|
|
29
|
+
FILE_INTERFACE,
|
|
30
|
+
file_interface,
|
|
31
|
+
)
|
|
32
|
+
from jvserve.lib.jvlogger import JVLogger
|
|
33
|
+
|
|
34
|
+
# quiet the jac_cloud logger down to errors only
|
|
35
|
+
# jac cloud dumps payload details to console which makes it hard to debug in JIVAS
|
|
36
|
+
os.environ["LOGGER_LEVEL"] = "ERROR"
|
|
37
|
+
load_dotenv(".env")
|
|
38
|
+
# Set up logging
|
|
39
|
+
JVLogger.setup_logging(level="INFO")
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
# Global for MongoDB collection with thread-safe initialization
|
|
43
|
+
url_proxy_collection = None
|
|
44
|
+
collection_init_lock = asyncio.Lock()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def get_url_proxy_collection() -> pymongo.collection.Collection:
|
|
48
|
+
"""Thread-safe initialization of MongoDB collection"""
|
|
49
|
+
global url_proxy_collection
|
|
50
|
+
if url_proxy_collection is None:
|
|
51
|
+
async with collection_init_lock:
|
|
52
|
+
if url_proxy_collection is None: # Double-check locking
|
|
53
|
+
loop = asyncio.get_running_loop()
|
|
54
|
+
with ThreadPoolExecutor() as pool:
|
|
55
|
+
url_proxy_collection = await loop.run_in_executor(
|
|
56
|
+
pool,
|
|
57
|
+
lambda: NodeAnchor.Collection.get_collection("url_proxies"),
|
|
58
|
+
)
|
|
59
|
+
return url_proxy_collection
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def serve_proxied_file(
|
|
63
|
+
file_path: str,
|
|
64
|
+
) -> FileResponse | StreamingResponse | Response:
|
|
65
|
+
"""Serve a proxied file from a remote or local URL (async version)"""
|
|
66
|
+
if FILE_INTERFACE == "local":
|
|
67
|
+
root_path = os.environ.get("JIVAS_FILES_ROOT_PATH", DEFAULT_FILES_ROOT)
|
|
68
|
+
full_path = os.path.join(root_path, file_path)
|
|
69
|
+
if not os.path.exists(full_path):
|
|
70
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
71
|
+
return FileResponse(full_path)
|
|
72
|
+
|
|
73
|
+
file_url = file_interface.get_file_url(file_path)
|
|
74
|
+
|
|
75
|
+
# Security check to prevent recursive calls
|
|
76
|
+
if file_url and ("localhost" in file_url or "127.0.0.1" in file_url):
|
|
77
|
+
raise HTTPException(
|
|
78
|
+
status_code=500, detail="Environment misconfiguration detected"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
if not file_url:
|
|
82
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
async with aiohttp.ClientSession() as session:
|
|
86
|
+
async with session.get(file_url) as response:
|
|
87
|
+
response.raise_for_status()
|
|
88
|
+
|
|
89
|
+
return StreamingResponse(
|
|
90
|
+
response.content.iter_chunked(8192),
|
|
91
|
+
media_type=response.headers.get(
|
|
92
|
+
"Content-Type", "application/octet-stream"
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
except aiohttp.ClientError as e:
|
|
96
|
+
raise HTTPException(status_code=502, detail=f"File fetch error: {str(e)}")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def run_jivas(filename: str, host: str = "localhost", port: int = 8000) -> None:
|
|
100
|
+
"""Starts JIVAS server with integrated file services"""
|
|
101
|
+
|
|
102
|
+
# Create agent interface instance with configuration
|
|
103
|
+
agent_interface = AgentInterface.get_instance(host=host, port=port)
|
|
104
|
+
|
|
105
|
+
base, mod = os.path.split(filename)
|
|
106
|
+
base = base if base else "./"
|
|
107
|
+
mod = mod[:-4]
|
|
108
|
+
|
|
109
|
+
JaseciFastAPI.enable()
|
|
110
|
+
|
|
111
|
+
ctx = JaseciContext.create(None)
|
|
112
|
+
if filename.endswith(".jac"):
|
|
113
|
+
Jac.jac_import(target=mod, base_path=base, override_name="__main__")
|
|
114
|
+
elif filename.endswith(".jir"):
|
|
115
|
+
with open(filename, "rb") as f:
|
|
116
|
+
Jac.attach_program(load(f))
|
|
117
|
+
Jac.jac_import(target=mod, base_path=base, override_name="__main__")
|
|
118
|
+
else:
|
|
119
|
+
raise ValueError("Not a valid file!\nOnly supports `.jac` and `.jir`")
|
|
120
|
+
|
|
121
|
+
# Define post-startup function to run AFTER server is ready
|
|
122
|
+
async def post_startup() -> None:
|
|
123
|
+
"""Wait for server to be ready before initializing agents"""
|
|
124
|
+
health_url = f"http://{host}:{port}/healthz"
|
|
125
|
+
max_retries = 10
|
|
126
|
+
retry_delay = 1.0
|
|
127
|
+
|
|
128
|
+
for attempt in range(max_retries):
|
|
129
|
+
try:
|
|
130
|
+
async with aiohttp.ClientSession() as session:
|
|
131
|
+
async with session.get(health_url, timeout=1) as response:
|
|
132
|
+
if response.status == 200:
|
|
133
|
+
logger.info("Server is ready, initializing agents...")
|
|
134
|
+
await agent_interface.init_agents()
|
|
135
|
+
return
|
|
136
|
+
except (aiohttp.ClientConnectorError, asyncio.TimeoutError) as e:
|
|
137
|
+
logger.warning(
|
|
138
|
+
f"Server not ready yet (attempt {attempt + 1} / {max_retries}): {e}"
|
|
139
|
+
)
|
|
140
|
+
await asyncio.sleep(retry_delay)
|
|
141
|
+
retry_delay *= 1.5 # Exponential backoff
|
|
142
|
+
|
|
143
|
+
logger.error(
|
|
144
|
+
"Server did not become ready in time. Agent initialization skipped."
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# set up lifespan events
|
|
148
|
+
async def on_startup() -> None:
|
|
149
|
+
logger.info("JIVAS is starting up...")
|
|
150
|
+
# Start initialization in background without blocking
|
|
151
|
+
asyncio.create_task(post_startup())
|
|
152
|
+
|
|
153
|
+
async def on_shutdown() -> None:
|
|
154
|
+
logger.info("JIVAS is shutting down...")
|
|
155
|
+
|
|
156
|
+
app = JaseciFastAPI.get()
|
|
157
|
+
app_lifespan = app.router.lifespan_context
|
|
158
|
+
|
|
159
|
+
@asynccontextmanager
|
|
160
|
+
async def lifespan_wrapper(app: FastAPI) -> AsyncIterator[Optional[str]]:
|
|
161
|
+
await on_startup()
|
|
162
|
+
async with app_lifespan(app) as maybe_state:
|
|
163
|
+
yield maybe_state
|
|
164
|
+
await on_shutdown()
|
|
165
|
+
|
|
166
|
+
app.router.lifespan_context = lifespan_wrapper
|
|
167
|
+
|
|
168
|
+
# Add CORS middleware to main app
|
|
169
|
+
app.add_middleware(
|
|
170
|
+
CORSMiddleware,
|
|
171
|
+
allow_origins=["*"],
|
|
172
|
+
allow_credentials=True,
|
|
173
|
+
allow_methods=["*"],
|
|
174
|
+
allow_headers=["*"],
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Ensure the local file directory exists if that's the interface
|
|
178
|
+
if FILE_INTERFACE == "local":
|
|
179
|
+
directory = os.environ.get("JIVAS_FILES_ROOT_PATH", DEFAULT_FILES_ROOT)
|
|
180
|
+
if not os.path.exists(directory):
|
|
181
|
+
os.makedirs(directory, exist_ok=True)
|
|
182
|
+
|
|
183
|
+
# Setup file serving endpoint for both local and S3
|
|
184
|
+
@app.get("/files/{file_path:path}", response_model=None)
|
|
185
|
+
async def serve_file(
|
|
186
|
+
file_path: str,
|
|
187
|
+
) -> FileResponse | StreamingResponse | Response:
|
|
188
|
+
# The serve_proxied_file function already handles both local and S3 cases
|
|
189
|
+
return await serve_proxied_file(file_path)
|
|
190
|
+
|
|
191
|
+
# Setup URL proxy endpoint
|
|
192
|
+
@app.get("/f/{file_id:path}", response_model=None)
|
|
193
|
+
async def get_proxied_file(
|
|
194
|
+
file_id: str,
|
|
195
|
+
) -> FileResponse | StreamingResponse | Response:
|
|
196
|
+
params = file_id.split("/")
|
|
197
|
+
object_id = params[0]
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
# Get MongoDB collection (thread-safe initialization)
|
|
201
|
+
collection = await get_url_proxy_collection()
|
|
202
|
+
|
|
203
|
+
# Run blocking MongoDB operation in thread pool
|
|
204
|
+
loop = asyncio.get_running_loop()
|
|
205
|
+
file_details = await loop.run_in_executor(
|
|
206
|
+
None, lambda: collection.find_one({"_id": ObjectId(object_id)})
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
descriptor_path = os.environ.get("JIVAS_DESCRIPTOR_ROOT_PATH")
|
|
210
|
+
|
|
211
|
+
if file_details:
|
|
212
|
+
if descriptor_path and descriptor_path in file_details["path"]:
|
|
213
|
+
return Response(status_code=403)
|
|
214
|
+
return await serve_proxied_file(file_details["path"])
|
|
215
|
+
|
|
216
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
217
|
+
except Exception as e:
|
|
218
|
+
logger.error(f"Proxy error: {str(e)}")
|
|
219
|
+
raise HTTPException(status_code=500, detail="Internal server error")
|
|
220
|
+
|
|
221
|
+
ctx.close()
|
|
222
|
+
# Run the app
|
|
223
|
+
JaseciFastAPI.start(host=host, port=port)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def log_reload(changes: set[tuple[Change, str]]) -> None:
|
|
227
|
+
"""Log changes."""
|
|
228
|
+
num_of_changes = len(changes)
|
|
229
|
+
logger.warning(
|
|
230
|
+
f'Detected {num_of_changes} change{"s" if num_of_changes > 1 else ""}'
|
|
231
|
+
)
|
|
232
|
+
for change in changes:
|
|
233
|
+
logger.warning(f"{change[1]} ({change[0].name})")
|
|
234
|
+
logger.warning("Reloading ...")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class JacCmd:
|
|
238
|
+
"""Jac CLI."""
|
|
239
|
+
|
|
240
|
+
@staticmethod
|
|
241
|
+
@hookimpl
|
|
242
|
+
def create_cmd() -> None:
|
|
243
|
+
"""Create Jac CLI cmds."""
|
|
244
|
+
|
|
245
|
+
@cmd_registry.register
|
|
246
|
+
def jvserve(filename: str, host: str = "localhost", port: int = 8000) -> None:
|
|
247
|
+
"""Launch unified JIVAS server with file services"""
|
|
248
|
+
watchdir = os.path.join(
|
|
249
|
+
os.path.abspath(os.path.dirname(filename)), "actions", ""
|
|
250
|
+
)
|
|
251
|
+
run_process(
|
|
252
|
+
watchdir,
|
|
253
|
+
target=run_jivas,
|
|
254
|
+
args=(filename, host, port),
|
|
255
|
+
callback=log_reload,
|
|
256
|
+
)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Agent Interface class and methods for interaction with Jivas."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import traceback
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from jvserve.lib.jac_interface import JacInterface
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AgentInterface:
|
|
14
|
+
"""Agent Interface for Jivas with proper concurrency handling."""
|
|
15
|
+
|
|
16
|
+
_instance = None
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
def __init__(self, host: str = "localhost", port: int = 8000) -> None:
|
|
20
|
+
"""Initialize the AgentInterface with JacInterface."""
|
|
21
|
+
self._jac = JacInterface(host, port)
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def get_instance(
|
|
25
|
+
cls, host: str = "localhost", port: int = 8000
|
|
26
|
+
) -> "AgentInterface":
|
|
27
|
+
"""Get a singleton instance of AgentInterface."""
|
|
28
|
+
if cls._instance is None:
|
|
29
|
+
env_host = os.environ.get("JIVAS_HOST", "localhost")
|
|
30
|
+
env_port = int(os.environ.get("JIVAS_PORT", "8000"))
|
|
31
|
+
host = host or env_host
|
|
32
|
+
port = port or env_port
|
|
33
|
+
cls._instance = cls(host, port)
|
|
34
|
+
return cls._instance
|
|
35
|
+
|
|
36
|
+
async def init_agents(self) -> None:
|
|
37
|
+
"""Initialize agents - async compatible"""
|
|
38
|
+
try:
|
|
39
|
+
if not await self._jac.spawn_walker_async(
|
|
40
|
+
walker_name="init_agents",
|
|
41
|
+
module_name="jivas.agent.core.init_agents",
|
|
42
|
+
attributes={"reporting": False},
|
|
43
|
+
):
|
|
44
|
+
self.logger.error("Agent initialization failed")
|
|
45
|
+
except Exception as e:
|
|
46
|
+
self._jac.reset()
|
|
47
|
+
self.logger.error(f"Init error: {e}\n{traceback.format_exc()}")
|
|
48
|
+
|
|
49
|
+
def api_pulse(self, action_label: str, agent_id: str) -> dict:
|
|
50
|
+
"""Synchronous pulse API call"""
|
|
51
|
+
if not self._jac.is_valid():
|
|
52
|
+
self.logger.warning("Invalid API state for pulse")
|
|
53
|
+
return {}
|
|
54
|
+
|
|
55
|
+
# Clean parameters
|
|
56
|
+
action_label = action_label.replace("action_label=", "")
|
|
57
|
+
agent_id = agent_id.replace("agent_id=", "")
|
|
58
|
+
|
|
59
|
+
endpoint = f"http://{self._jac.host}:{self._jac.port}/walker/do_pulse"
|
|
60
|
+
headers = {"Authorization": f"Bearer {self._jac.token}"}
|
|
61
|
+
payload = {"action_label": action_label, "agent_id": agent_id}
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
response = requests.post(
|
|
65
|
+
endpoint, json=payload, headers=headers, timeout=10
|
|
66
|
+
)
|
|
67
|
+
if response.status_code == 200:
|
|
68
|
+
return response.json().get("reports", {})
|
|
69
|
+
if response.status_code == 401:
|
|
70
|
+
self._jac.reset()
|
|
71
|
+
except Exception as e:
|
|
72
|
+
self._jac.reset()
|
|
73
|
+
self.logger.error(f"Pulse error: {e}\n{traceback.format_exc()}")
|
|
74
|
+
|
|
75
|
+
return {}
|
|
76
|
+
|
|
77
|
+
async def _finalize_interaction(
|
|
78
|
+
self, interaction_node: Any, full_text: str, total_tokens: int
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Finalize interaction in background"""
|
|
81
|
+
try:
|
|
82
|
+
interaction_node.set_text_message(message=full_text)
|
|
83
|
+
interaction_node.add_tokens(total_tokens)
|
|
84
|
+
|
|
85
|
+
await self._jac.spawn_walker_async(
|
|
86
|
+
walker_name="update_interaction",
|
|
87
|
+
module_name="jivas.agent.memory.update_interaction",
|
|
88
|
+
attributes={"interaction_data": interaction_node.export()},
|
|
89
|
+
)
|
|
90
|
+
except Exception as e:
|
|
91
|
+
self.logger.error(f"Finalize error: {e}")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# Module-level functions
|
|
95
|
+
def do_pulse(action_label: str, agent_id: str) -> dict:
|
|
96
|
+
"""Execute pulse action synchronously"""
|
|
97
|
+
return AgentInterface.get_instance().api_pulse(action_label, agent_id)
|
|
@@ -78,7 +78,7 @@ class LocalFileInterface(FileInterface):
|
|
|
78
78
|
"""Get URL for accessing local file via HTTP."""
|
|
79
79
|
file_path = os.path.join(self.__root_dir, filename)
|
|
80
80
|
if os.path.exists(file_path):
|
|
81
|
-
return f"{os.environ.get('JIVAS_FILES_URL', 'http://localhost:
|
|
81
|
+
return f"{os.environ.get('JIVAS_FILES_URL', 'http://localhost:8000/files')}/{filename}"
|
|
82
82
|
return None
|
|
83
83
|
|
|
84
84
|
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""JacInterface: A connection and context state provider for Jac Runtime."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import traceback
|
|
9
|
+
from typing import Optional, Tuple
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
from fastapi import Request
|
|
13
|
+
from jac_cloud.core.archetype import ( # type: ignore
|
|
14
|
+
NodeAnchor,
|
|
15
|
+
WalkerArchetype,
|
|
16
|
+
)
|
|
17
|
+
from jac_cloud.core.context import (
|
|
18
|
+
JASECI_CONTEXT,
|
|
19
|
+
JaseciContext,
|
|
20
|
+
)
|
|
21
|
+
from jac_cloud.plugin.jaseci import JacPlugin
|
|
22
|
+
from jaclang.runtimelib.machine import JacMachine
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class JacInterface:
|
|
26
|
+
"""Thread-safe connection and context state provider for Jac Runtime with auto-authentication."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, host: str = "localhost", port: int = 8000) -> None:
|
|
29
|
+
"""Initialize JacInterface with host and port."""
|
|
30
|
+
self.host = host
|
|
31
|
+
self.port = port
|
|
32
|
+
self.root_id = ""
|
|
33
|
+
self.token = ""
|
|
34
|
+
self.expiration = 0.0
|
|
35
|
+
self._lock = threading.RLock() # Thread-safe lock
|
|
36
|
+
self.logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
def update(self, root_id: str, token: str, expiration: float) -> None:
|
|
39
|
+
"""Thread-safe state update"""
|
|
40
|
+
with self._lock:
|
|
41
|
+
self.root_id = root_id
|
|
42
|
+
self.token = token
|
|
43
|
+
self.expiration = expiration
|
|
44
|
+
|
|
45
|
+
def reset(self) -> None:
|
|
46
|
+
"""Thread-safe state reset"""
|
|
47
|
+
with self._lock:
|
|
48
|
+
self.root_id = ""
|
|
49
|
+
self.token = ""
|
|
50
|
+
self.expiration = 0.0
|
|
51
|
+
|
|
52
|
+
def is_valid(self) -> bool:
|
|
53
|
+
"""Thread-safe validity check"""
|
|
54
|
+
with self._lock:
|
|
55
|
+
return bool(
|
|
56
|
+
self.token and self.expiration and self.expiration > time.time()
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def get_state(self) -> Tuple[str, str, Optional[float]]:
|
|
60
|
+
"""Get current state with auto-authentication"""
|
|
61
|
+
if not self.is_valid():
|
|
62
|
+
self._authenticate()
|
|
63
|
+
with self._lock:
|
|
64
|
+
return (self.root_id, self.token, self.expiration)
|
|
65
|
+
|
|
66
|
+
def get_context(self, request: Request | None = None) -> Optional[JaseciContext]:
|
|
67
|
+
"""Get Jaseci context with proper thread safety."""
|
|
68
|
+
|
|
69
|
+
state = self.get_state()
|
|
70
|
+
if not state or not self.is_valid():
|
|
71
|
+
self.logger.error("Failed to get valid state for Jaseci context")
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
root_id = state[0]
|
|
76
|
+
entry_node = NodeAnchor.ref(f"n:root:{root_id}") # type: ignore
|
|
77
|
+
if not entry_node:
|
|
78
|
+
self.logger.error("Failed to resolve entry node from root_id")
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
ctx = JaseciContext.create(request, entry_node)
|
|
82
|
+
if not ctx:
|
|
83
|
+
self.logger.error("Failed to create JaseciContext with entry node")
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
ctx.system_root = entry_node
|
|
87
|
+
ctx.root_state = entry_node
|
|
88
|
+
|
|
89
|
+
# Clean up any existing context before setting new one
|
|
90
|
+
existing_ctx = JASECI_CONTEXT.get(None)
|
|
91
|
+
if existing_ctx:
|
|
92
|
+
try:
|
|
93
|
+
existing_ctx.close()
|
|
94
|
+
except Exception as e:
|
|
95
|
+
self.logger.warning(f"Error while closing existing context: {e}")
|
|
96
|
+
|
|
97
|
+
JASECI_CONTEXT.set(ctx)
|
|
98
|
+
return ctx
|
|
99
|
+
|
|
100
|
+
except Exception as e:
|
|
101
|
+
self.logger.error(
|
|
102
|
+
f"Failed to create JaseciContext: {e}\n{traceback.format_exc()}"
|
|
103
|
+
)
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
def spawn_walker(
|
|
107
|
+
self,
|
|
108
|
+
walker_name: str | None,
|
|
109
|
+
module_name: str,
|
|
110
|
+
attributes: dict = {}, # noqa: B006
|
|
111
|
+
request: Request | None = None, # noqa: B006
|
|
112
|
+
) -> Optional[WalkerArchetype]:
|
|
113
|
+
"""Spawn walker with proper context handling and thread safety"""
|
|
114
|
+
|
|
115
|
+
if not all([walker_name, module_name]):
|
|
116
|
+
self.logger.error("Missing required parameters for spawning walker")
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
ctx = self.get_context(request)
|
|
120
|
+
if not ctx:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
if module_name not in JacMachine.list_modules():
|
|
125
|
+
self.logger.error(f"Module {module_name} not loaded")
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
entry_node = ctx.entry_node.archetype
|
|
129
|
+
|
|
130
|
+
return JacPlugin.spawn(
|
|
131
|
+
JacMachine.spawn_walker(walker_name, attributes, module_name),
|
|
132
|
+
entry_node,
|
|
133
|
+
)
|
|
134
|
+
except Exception as e:
|
|
135
|
+
self.logger.error(f"Error spawning walker: {e}\n{traceback.format_exc()}")
|
|
136
|
+
return None
|
|
137
|
+
finally:
|
|
138
|
+
if ctx:
|
|
139
|
+
ctx.close()
|
|
140
|
+
if JASECI_CONTEXT.get(None) == ctx:
|
|
141
|
+
JASECI_CONTEXT.set(None)
|
|
142
|
+
|
|
143
|
+
def _authenticate(self) -> None:
|
|
144
|
+
"""Thread-safe authentication with retry logic and improved error handling"""
|
|
145
|
+
user = os.environ.get("JIVAS_USER")
|
|
146
|
+
password = os.environ.get("JIVAS_PASSWORD")
|
|
147
|
+
if not user or not password:
|
|
148
|
+
self.logger.error("Missing JIVAS_USER or JIVAS_PASSWORD")
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
login_url = f"http://{self.host}:{self.port}/user/login"
|
|
152
|
+
register_url = f"http://{self.host}:{self.port}/user/register"
|
|
153
|
+
|
|
154
|
+
with self._lock:
|
|
155
|
+
try:
|
|
156
|
+
# Try login first
|
|
157
|
+
response = requests.post(
|
|
158
|
+
login_url, json={"email": user, "password": password}, timeout=15
|
|
159
|
+
)
|
|
160
|
+
self.logger.info(f"Login response status: {response.status_code}")
|
|
161
|
+
if response.status_code == 200:
|
|
162
|
+
self._process_auth_response(response.json())
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
# Register if login fails
|
|
166
|
+
reg_response = requests.post(
|
|
167
|
+
register_url, json={"email": user, "password": password}, timeout=15
|
|
168
|
+
)
|
|
169
|
+
self.logger.info(
|
|
170
|
+
f"Register response status: {reg_response.status_code}"
|
|
171
|
+
)
|
|
172
|
+
if reg_response.status_code == 201:
|
|
173
|
+
# Retry login after registration
|
|
174
|
+
login_response = requests.post(
|
|
175
|
+
login_url,
|
|
176
|
+
json={"email": user, "password": password},
|
|
177
|
+
timeout=15,
|
|
178
|
+
)
|
|
179
|
+
self.logger.info(
|
|
180
|
+
f"Retry login response status: {login_response.status_code}"
|
|
181
|
+
)
|
|
182
|
+
if login_response.status_code == 200:
|
|
183
|
+
self._process_auth_response(login_response.json())
|
|
184
|
+
except requests.exceptions.RequestException as e:
|
|
185
|
+
self.logger.error(f"Network error during authentication: {e}")
|
|
186
|
+
except Exception as e:
|
|
187
|
+
self.logger.error(
|
|
188
|
+
f"Authentication failed: {e}\n{traceback.format_exc()}"
|
|
189
|
+
)
|
|
190
|
+
self.reset()
|
|
191
|
+
|
|
192
|
+
def _process_auth_response(self, response_data: dict) -> None:
|
|
193
|
+
"""Process authentication response and update state"""
|
|
194
|
+
user_data = response_data.get("user", {})
|
|
195
|
+
root_id = user_data.get("root_id", "")
|
|
196
|
+
token = response_data.get("token", "")
|
|
197
|
+
expiration = user_data.get("expiration")
|
|
198
|
+
|
|
199
|
+
if not all([root_id, token, expiration]):
|
|
200
|
+
self.logger.error("Invalid authentication response")
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
self.update(root_id, token, expiration)
|
|
204
|
+
|
|
205
|
+
# Async versions of methods
|
|
206
|
+
async def _authenticate_async(self) -> None:
|
|
207
|
+
"""Asynchronous wrapper for authentication"""
|
|
208
|
+
loop = asyncio.get_running_loop()
|
|
209
|
+
await loop.run_in_executor(None, self._authenticate)
|
|
210
|
+
|
|
211
|
+
async def spawn_walker_async(
|
|
212
|
+
self,
|
|
213
|
+
walker_name: str,
|
|
214
|
+
module_name: str,
|
|
215
|
+
attributes: dict,
|
|
216
|
+
request: Request | None = None,
|
|
217
|
+
) -> Optional[WalkerArchetype]:
|
|
218
|
+
"""Asynchronous wrapper for walker spawning"""
|
|
219
|
+
loop = asyncio.get_running_loop()
|
|
220
|
+
return await loop.run_in_executor(
|
|
221
|
+
None, self.spawn_walker, walker_name, module_name, attributes, request
|
|
222
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: jvserve
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.1
|
|
4
4
|
Summary: FastAPI webserver for loading and interaction with JIVAS agents.
|
|
5
5
|
Home-page: https://github.com/TrueSelph/jvserve
|
|
6
6
|
Author: TrueSelph Inc.
|
|
@@ -9,7 +9,8 @@ Keywords: jivas
|
|
|
9
9
|
Requires-Python: >=3.12.0
|
|
10
10
|
Description-Content-Type: text/markdown
|
|
11
11
|
License-File: LICENSE
|
|
12
|
-
Requires-Dist: jac-cloud
|
|
12
|
+
Requires-Dist: jac-cloud
|
|
13
|
+
Requires-Dist: jaclang==0.8.4
|
|
13
14
|
Requires-Dist: pyaml>=25.1.0
|
|
14
15
|
Requires-Dist: requests>=2.32.3
|
|
15
16
|
Requires-Dist: aiohttp>=3.10.10
|
|
@@ -80,7 +81,7 @@ jac jvfileserve ./static
|
|
|
80
81
|
### Supported Arguments
|
|
81
82
|
|
|
82
83
|
- **filename**: Path to your JAC file.
|
|
83
|
-
- **host**: Host address to bind the server (default: `
|
|
84
|
+
- **host**: Host address to bind the server (default: `localhost`).
|
|
84
85
|
- **port**: Port number to bind the server (default: `8000`).
|
|
85
86
|
- **loglevel**: Logging level (default: `INFO`).
|
|
86
87
|
- **workers**: Number of worker processes (optional).
|
|
@@ -11,8 +11,8 @@ jvserve.egg-info/requires.txt
|
|
|
11
11
|
jvserve.egg-info/top_level.txt
|
|
12
12
|
jvserve/lib/__init__.py
|
|
13
13
|
jvserve/lib/agent_interface.py
|
|
14
|
-
jvserve/lib/agent_pulse.py
|
|
15
14
|
jvserve/lib/file_interface.py
|
|
15
|
+
jvserve/lib/jac_interface.py
|
|
16
16
|
jvserve/lib/jvlogger.py
|
|
17
17
|
tests/test_file_interface.py
|
|
18
18
|
tests/test_jvlogger.py
|