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.
Files changed (33) hide show
  1. {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/PKG-INFO +2 -2
  2. {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/pyproject.toml +2 -2
  3. licos_agent_runtime-0.2.6/src/licos_agent_runtime/agent_canvas.py +60 -0
  4. licos_agent_runtime-0.2.6/src/licos_agent_runtime/agent_config.py +70 -0
  5. {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/src/licos_agent_runtime/app.py +38 -1
  6. licos_agent_runtime-0.2.6/src/licos_agent_runtime/attachments.py +352 -0
  7. {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/src/licos_agent_runtime/graph_loader.py +6 -1
  8. {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/src/licos_agent_runtime/service.py +69 -13
  9. {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/src/licos_agent_runtime/stream_protocol.py +80 -11
  10. {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/src/licos_agent_runtime/stream_runner.py +1 -1
  11. licos_agent_runtime-0.2.6/src/licos_agent_runtime/workflow_canvas.py +475 -0
  12. licos_agent_runtime-0.2.6/tests/test_agent_config.py +118 -0
  13. {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/tests/test_graph_loader.py +14 -0
  14. {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/tests/test_stream_protocol.py +48 -0
  15. licos_agent_runtime-0.2.6/tests/test_workflow_protocol.py +534 -0
  16. licos_agent_runtime-0.2.2/tests/test_workflow_protocol.py +0 -137
  17. {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/.gitignore +0 -0
  18. {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/src/licos_agent_runtime/__init__.py +0 -0
  19. {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/src/licos_agent_runtime/__main__.py +0 -0
  20. {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/src/licos_agent_runtime/context.py +0 -0
  21. {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/src/licos_agent_runtime/errors.py +0 -0
  22. {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/src/licos_agent_runtime/logging.py +0 -0
  23. {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/src/licos_agent_runtime/openai.py +0 -0
  24. {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/src/licos_agent_runtime/parser.py +0 -0
  25. {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/src/licos_agent_runtime/payload.py +0 -0
  26. {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/src/licos_agent_runtime/run_config.py +0 -0
  27. {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/src/licos_agent_runtime/telemetry.py +0 -0
  28. {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/src/licos_agent_runtime/workflow_protocol.py +0 -0
  29. {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/tests/test_context.py +0 -0
  30. {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/tests/test_parser.py +0 -0
  31. {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/tests/test_payload.py +0 -0
  32. {licos_agent_runtime-0.2.2 → licos_agent_runtime-0.2.6}/tests/test_service_stream_protocol.py +0 -0
  33. {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.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.0
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.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.0",
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 = None if workflow_debug else (workflow_stream_mode or None)
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
@@ -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