licos-agent-runtime 0.2.2__tar.gz → 0.2.6__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.
- {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/PKG-INFO +2 -2
- {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/pyproject.toml +2 -2
- licos_agent_runtime-0.2.6/src/licos_agent_runtime/agent_canvas.py +60 -0
- licos_agent_runtime-0.2.6/src/licos_agent_runtime/agent_config.py +70 -0
- {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/src/licos_agent_runtime/app.py +38 -1
- licos_agent_runtime-0.2.6/src/licos_agent_runtime/attachments.py +352 -0
- {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/src/licos_agent_runtime/graph_loader.py +6 -1
- {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/src/licos_agent_runtime/service.py +69 -13
- {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/src/licos_agent_runtime/stream_protocol.py +80 -11
- {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/src/licos_agent_runtime/stream_runner.py +1 -1
- licos_agent_runtime-0.2.6/src/licos_agent_runtime/workflow_canvas.py +475 -0
- licos_agent_runtime-0.2.6/tests/test_agent_config.py +118 -0
- {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/tests/test_graph_loader.py +14 -0
- {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/tests/test_stream_protocol.py +48 -0
- licos_agent_runtime-0.2.6/tests/test_workflow_protocol.py +534 -0
- licos_agent_runtime-0.2.2/tests/test_workflow_protocol.py +0 -137
- {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/.gitignore +0 -0
- {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/src/licos_agent_runtime/__init__.py +0 -0
- {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/src/licos_agent_runtime/__main__.py +0 -0
- {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/src/licos_agent_runtime/context.py +0 -0
- {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/src/licos_agent_runtime/errors.py +0 -0
- {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/src/licos_agent_runtime/logging.py +0 -0
- {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/src/licos_agent_runtime/openai.py +0 -0
- {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/src/licos_agent_runtime/parser.py +0 -0
- {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/src/licos_agent_runtime/payload.py +0 -0
- {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/src/licos_agent_runtime/run_config.py +0 -0
- {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/src/licos_agent_runtime/telemetry.py +0 -0
- {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/src/licos_agent_runtime/workflow_protocol.py +0 -0
- {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/tests/test_context.py +0 -0
- {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/tests/test_parser.py +0 -0
- {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/tests/test_payload.py +0 -0
- {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/tests/test_service_stream_protocol.py +0 -0
- {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/tests/test_stream_runner.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: licos-agent-runtime
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.6
|
|
4
4
|
Summary: LICOS Python agent runtime for LangGraph/LangChain projects
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Requires-Dist: fastapi<1,>=0.121
|
|
@@ -12,6 +12,6 @@ Requires-Dist: langgraph-prebuilt<2,>=1.0
|
|
|
12
12
|
Requires-Dist: langgraph-sdk<1,>=0.3
|
|
13
13
|
Requires-Dist: langgraph<2,>=1.1
|
|
14
14
|
Requires-Dist: langsmith<1,>=0.4
|
|
15
|
-
Requires-Dist: licos-platform-sdk>=0.2.
|
|
15
|
+
Requires-Dist: licos-platform-sdk>=0.2.2
|
|
16
16
|
Requires-Dist: pydantic<3,>=2.12
|
|
17
17
|
Requires-Dist: uvicorn<1,>=0.38
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "licos-agent-runtime"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.6"
|
|
8
8
|
description = "LICOS Python agent runtime for LangGraph/LangChain projects"
|
|
9
9
|
requires-python = ">=3.10"
|
|
10
10
|
dependencies = [
|
|
@@ -19,7 +19,7 @@ dependencies = [
|
|
|
19
19
|
"langgraph-sdk>=0.3,<1",
|
|
20
20
|
"langgraph-checkpoint-postgres>=3.0,<4",
|
|
21
21
|
"langsmith>=0.4,<1",
|
|
22
|
-
"licos-platform-sdk>=0.2.
|
|
22
|
+
"licos-platform-sdk>=0.2.2",
|
|
23
23
|
]
|
|
24
24
|
|
|
25
25
|
[project.scripts]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .agent_config import AGENT_CONFIG_PATH
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def agent_canvas_data() -> dict[str, Any]:
|
|
10
|
+
nodes: list[dict[str, Any]] = [
|
|
11
|
+
{
|
|
12
|
+
"id": "__start__",
|
|
13
|
+
"type": "start",
|
|
14
|
+
"definition": {
|
|
15
|
+
"info": {
|
|
16
|
+
"title": "开始",
|
|
17
|
+
"description": "",
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"script": {},
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"id": "agent",
|
|
24
|
+
"type": "agent",
|
|
25
|
+
"definition": {
|
|
26
|
+
"info": {
|
|
27
|
+
"title": "agent节点",
|
|
28
|
+
"description": "",
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"llm_config": [AGENT_CONFIG_PATH],
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"script": {},
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"id": "__end__",
|
|
38
|
+
"type": "end",
|
|
39
|
+
"definition": {
|
|
40
|
+
"info": {
|
|
41
|
+
"title": "结束",
|
|
42
|
+
"description": "",
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"script": {},
|
|
46
|
+
},
|
|
47
|
+
]
|
|
48
|
+
return {
|
|
49
|
+
"canvas_data": "",
|
|
50
|
+
"canvas_version": "",
|
|
51
|
+
"data": "\n" + json.dumps({"nodes": nodes}, ensure_ascii=False, indent=4),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def agent_canvas_response() -> dict[str, Any]:
|
|
56
|
+
return {
|
|
57
|
+
"code": 0,
|
|
58
|
+
"data": agent_canvas_data(),
|
|
59
|
+
"msg": "",
|
|
60
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .graph_loader import project_root
|
|
8
|
+
|
|
9
|
+
AGENT_CONFIG_RELATIVE_PATH = Path("config") / "agent_llm_config.json"
|
|
10
|
+
AGENT_CONFIG_PATH = AGENT_CONFIG_RELATIVE_PATH.as_posix()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _empty_payload(*, exists: bool = False) -> dict[str, Any]:
|
|
14
|
+
return {
|
|
15
|
+
"path": AGENT_CONFIG_PATH,
|
|
16
|
+
"exists": exists,
|
|
17
|
+
"config": {},
|
|
18
|
+
"sp": "",
|
|
19
|
+
"up": "",
|
|
20
|
+
"tools": [],
|
|
21
|
+
"raw": {},
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _text(value: Any) -> str:
|
|
26
|
+
if value is None:
|
|
27
|
+
return ""
|
|
28
|
+
return value if isinstance(value, str) else str(value)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _tools(value: Any) -> list[str]:
|
|
32
|
+
if not isinstance(value, list):
|
|
33
|
+
return []
|
|
34
|
+
|
|
35
|
+
result: list[str] = []
|
|
36
|
+
seen: set[str] = set()
|
|
37
|
+
for item in value:
|
|
38
|
+
name = _text(item).strip()
|
|
39
|
+
if not name or name in seen:
|
|
40
|
+
continue
|
|
41
|
+
result.append(name)
|
|
42
|
+
seen.add(name)
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def read_agent_config(root: Path | None = None) -> dict[str, Any]:
|
|
47
|
+
"""Read Coze-style agent config metadata without failing runtime APIs."""
|
|
48
|
+
base = root or project_root()
|
|
49
|
+
path = base / AGENT_CONFIG_RELATIVE_PATH
|
|
50
|
+
if not path.is_file():
|
|
51
|
+
return _empty_payload(exists=False)
|
|
52
|
+
|
|
53
|
+
payload = _empty_payload(exists=True)
|
|
54
|
+
try:
|
|
55
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
56
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
57
|
+
payload["error"] = str(exc)
|
|
58
|
+
return payload
|
|
59
|
+
|
|
60
|
+
if not isinstance(raw, dict):
|
|
61
|
+
payload["error"] = "agent config must be a JSON object"
|
|
62
|
+
return payload
|
|
63
|
+
|
|
64
|
+
config = raw.get("config")
|
|
65
|
+
payload["raw"] = raw
|
|
66
|
+
payload["config"] = config if isinstance(config, dict) else {}
|
|
67
|
+
payload["sp"] = _text(raw.get("sp"))
|
|
68
|
+
payload["up"] = _text(raw.get("up"))
|
|
69
|
+
payload["tools"] = _tools(raw.get("tools"))
|
|
70
|
+
return payload
|
|
@@ -78,7 +78,7 @@ def create_app(service: GraphService | None = None) -> FastAPI:
|
|
|
78
78
|
request_context.set(ctx)
|
|
79
79
|
workflow_stream_mode = request.headers.get(HEADER_X_WORKFLOW_STREAM_MODE, "").lower()
|
|
80
80
|
workflow_debug = workflow_stream_mode == "debug"
|
|
81
|
-
stream_mode =
|
|
81
|
+
stream_mode = "debug" if workflow_debug else "updates"
|
|
82
82
|
run_opt = RunOpt(workflow_debug=workflow_debug, stream_mode=stream_mode, platform_stream_protocol=True)
|
|
83
83
|
raw_body = await request.body()
|
|
84
84
|
try:
|
|
@@ -181,6 +181,43 @@ def create_app(service: GraphService | None = None) -> FastAPI:
|
|
|
181
181
|
del request
|
|
182
182
|
return runtime_service.graph_inout_schema()
|
|
183
183
|
|
|
184
|
+
@app.get("/agent/config")
|
|
185
|
+
async def http_agent_config(request: Request) -> dict[str, Any]:
|
|
186
|
+
del request
|
|
187
|
+
return runtime_service.agent_config_info()
|
|
188
|
+
|
|
189
|
+
@app.get("/agent/canvas")
|
|
190
|
+
async def http_agent_canvas_get(request: Request) -> dict[str, Any]:
|
|
191
|
+
del request
|
|
192
|
+
return runtime_service.agent_canvas()
|
|
193
|
+
|
|
194
|
+
@app.post("/agent/canvas")
|
|
195
|
+
async def http_agent_canvas_post(request: Request) -> dict[str, Any]:
|
|
196
|
+
del request
|
|
197
|
+
return runtime_service.agent_canvas()
|
|
198
|
+
|
|
199
|
+
@app.post("/v1/api/project/{project_id}/agent/canvas")
|
|
200
|
+
async def http_agent_canvas_coze(project_id: str, request: Request) -> dict[str, Any]:
|
|
201
|
+
del project_id, request
|
|
202
|
+
return runtime_service.agent_canvas()
|
|
203
|
+
|
|
204
|
+
@app.post("/agent/canvas_submit")
|
|
205
|
+
async def http_agent_canvas_submit(request: Request) -> dict[str, Any]:
|
|
206
|
+
try:
|
|
207
|
+
payload = await request.json()
|
|
208
|
+
if not isinstance(payload, dict):
|
|
209
|
+
raise ValueError("request body must be an object")
|
|
210
|
+
return runtime_service.agent_canvas_submit(payload)
|
|
211
|
+
except json.JSONDecodeError as exc:
|
|
212
|
+
raise HTTPException(status_code=400, detail="Invalid JSON format") from exc
|
|
213
|
+
except ValueError as exc:
|
|
214
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
215
|
+
|
|
216
|
+
@app.post("/v1/api/project/{project_id}/agent/canvas_submit")
|
|
217
|
+
async def http_agent_canvas_submit_coze(project_id: str, request: Request) -> dict[str, Any]:
|
|
218
|
+
del project_id
|
|
219
|
+
return await http_agent_canvas_submit(request)
|
|
220
|
+
|
|
184
221
|
return app
|
|
185
222
|
|
|
186
223
|
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path, PurePosixPath
|
|
5
|
+
import tempfile
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.parse import unquote, urlparse
|
|
8
|
+
from urllib.request import Request, urlopen
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
DEFAULT_PROJECT_PATH = "/workspace/projects"
|
|
12
|
+
DEFAULT_ATTACHMENT_DIR = "assets"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def project_path() -> Path:
|
|
16
|
+
return Path(os.environ.get("LICOS_PROJECT_PATH") or DEFAULT_PROJECT_PATH).resolve()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def attachment_dir(project_root: Path | None = None) -> Path:
|
|
20
|
+
root = project_root or project_path()
|
|
21
|
+
configured = os.environ.get("LICOS_AGENT_ATTACHMENT_DIR") or DEFAULT_ATTACHMENT_DIR
|
|
22
|
+
relative = PurePosixPath(str(configured).replace("\\", "/"))
|
|
23
|
+
if relative.is_absolute() or any(part == ".." for part in relative.parts):
|
|
24
|
+
relative = PurePosixPath(DEFAULT_ATTACHMENT_DIR)
|
|
25
|
+
return root.joinpath(*relative.parts)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def extract_upload_file(item: Any) -> dict[str, Any] | None:
|
|
29
|
+
if not isinstance(item, dict):
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
upload_file: Any = None
|
|
33
|
+
if item.get("type") == "upload_file":
|
|
34
|
+
content = item.get("content")
|
|
35
|
+
if isinstance(content, dict):
|
|
36
|
+
upload_file = content.get("upload_file") or content.get("file")
|
|
37
|
+
elif isinstance(content, str):
|
|
38
|
+
upload_file = {"url": content}
|
|
39
|
+
elif "upload_file" in item:
|
|
40
|
+
upload_file = item.get("upload_file")
|
|
41
|
+
|
|
42
|
+
if not isinstance(upload_file, dict):
|
|
43
|
+
return None
|
|
44
|
+
return dict(upload_file)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def materialize_upload_file(upload_file: dict[str, Any], index: int = 0) -> dict[str, Any]:
|
|
48
|
+
root = project_path()
|
|
49
|
+
assets = attachment_dir(root)
|
|
50
|
+
file_name = _safe_file_name(
|
|
51
|
+
upload_file.get("file_name")
|
|
52
|
+
or upload_file.get("name")
|
|
53
|
+
or _file_name_from_url(upload_file.get("url"))
|
|
54
|
+
or f"attachment-{index + 1}"
|
|
55
|
+
)
|
|
56
|
+
dest = (assets / file_name).resolve()
|
|
57
|
+
record = _attachment_record(upload_file, root, dest, file_name)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
_ensure_inside(root, dest)
|
|
61
|
+
assets.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
|
|
63
|
+
url = upload_file.get("url")
|
|
64
|
+
if isinstance(url, str) and url.strip():
|
|
65
|
+
content_type = _download_to_file(url.strip(), dest)
|
|
66
|
+
record.update(_saved_record(root, dest))
|
|
67
|
+
if content_type:
|
|
68
|
+
record["mime_type"] = content_type
|
|
69
|
+
record["file_type"] = infer_file_type(record)
|
|
70
|
+
return record
|
|
71
|
+
|
|
72
|
+
record["status"] = "failed"
|
|
73
|
+
record["error"] = "missing upload_file.url"
|
|
74
|
+
record["file_type"] = infer_file_type(record)
|
|
75
|
+
return record
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
record["status"] = "failed"
|
|
78
|
+
record["error"] = str(exc) or exc.__class__.__name__
|
|
79
|
+
record["file_type"] = infer_file_type(record)
|
|
80
|
+
return record
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def materialize_upload_files(upload_files: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
84
|
+
return [materialize_upload_file(upload_file, index) for index, upload_file in enumerate(upload_files)]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def format_attachment_manifest(attachments: list[dict[str, Any]]) -> str:
|
|
88
|
+
if not attachments:
|
|
89
|
+
return ""
|
|
90
|
+
lines = ["Attachments:"]
|
|
91
|
+
for attachment in attachments:
|
|
92
|
+
file_name = attachment.get("file_name") or ""
|
|
93
|
+
file_path = attachment.get("file_path") or ""
|
|
94
|
+
status = attachment.get("status") or ""
|
|
95
|
+
line = f"- file_name: {file_name}"
|
|
96
|
+
if file_path:
|
|
97
|
+
line += f", file_path: {file_path}"
|
|
98
|
+
if status and status != "saved":
|
|
99
|
+
line += f", status: {status}"
|
|
100
|
+
if attachment.get("error"):
|
|
101
|
+
line += f", error: {attachment['error']}"
|
|
102
|
+
lines.append(line)
|
|
103
|
+
return "\n".join(lines)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def content_parts_for_attachment(attachment: dict[str, Any]) -> list[dict[str, Any]]:
|
|
107
|
+
url = str(attachment.get("url") or attachment.get("source_url") or "")
|
|
108
|
+
file_name = str(attachment.get("file_name") or "")
|
|
109
|
+
file_path = str(attachment.get("file_path") or "")
|
|
110
|
+
file_type = str(attachment.get("file_type") or infer_file_type(attachment))
|
|
111
|
+
mime_type = str(attachment.get("mime_type") or "")
|
|
112
|
+
|
|
113
|
+
if file_type == "image" and url:
|
|
114
|
+
return [
|
|
115
|
+
{"type": "text", "text": url},
|
|
116
|
+
{"type": "image_url", "image_url": {"url": url}},
|
|
117
|
+
]
|
|
118
|
+
if file_type == "video" and url:
|
|
119
|
+
video_url: dict[str, Any] = {"url": url}
|
|
120
|
+
if mime_type:
|
|
121
|
+
video_url["media_type"] = mime_type
|
|
122
|
+
return [
|
|
123
|
+
{"type": "text", "text": url},
|
|
124
|
+
{"type": "video_url", "video_url": video_url},
|
|
125
|
+
]
|
|
126
|
+
if file_type == "audio" and url:
|
|
127
|
+
return [{"type": "text", "text": f"audio url: {url}"}]
|
|
128
|
+
|
|
129
|
+
text = f"file name:{file_name}, url: {url}"
|
|
130
|
+
if file_path:
|
|
131
|
+
text += f"\nlocal file path: {file_path}"
|
|
132
|
+
extracted = extract_text_preview(attachment)
|
|
133
|
+
if extracted:
|
|
134
|
+
text += f"\n\nFile Content:\n{extracted}"
|
|
135
|
+
return [{"type": "text", "text": text}]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def infer_file_type(attachment: dict[str, Any]) -> str:
|
|
139
|
+
mime_type = str(attachment.get("mime_type") or "").split(";")[0].strip().lower()
|
|
140
|
+
if mime_type:
|
|
141
|
+
top = mime_type.split("/", 1)[0]
|
|
142
|
+
if top in {"image", "video", "audio"}:
|
|
143
|
+
return top
|
|
144
|
+
if top == "text" or mime_type in {
|
|
145
|
+
"application/json",
|
|
146
|
+
"application/xml",
|
|
147
|
+
"application/pdf",
|
|
148
|
+
"application/msword",
|
|
149
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
150
|
+
"application/vnd.ms-excel",
|
|
151
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
152
|
+
"application/vnd.ms-powerpoint",
|
|
153
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
154
|
+
}:
|
|
155
|
+
return "document"
|
|
156
|
+
|
|
157
|
+
for value in (attachment.get("file_name"), attachment.get("url"), attachment.get("source_url")):
|
|
158
|
+
ext = _extension(value)
|
|
159
|
+
if not ext:
|
|
160
|
+
continue
|
|
161
|
+
if ext in IMAGE_EXTENSIONS:
|
|
162
|
+
return "image"
|
|
163
|
+
if ext in VIDEO_EXTENSIONS:
|
|
164
|
+
return "video"
|
|
165
|
+
if ext in AUDIO_EXTENSIONS:
|
|
166
|
+
return "audio"
|
|
167
|
+
if ext in DOCUMENT_EXTENSIONS:
|
|
168
|
+
return "document"
|
|
169
|
+
return "default"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def extract_text_preview(attachment: dict[str, Any]) -> str:
|
|
173
|
+
path = attachment.get("local_path")
|
|
174
|
+
if not isinstance(path, str) or not path:
|
|
175
|
+
return ""
|
|
176
|
+
suffix = _extension(path)
|
|
177
|
+
if suffix not in TEXT_PREVIEW_EXTENSIONS:
|
|
178
|
+
return ""
|
|
179
|
+
try:
|
|
180
|
+
data = Path(path).read_bytes()
|
|
181
|
+
except OSError:
|
|
182
|
+
return ""
|
|
183
|
+
if len(data) > TEXT_PREVIEW_MAX_BYTES:
|
|
184
|
+
data = data[:TEXT_PREVIEW_MAX_BYTES]
|
|
185
|
+
return data.decode("utf-8", errors="replace")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _attachment_record(upload_file: dict[str, Any], root: Path, dest: Path, file_name: str) -> dict[str, Any]:
|
|
189
|
+
source_file_name = upload_file.get("file_name") or upload_file.get("name")
|
|
190
|
+
record: dict[str, Any] = {
|
|
191
|
+
"type": "upload_file",
|
|
192
|
+
"file_name": file_name,
|
|
193
|
+
"file_path": _relative_posix(root, dest),
|
|
194
|
+
"local_path": str(dest),
|
|
195
|
+
"status": "pending",
|
|
196
|
+
}
|
|
197
|
+
if source_file_name and str(source_file_name) != file_name:
|
|
198
|
+
record["original_file_name"] = str(source_file_name)
|
|
199
|
+
for source_key, target_key in (
|
|
200
|
+
("url", "url"),
|
|
201
|
+
("mime_type", "mime_type"),
|
|
202
|
+
("content_type", "mime_type"),
|
|
203
|
+
("size", "size"),
|
|
204
|
+
):
|
|
205
|
+
value = upload_file.get(source_key)
|
|
206
|
+
if value not in (None, "") and target_key not in record:
|
|
207
|
+
record[target_key] = value
|
|
208
|
+
record["file_type"] = infer_file_type(record)
|
|
209
|
+
return record
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _saved_record(root: Path, dest: Path) -> dict[str, Any]:
|
|
213
|
+
stat = dest.stat()
|
|
214
|
+
return {
|
|
215
|
+
"file_path": _relative_posix(root, dest),
|
|
216
|
+
"local_path": str(dest),
|
|
217
|
+
"size": stat.st_size,
|
|
218
|
+
"status": "saved",
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _download_to_file(url: str, dest: Path) -> str:
|
|
223
|
+
parsed = urlparse(url)
|
|
224
|
+
if parsed.scheme not in {"http", "https"}:
|
|
225
|
+
raise ValueError(f"unsupported upload_file.url scheme: {parsed.scheme or '<empty>'}")
|
|
226
|
+
|
|
227
|
+
timeout = _float_env("LICOS_AGENT_ATTACHMENT_TIMEOUT_SECONDS", 30.0)
|
|
228
|
+
max_bytes = int(_float_env("LICOS_AGENT_ATTACHMENT_MAX_BYTES", 100 * 1024 * 1024))
|
|
229
|
+
request = Request(url, headers={"User-Agent": "licos-agent-runtime"})
|
|
230
|
+
|
|
231
|
+
tmp_path: Path | None = None
|
|
232
|
+
try:
|
|
233
|
+
content_type = ""
|
|
234
|
+
with urlopen(request, timeout=timeout) as response:
|
|
235
|
+
content_type = str(response.headers.get("Content-Type") or "").split(";", 1)[0].strip()
|
|
236
|
+
content_length = response.headers.get("Content-Length")
|
|
237
|
+
if content_length and int(content_length) > max_bytes:
|
|
238
|
+
raise ValueError(f"attachment exceeds max size: {content_length} > {max_bytes}")
|
|
239
|
+
|
|
240
|
+
with tempfile.NamedTemporaryFile(delete=False, dir=str(dest.parent), prefix=f".{dest.name}.") as tmp:
|
|
241
|
+
tmp_path = Path(tmp.name)
|
|
242
|
+
total = 0
|
|
243
|
+
while True:
|
|
244
|
+
chunk = response.read(1024 * 1024)
|
|
245
|
+
if not chunk:
|
|
246
|
+
break
|
|
247
|
+
total += len(chunk)
|
|
248
|
+
if total > max_bytes:
|
|
249
|
+
raise ValueError(f"attachment exceeds max size: {total} > {max_bytes}")
|
|
250
|
+
tmp.write(chunk)
|
|
251
|
+
|
|
252
|
+
if tmp_path is None:
|
|
253
|
+
raise ValueError("failed to create temporary attachment file")
|
|
254
|
+
os.replace(tmp_path, dest)
|
|
255
|
+
return content_type
|
|
256
|
+
except Exception:
|
|
257
|
+
if tmp_path is not None and tmp_path.exists():
|
|
258
|
+
try:
|
|
259
|
+
tmp_path.unlink()
|
|
260
|
+
except OSError:
|
|
261
|
+
pass
|
|
262
|
+
raise
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _ensure_inside(root: Path, path: Path) -> None:
|
|
266
|
+
root_resolved = root.resolve()
|
|
267
|
+
path_resolved = path.resolve()
|
|
268
|
+
if path_resolved == root_resolved:
|
|
269
|
+
return
|
|
270
|
+
if root_resolved not in path_resolved.parents:
|
|
271
|
+
raise ValueError(f"path is outside project root: {path}")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _safe_file_name(value: Any) -> str:
|
|
275
|
+
raw = str(value or "").replace("\\", "/")
|
|
276
|
+
name = PurePosixPath(raw).name.strip()
|
|
277
|
+
if not name:
|
|
278
|
+
name = "attachment"
|
|
279
|
+
sanitized = "".join("_" if char in {'/', '\\', ':', '*', '?', '"', '<', '>', '|'} or ord(char) < 32 else char for char in name)
|
|
280
|
+
sanitized = sanitized.strip(" .")
|
|
281
|
+
return sanitized or "attachment"
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _file_name_from_url(value: Any) -> str | None:
|
|
285
|
+
if not isinstance(value, str) or not value.strip():
|
|
286
|
+
return None
|
|
287
|
+
parsed = urlparse(value)
|
|
288
|
+
name = PurePosixPath(unquote(parsed.path)).name
|
|
289
|
+
return name or None
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _extension(value: Any) -> str:
|
|
293
|
+
if not isinstance(value, str) or not value.strip():
|
|
294
|
+
return ""
|
|
295
|
+
parsed = urlparse(value)
|
|
296
|
+
path = unquote(parsed.path or value)
|
|
297
|
+
suffix = PurePosixPath(path).suffix.lower().lstrip(".")
|
|
298
|
+
return suffix
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _relative_posix(root: Path, path: Path) -> str:
|
|
302
|
+
try:
|
|
303
|
+
relative = path.resolve().relative_to(root.resolve())
|
|
304
|
+
except ValueError:
|
|
305
|
+
return path.name
|
|
306
|
+
return relative.as_posix()
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _float_env(name: str, default: float) -> float:
|
|
310
|
+
value = os.environ.get(name)
|
|
311
|
+
if value is None:
|
|
312
|
+
return default
|
|
313
|
+
try:
|
|
314
|
+
return float(value)
|
|
315
|
+
except ValueError:
|
|
316
|
+
return default
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
IMAGE_EXTENSIONS = {
|
|
320
|
+
"apng",
|
|
321
|
+
"avif",
|
|
322
|
+
"bmp",
|
|
323
|
+
"gif",
|
|
324
|
+
"heic",
|
|
325
|
+
"ico",
|
|
326
|
+
"jpg",
|
|
327
|
+
"jpeg",
|
|
328
|
+
"png",
|
|
329
|
+
"svg",
|
|
330
|
+
"tiff",
|
|
331
|
+
"webp",
|
|
332
|
+
}
|
|
333
|
+
VIDEO_EXTENSIONS = {"mp4", "avi", "mov", "mkv", "flv", "wmv", "webm", "m4v", "3gp"}
|
|
334
|
+
AUDIO_EXTENSIONS = {"mp3", "wav", "flac", "aac", "ogg", "wma", "m4a"}
|
|
335
|
+
DOCUMENT_EXTENSIONS = {
|
|
336
|
+
"pdf",
|
|
337
|
+
"doc",
|
|
338
|
+
"docx",
|
|
339
|
+
"xls",
|
|
340
|
+
"xlsx",
|
|
341
|
+
"ppt",
|
|
342
|
+
"pptx",
|
|
343
|
+
"txt",
|
|
344
|
+
"md",
|
|
345
|
+
"csv",
|
|
346
|
+
"json",
|
|
347
|
+
"xml",
|
|
348
|
+
"html",
|
|
349
|
+
"htm",
|
|
350
|
+
}
|
|
351
|
+
TEXT_PREVIEW_EXTENSIONS = {"txt", "md", "csv", "json", "xml", "html", "htm", "log"}
|
|
352
|
+
TEXT_PREVIEW_MAX_BYTES = 200_000
|
{licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/src/licos_agent_runtime/graph_loader.py
RENAMED
|
@@ -176,7 +176,7 @@ def _load_from_module(module_name: str, candidates: tuple[str, ...], ctx: Contex
|
|
|
176
176
|
def get_agent_instance(module_name: str = "agents.agent", ctx: Context | None = None) -> Any:
|
|
177
177
|
return _load_from_module(
|
|
178
178
|
module_name,
|
|
179
|
-
("create_agent", "get_agent", "agent", "graph", "app", "builder"),
|
|
179
|
+
("create_agent", "build_agent", "get_agent", "agent", "graph", "app", "builder"),
|
|
180
180
|
ctx,
|
|
181
181
|
)
|
|
182
182
|
|
|
@@ -304,6 +304,11 @@ def get_graph_node_func_with_inout(graph: Any, node_id: str) -> tuple[Any | None
|
|
|
304
304
|
if current_id == node_id or getattr(item, "id", None) == node_id or getattr(item, "name", None) == node_id:
|
|
305
305
|
node = item
|
|
306
306
|
break
|
|
307
|
+
data = _node_data(item)
|
|
308
|
+
func = _resolve_callable(data)
|
|
309
|
+
if getattr(func, "__name__", None) == node_id:
|
|
310
|
+
node = item
|
|
311
|
+
break
|
|
307
312
|
|
|
308
313
|
if node is None:
|
|
309
314
|
return None, None, None
|