licos-agent-runtime 0.2.0__tar.gz → 0.2.2__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.0 → licos_agent_runtime-0.2.2}/.gitignore +4 -1
- {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/PKG-INFO +1 -1
- {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/pyproject.toml +29 -29
- {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/graph_loader.py +21 -2
- {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/service.py +227 -10
- {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/stream_protocol.py +86 -7
- {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/stream_runner.py +1 -0
- {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/telemetry.py +22 -22
- licos_agent_runtime-0.2.2/src/licos_agent_runtime/workflow_protocol.py +130 -0
- {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/tests/test_graph_loader.py +35 -0
- {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/tests/test_stream_protocol.py +49 -0
- licos_agent_runtime-0.2.2/tests/test_workflow_protocol.py +137 -0
- {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/__init__.py +0 -0
- {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/__main__.py +0 -0
- {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/app.py +0 -0
- {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/context.py +0 -0
- {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/errors.py +0 -0
- {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/logging.py +0 -0
- {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/openai.py +0 -0
- {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/parser.py +0 -0
- {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/payload.py +0 -0
- {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/run_config.py +0 -0
- {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/tests/test_context.py +0 -0
- {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/tests/test_parser.py +0 -0
- {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/tests/test_payload.py +0 -0
- {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/tests/test_service_stream_protocol.py +0 -0
- {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/tests/test_stream_runner.py +0 -0
|
@@ -1,29 +1,29 @@
|
|
|
1
|
-
[build-system]
|
|
2
|
-
requires = ["hatchling"]
|
|
3
|
-
build-backend = "hatchling.build"
|
|
4
|
-
|
|
5
|
-
[project]
|
|
6
|
-
name = "licos-agent-runtime"
|
|
7
|
-
version = "0.2.
|
|
8
|
-
description = "LICOS Python agent runtime for LangGraph/LangChain projects"
|
|
9
|
-
requires-python = ">=3.10"
|
|
10
|
-
dependencies = [
|
|
11
|
-
"fastapi>=0.121,<1",
|
|
12
|
-
"uvicorn>=0.38,<1",
|
|
13
|
-
"pydantic>=2.12,<3",
|
|
14
|
-
"langchain>=1.2,<2",
|
|
15
|
-
"langchain-openai>=1.2,<2",
|
|
16
|
-
"langgraph>=1.1,<2",
|
|
17
|
-
"langgraph-checkpoint>=4,<5",
|
|
18
|
-
"langgraph-prebuilt>=1.0,<2",
|
|
19
|
-
"langgraph-sdk>=0.3,<1",
|
|
20
|
-
"langgraph-checkpoint-postgres>=3.0,<4",
|
|
21
|
-
"langsmith>=0.4,<1",
|
|
22
|
-
"licos-platform-sdk>=0.2.0",
|
|
23
|
-
]
|
|
24
|
-
|
|
25
|
-
[project.scripts]
|
|
26
|
-
licos-agent-runtime = "licos_agent_runtime.__main__:main"
|
|
27
|
-
|
|
28
|
-
[tool.hatch.build.targets.wheel]
|
|
29
|
-
packages = ["src/licos_agent_runtime"]
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "licos-agent-runtime"
|
|
7
|
+
version = "0.2.2"
|
|
8
|
+
description = "LICOS Python agent runtime for LangGraph/LangChain projects"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"fastapi>=0.121,<1",
|
|
12
|
+
"uvicorn>=0.38,<1",
|
|
13
|
+
"pydantic>=2.12,<3",
|
|
14
|
+
"langchain>=1.2,<2",
|
|
15
|
+
"langchain-openai>=1.2,<2",
|
|
16
|
+
"langgraph>=1.1,<2",
|
|
17
|
+
"langgraph-checkpoint>=4,<5",
|
|
18
|
+
"langgraph-prebuilt>=1.0,<2",
|
|
19
|
+
"langgraph-sdk>=0.3,<1",
|
|
20
|
+
"langgraph-checkpoint-postgres>=3.0,<4",
|
|
21
|
+
"langsmith>=0.4,<1",
|
|
22
|
+
"licos-platform-sdk>=0.2.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
licos-agent-runtime = "licos_agent_runtime.__main__:main"
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["src/licos_agent_runtime"]
|
{licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/graph_loader.py
RENAMED
|
@@ -93,19 +93,38 @@ def is_dev_env() -> bool:
|
|
|
93
93
|
|
|
94
94
|
def is_agent_project(root: Path | None = None) -> bool:
|
|
95
95
|
project_type = (os.environ.get("AGENT_PROJECT_TYPE") or os.environ.get("LICOS_PROJECT_TYPE") or "").strip()
|
|
96
|
-
|
|
96
|
+
normalized_type = project_type.upper()
|
|
97
|
+
if normalized_type == "AGENT":
|
|
97
98
|
return True
|
|
99
|
+
if normalized_type == "WORKFLOW":
|
|
100
|
+
return False
|
|
98
101
|
|
|
99
102
|
root = root or project_root()
|
|
100
103
|
template = (_read_project_template(root) or "").upper()
|
|
101
104
|
if template == "AGENT":
|
|
102
105
|
return True
|
|
106
|
+
if template == "WORKFLOW":
|
|
107
|
+
return False
|
|
103
108
|
|
|
104
109
|
return (root / "src" / "agents" / "agent.py").is_file() or (root / "agents" / "agent.py").is_file()
|
|
105
110
|
|
|
106
111
|
|
|
107
112
|
def is_workflow_project(root: Path | None = None) -> bool:
|
|
108
|
-
|
|
113
|
+
project_type = (os.environ.get("AGENT_PROJECT_TYPE") or os.environ.get("LICOS_PROJECT_TYPE") or "").strip()
|
|
114
|
+
normalized_type = project_type.upper()
|
|
115
|
+
if normalized_type == "WORKFLOW":
|
|
116
|
+
return True
|
|
117
|
+
if normalized_type == "AGENT":
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
root = root or project_root()
|
|
121
|
+
template = (_read_project_template(root) or "").upper()
|
|
122
|
+
if template == "WORKFLOW":
|
|
123
|
+
return True
|
|
124
|
+
if template == "AGENT":
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
return (root / "src" / "graphs" / "graph.py").is_file() or (root / "graphs" / "graph.py").is_file()
|
|
109
128
|
|
|
110
129
|
|
|
111
130
|
def _call_factory(factory: Any, ctx: Context | None) -> Any:
|
|
@@ -7,6 +7,7 @@ import json
|
|
|
7
7
|
import logging
|
|
8
8
|
import os
|
|
9
9
|
import threading
|
|
10
|
+
import time
|
|
10
11
|
from typing import Any, AsyncIterable, Iterable
|
|
11
12
|
|
|
12
13
|
from . import telemetry
|
|
@@ -34,6 +35,16 @@ from .parser import LangGraphParser
|
|
|
34
35
|
from .parser import schema_to_json
|
|
35
36
|
from .run_config import init_agent_config, init_run_config
|
|
36
37
|
from .stream_runner import AgentStreamRunner, RunOpt, WorkflowStreamRunner
|
|
38
|
+
from .workflow_protocol import (
|
|
39
|
+
DEFAULT_ERROR_CODE as WORKFLOW_DEFAULT_ERROR_CODE,
|
|
40
|
+
workflow_end_event,
|
|
41
|
+
workflow_error_event,
|
|
42
|
+
workflow_log_id,
|
|
43
|
+
workflow_node_end_event,
|
|
44
|
+
workflow_node_start_event,
|
|
45
|
+
workflow_ping_event,
|
|
46
|
+
workflow_start_event,
|
|
47
|
+
)
|
|
37
48
|
|
|
38
49
|
logger = logging.getLogger(__name__)
|
|
39
50
|
|
|
@@ -57,6 +68,21 @@ async def _call_ainvoke(graph: Any, payload: dict[str, Any], run_config: dict[st
|
|
|
57
68
|
return await graph.ainvoke(payload, **kwargs)
|
|
58
69
|
|
|
59
70
|
|
|
71
|
+
def _call_astream_events(graph: Any, payload: dict[str, Any], run_config: dict[str, Any], ctx: Context) -> Any:
|
|
72
|
+
return graph.astream_events(
|
|
73
|
+
payload,
|
|
74
|
+
**_select_supported_kwargs(
|
|
75
|
+
graph.astream_events,
|
|
76
|
+
[
|
|
77
|
+
{"config": run_config, "context": ctx, "version": "v2"},
|
|
78
|
+
{"config": run_config, "version": "v2"},
|
|
79
|
+
{"config": run_config, "context": ctx},
|
|
80
|
+
{"config": run_config},
|
|
81
|
+
],
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
60
86
|
def _select_supported_kwargs(method: Any, kwargs_list: list[dict[str, Any]]) -> dict[str, Any]:
|
|
61
87
|
try:
|
|
62
88
|
signature = inspect.signature(method)
|
|
@@ -108,7 +134,7 @@ class GraphService:
|
|
|
108
134
|
|
|
109
135
|
@staticmethod
|
|
110
136
|
def _sse_event(data: Any, event_id: Any = None) -> str:
|
|
111
|
-
id_line = f"id: {event_id}\n" if event_id else ""
|
|
137
|
+
id_line = f"id: {event_id}\n" if event_id is not None else ""
|
|
112
138
|
return f"{id_line}event: message\ndata: {json.dumps(data, ensure_ascii=False, default=str)}\n\n"
|
|
113
139
|
|
|
114
140
|
def _get_stream_runner(self) -> AgentStreamRunner | WorkflowStreamRunner:
|
|
@@ -132,8 +158,19 @@ class GraphService:
|
|
|
132
158
|
|
|
133
159
|
try:
|
|
134
160
|
graph = self._get_graph(ctx)
|
|
135
|
-
|
|
161
|
+
agent_project = is_agent_project()
|
|
162
|
+
start_perf = time.perf_counter()
|
|
163
|
+
run_config = init_agent_config(graph, ctx) if agent_project else init_run_config(graph, ctx)
|
|
136
164
|
result = await _call_ainvoke(graph, payload, run_config, ctx)
|
|
165
|
+
if not agent_project:
|
|
166
|
+
return {
|
|
167
|
+
"run_id": run_id,
|
|
168
|
+
"log_id": workflow_log_id(),
|
|
169
|
+
"output": result if result is not None else {},
|
|
170
|
+
"time_cost_ms": _elapsed_perf_ms(start_perf),
|
|
171
|
+
"code": 0,
|
|
172
|
+
"message": "success",
|
|
173
|
+
}
|
|
137
174
|
if result is None:
|
|
138
175
|
result = {}
|
|
139
176
|
if isinstance(result, dict):
|
|
@@ -181,7 +218,12 @@ class GraphService:
|
|
|
181
218
|
run_opt = run_opt or RunOpt()
|
|
182
219
|
run_id = ctx.run_id
|
|
183
220
|
if run_opt.platform_stream_protocol:
|
|
184
|
-
|
|
221
|
+
stream = (
|
|
222
|
+
self._stream_sse_agent_platform(payload, ctx=ctx, run_opt=run_opt)
|
|
223
|
+
if is_agent_project()
|
|
224
|
+
else self._stream_sse_workflow_platform(payload, ctx=ctx, run_opt=run_opt)
|
|
225
|
+
)
|
|
226
|
+
async for item in stream:
|
|
185
227
|
yield item
|
|
186
228
|
return
|
|
187
229
|
|
|
@@ -201,7 +243,7 @@ class GraphService:
|
|
|
201
243
|
self.running_tasks.pop(run_id, None)
|
|
202
244
|
telemetry.flush()
|
|
203
245
|
|
|
204
|
-
async def
|
|
246
|
+
async def _stream_sse_agent_platform(
|
|
205
247
|
self,
|
|
206
248
|
payload: dict[str, Any],
|
|
207
249
|
ctx: Context,
|
|
@@ -242,6 +284,157 @@ class GraphService:
|
|
|
242
284
|
self.running_tasks.pop(run_id, None)
|
|
243
285
|
telemetry.flush()
|
|
244
286
|
|
|
287
|
+
async def _stream_sse_workflow_platform(
|
|
288
|
+
self,
|
|
289
|
+
payload: dict[str, Any],
|
|
290
|
+
ctx: Context,
|
|
291
|
+
run_opt: RunOpt,
|
|
292
|
+
) -> AsyncIterable[str]:
|
|
293
|
+
run_id = ctx.run_id
|
|
294
|
+
log_id = workflow_log_id()
|
|
295
|
+
start_perf = time.perf_counter()
|
|
296
|
+
event_index = 0
|
|
297
|
+
|
|
298
|
+
yield self._sse_event(workflow_start_event(run_id=run_id, log_id=log_id), event_index)
|
|
299
|
+
event_index += 1
|
|
300
|
+
|
|
301
|
+
queue: asyncio.Queue[dict[str, Any] | BaseException | None] = asyncio.Queue()
|
|
302
|
+
|
|
303
|
+
async def produce() -> None:
|
|
304
|
+
try:
|
|
305
|
+
async for event in self._workflow_platform_events(payload, ctx, log_id, start_perf):
|
|
306
|
+
await queue.put(event)
|
|
307
|
+
except BaseException as exc:
|
|
308
|
+
await queue.put(exc)
|
|
309
|
+
finally:
|
|
310
|
+
await queue.put(None)
|
|
311
|
+
|
|
312
|
+
producer = asyncio.create_task(produce())
|
|
313
|
+
ping_interval = run_opt.workflow_ping_interval_seconds
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
while True:
|
|
317
|
+
try:
|
|
318
|
+
if ping_interval and ping_interval > 0:
|
|
319
|
+
item = await asyncio.wait_for(queue.get(), timeout=ping_interval)
|
|
320
|
+
else:
|
|
321
|
+
item = await queue.get()
|
|
322
|
+
except asyncio.TimeoutError:
|
|
323
|
+
yield self._sse_event(workflow_ping_event(run_id=run_id, log_id=log_id), event_index)
|
|
324
|
+
event_index += 1
|
|
325
|
+
continue
|
|
326
|
+
|
|
327
|
+
if item is None:
|
|
328
|
+
break
|
|
329
|
+
if isinstance(item, BaseException):
|
|
330
|
+
if isinstance(item, asyncio.CancelledError):
|
|
331
|
+
raise item
|
|
332
|
+
code = stream_error_code(item) or WORKFLOW_DEFAULT_ERROR_CODE
|
|
333
|
+
message = str(item) or item.__class__.__name__
|
|
334
|
+
yield self._sse_event(
|
|
335
|
+
workflow_error_event(run_id=run_id, log_id=log_id, code=code, message=message),
|
|
336
|
+
event_index,
|
|
337
|
+
)
|
|
338
|
+
event_index += 1
|
|
339
|
+
break
|
|
340
|
+
|
|
341
|
+
yield self._sse_event(item, event_index)
|
|
342
|
+
event_index += 1
|
|
343
|
+
except asyncio.CancelledError:
|
|
344
|
+
producer.cancel()
|
|
345
|
+
yield self._sse_event(
|
|
346
|
+
workflow_error_event(run_id=run_id, log_id=log_id, code=1, message="cancelled"),
|
|
347
|
+
event_index,
|
|
348
|
+
)
|
|
349
|
+
finally:
|
|
350
|
+
with suppress(asyncio.CancelledError):
|
|
351
|
+
await producer
|
|
352
|
+
self.running_tasks.pop(run_id, None)
|
|
353
|
+
telemetry.flush()
|
|
354
|
+
|
|
355
|
+
async def _workflow_platform_events(
|
|
356
|
+
self,
|
|
357
|
+
payload: dict[str, Any],
|
|
358
|
+
ctx: Context,
|
|
359
|
+
log_id: str,
|
|
360
|
+
start_perf: float,
|
|
361
|
+
) -> AsyncIterable[dict[str, Any]]:
|
|
362
|
+
graph = self._get_graph(ctx)
|
|
363
|
+
run_config = init_run_config(graph, ctx)
|
|
364
|
+
node_start_times: dict[str, float] = {}
|
|
365
|
+
final_output: Any = {}
|
|
366
|
+
|
|
367
|
+
if hasattr(graph, "astream_events"):
|
|
368
|
+
async for event in _call_astream_events(graph, payload, run_config, ctx):
|
|
369
|
+
if not isinstance(event, dict):
|
|
370
|
+
continue
|
|
371
|
+
event_type = str(event.get("event") or "")
|
|
372
|
+
name = str(event.get("name") or "")
|
|
373
|
+
event_run_id = str(event.get("run_id") or name or ctx.run_id)
|
|
374
|
+
data = event.get("data") if isinstance(event.get("data"), dict) else {}
|
|
375
|
+
|
|
376
|
+
if event_type.endswith("_start") and not _is_workflow_root_event(event):
|
|
377
|
+
node_start_times[event_run_id] = time.perf_counter()
|
|
378
|
+
yield workflow_node_start_event(
|
|
379
|
+
run_id=ctx.run_id,
|
|
380
|
+
log_id=log_id,
|
|
381
|
+
node_name=self._workflow_node_name(graph, name),
|
|
382
|
+
input=data.get("input", {}),
|
|
383
|
+
)
|
|
384
|
+
continue
|
|
385
|
+
|
|
386
|
+
if event_type.endswith("_end"):
|
|
387
|
+
output = data.get("output", {})
|
|
388
|
+
if _is_workflow_root_event(event) or event_run_id not in node_start_times:
|
|
389
|
+
if output:
|
|
390
|
+
final_output = output
|
|
391
|
+
continue
|
|
392
|
+
node_started = node_start_times.pop(event_run_id, time.perf_counter())
|
|
393
|
+
yield workflow_node_end_event(
|
|
394
|
+
run_id=ctx.run_id,
|
|
395
|
+
log_id=log_id,
|
|
396
|
+
node_name=self._workflow_node_name(graph, name),
|
|
397
|
+
output=output,
|
|
398
|
+
time_cost_ms=_elapsed_perf_ms(node_started),
|
|
399
|
+
)
|
|
400
|
+
if output:
|
|
401
|
+
final_output = output
|
|
402
|
+
continue
|
|
403
|
+
|
|
404
|
+
if event_type.endswith("_error"):
|
|
405
|
+
error = data.get("error") or data.get("exception") or "workflow node failed"
|
|
406
|
+
yield workflow_error_event(
|
|
407
|
+
run_id=ctx.run_id,
|
|
408
|
+
log_id=log_id,
|
|
409
|
+
code=WORKFLOW_DEFAULT_ERROR_CODE,
|
|
410
|
+
message=str(error),
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
yield workflow_end_event(
|
|
414
|
+
run_id=ctx.run_id,
|
|
415
|
+
log_id=log_id,
|
|
416
|
+
output=final_output,
|
|
417
|
+
time_cost_ms=_elapsed_perf_ms(start_perf),
|
|
418
|
+
)
|
|
419
|
+
return
|
|
420
|
+
|
|
421
|
+
result = await _call_ainvoke(graph, payload, run_config, ctx)
|
|
422
|
+
yield workflow_end_event(
|
|
423
|
+
run_id=ctx.run_id,
|
|
424
|
+
log_id=log_id,
|
|
425
|
+
output=result,
|
|
426
|
+
time_cost_ms=_elapsed_perf_ms(start_perf),
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
def _workflow_node_name(self, graph: Any, node_id: str) -> str:
|
|
430
|
+
parser = LangGraphParser(graph)
|
|
431
|
+
metadata = parser.get_node_metadata(node_id) or {}
|
|
432
|
+
for key in ("title", "name", "label"):
|
|
433
|
+
value = metadata.get(key)
|
|
434
|
+
if isinstance(value, str) and value.strip():
|
|
435
|
+
return value.strip()
|
|
436
|
+
return node_id or "workflow_node"
|
|
437
|
+
|
|
245
438
|
def cancel_run(self, run_id: str, ctx: Context | None = None) -> dict[str, Any]:
|
|
246
439
|
del ctx
|
|
247
440
|
logger.info("Attempting to cancel run_id=%s", run_id)
|
|
@@ -299,15 +492,16 @@ class GraphService:
|
|
|
299
492
|
if output_cls is None and hasattr(graph, "get_output_schema"):
|
|
300
493
|
output_cls = graph.get_output_schema()
|
|
301
494
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
return {
|
|
495
|
+
result = {
|
|
305
496
|
"input_schema": schema_to_json(input_cls),
|
|
306
497
|
"output_schema": schema_to_json(output_cls),
|
|
307
|
-
"nodes": parser.get_nodes_metadata(),
|
|
308
|
-
"code": 0,
|
|
309
|
-
"msg": "",
|
|
310
498
|
}
|
|
499
|
+
if is_agent_project():
|
|
500
|
+
parser = LangGraphParser(graph)
|
|
501
|
+
result["nodes"] = parser.get_nodes_metadata()
|
|
502
|
+
result["code"] = 0
|
|
503
|
+
result["msg"] = ""
|
|
504
|
+
return result
|
|
311
505
|
|
|
312
506
|
async def astream(
|
|
313
507
|
self,
|
|
@@ -320,3 +514,26 @@ class GraphService:
|
|
|
320
514
|
stream_runner = self._get_stream_runner()
|
|
321
515
|
async for chunk in stream_runner.astream(payload, graph, run_config, ctx, run_opt):
|
|
322
516
|
yield chunk
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _elapsed_perf_ms(start: float) -> int:
|
|
520
|
+
return max(0, int((time.perf_counter() - start) * 1000))
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _is_workflow_root_event(event: dict[str, Any]) -> bool:
|
|
524
|
+
name = str(event.get("name") or "").lower()
|
|
525
|
+
if name.startswith("__"):
|
|
526
|
+
return True
|
|
527
|
+
if name in {"langgraph", "stategraph", "compiledstategraph", "graph", "__root__"}:
|
|
528
|
+
return True
|
|
529
|
+
metadata = event.get("metadata")
|
|
530
|
+
if isinstance(metadata, dict):
|
|
531
|
+
if metadata.get("langgraph_node") in {"__start__", "__end__"}:
|
|
532
|
+
return True
|
|
533
|
+
if metadata.get("langgraph_node") is None and name in {
|
|
534
|
+
"langgraph",
|
|
535
|
+
"graph",
|
|
536
|
+
"stategraph",
|
|
537
|
+
}:
|
|
538
|
+
return True
|
|
539
|
+
return False
|
{licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/stream_protocol.py
RENAMED
|
@@ -223,7 +223,7 @@ def chunk_to_events(chunk: Any) -> list[dict[str, Any]]:
|
|
|
223
223
|
events.extend(_tool_events_from_chunk(chunk))
|
|
224
224
|
|
|
225
225
|
answer = extract_answer(chunk)
|
|
226
|
-
if answer:
|
|
226
|
+
if answer and not _is_tool_only_chunk(chunk):
|
|
227
227
|
events.append(answer_event(answer))
|
|
228
228
|
return events
|
|
229
229
|
|
|
@@ -243,6 +243,14 @@ def _tool_events_from_chunk(chunk: Any) -> list[dict[str, Any]]:
|
|
|
243
243
|
if isinstance(tool_response, dict):
|
|
244
244
|
events.append(_tool_response_from_mapping(tool_response))
|
|
245
245
|
|
|
246
|
+
tool_calls = chunk.get("tool_calls")
|
|
247
|
+
if isinstance(tool_calls, list):
|
|
248
|
+
events.extend(_tool_requests_from_calls(tool_calls))
|
|
249
|
+
|
|
250
|
+
tool_call_chunks = chunk.get("tool_call_chunks")
|
|
251
|
+
if isinstance(tool_call_chunks, list):
|
|
252
|
+
events.extend(_tool_requests_from_calls(tool_call_chunks))
|
|
253
|
+
|
|
246
254
|
messages = chunk.get("messages")
|
|
247
255
|
if isinstance(messages, list):
|
|
248
256
|
for message in messages:
|
|
@@ -259,8 +267,10 @@ def _tool_events_from_chunk(chunk: Any) -> list[dict[str, Any]]:
|
|
|
259
267
|
def _tool_events_from_message(message: Any) -> list[dict[str, Any]]:
|
|
260
268
|
events: list[dict[str, Any]] = []
|
|
261
269
|
tool_calls = None
|
|
270
|
+
tool_call_chunks = None
|
|
262
271
|
if isinstance(message, dict):
|
|
263
272
|
tool_calls = message.get("tool_calls")
|
|
273
|
+
tool_call_chunks = message.get("tool_call_chunks")
|
|
264
274
|
if message.get("tool_call_id") and not tool_calls:
|
|
265
275
|
events.append(
|
|
266
276
|
tool_response_event(
|
|
@@ -271,6 +281,7 @@ def _tool_events_from_message(message: Any) -> list[dict[str, Any]]:
|
|
|
271
281
|
)
|
|
272
282
|
else:
|
|
273
283
|
tool_calls = getattr(message, "tool_calls", None)
|
|
284
|
+
tool_call_chunks = getattr(message, "tool_call_chunks", None)
|
|
274
285
|
tool_call_id = getattr(message, "tool_call_id", None)
|
|
275
286
|
if tool_call_id:
|
|
276
287
|
events.append(
|
|
@@ -282,26 +293,64 @@ def _tool_events_from_message(message: Any) -> list[dict[str, Any]]:
|
|
|
282
293
|
)
|
|
283
294
|
|
|
284
295
|
if isinstance(tool_calls, list):
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
296
|
+
events.extend(_tool_requests_from_calls(tool_calls))
|
|
297
|
+
if isinstance(tool_call_chunks, list):
|
|
298
|
+
events.extend(_tool_requests_from_calls(tool_call_chunks))
|
|
299
|
+
return events
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _tool_requests_from_calls(calls: list[Any]) -> list[dict[str, Any]]:
|
|
303
|
+
events: list[dict[str, Any]] = []
|
|
304
|
+
is_parallel = len(calls) > 1
|
|
305
|
+
for index, call in enumerate(calls):
|
|
306
|
+
mapping = _object_to_mapping(call)
|
|
307
|
+
if mapping:
|
|
308
|
+
events.append(_tool_request_from_mapping(mapping, index, is_parallel))
|
|
289
309
|
return events
|
|
290
310
|
|
|
291
311
|
|
|
312
|
+
def _object_to_mapping(value: Any) -> dict[str, Any]:
|
|
313
|
+
if isinstance(value, dict):
|
|
314
|
+
return value
|
|
315
|
+
|
|
316
|
+
mapping: dict[str, Any] = {}
|
|
317
|
+
for name in (
|
|
318
|
+
"tool_call_id",
|
|
319
|
+
"id",
|
|
320
|
+
"tool_name",
|
|
321
|
+
"name",
|
|
322
|
+
"parameters",
|
|
323
|
+
"args",
|
|
324
|
+
"arguments",
|
|
325
|
+
"is_parallel",
|
|
326
|
+
"index",
|
|
327
|
+
"stream_parameters",
|
|
328
|
+
"code",
|
|
329
|
+
"message",
|
|
330
|
+
"result",
|
|
331
|
+
"content",
|
|
332
|
+
):
|
|
333
|
+
if hasattr(value, name):
|
|
334
|
+
mapping[name] = getattr(value, name)
|
|
335
|
+
return mapping
|
|
336
|
+
|
|
337
|
+
|
|
292
338
|
def _tool_request_from_mapping(value: dict[str, Any], index: int, is_parallel: bool) -> dict[str, Any]:
|
|
293
339
|
parameters = value.get("parameters")
|
|
294
340
|
if parameters is None:
|
|
295
341
|
parameters = value.get("args")
|
|
296
342
|
if parameters is None:
|
|
297
343
|
parameters = value.get("arguments")
|
|
344
|
+
stream_parameters = value.get("stream_parameters")
|
|
345
|
+
if stream_parameters is None and isinstance(parameters, str):
|
|
346
|
+
stream_parameters = parameters
|
|
298
347
|
return tool_request_event(
|
|
299
348
|
tool_call_id=str(value.get("tool_call_id") or value.get("id") or ""),
|
|
300
349
|
tool_name=str(value.get("tool_name") or value.get("name") or ""),
|
|
301
|
-
parameters=parameters
|
|
350
|
+
parameters=_parameters_object(parameters),
|
|
302
351
|
is_parallel=bool(value.get("is_parallel", is_parallel)),
|
|
303
352
|
index=int(value.get("index", index) or 0),
|
|
304
|
-
stream_parameters=str(
|
|
353
|
+
stream_parameters=str(stream_parameters or ""),
|
|
305
354
|
)
|
|
306
355
|
|
|
307
356
|
|
|
@@ -316,6 +365,36 @@ def _tool_response_from_mapping(value: dict[str, Any]) -> dict[str, Any]:
|
|
|
316
365
|
)
|
|
317
366
|
|
|
318
367
|
|
|
368
|
+
def _parameters_object(value: Any) -> dict[str, Any]:
|
|
369
|
+
if value is None or value == "":
|
|
370
|
+
return {}
|
|
371
|
+
if isinstance(value, dict):
|
|
372
|
+
return value
|
|
373
|
+
if isinstance(value, str):
|
|
374
|
+
try:
|
|
375
|
+
parsed = json.loads(value)
|
|
376
|
+
except json.JSONDecodeError:
|
|
377
|
+
return {"value": value}
|
|
378
|
+
if isinstance(parsed, dict):
|
|
379
|
+
return parsed
|
|
380
|
+
return {"value": parsed}
|
|
381
|
+
return {"value": value}
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _is_tool_only_chunk(chunk: Any) -> bool:
|
|
385
|
+
if isinstance(chunk, dict):
|
|
386
|
+
if "messages" in chunk:
|
|
387
|
+
return False
|
|
388
|
+
return any(key in chunk for key in ("tool_request", "tool_response", "tool_call_id"))
|
|
389
|
+
|
|
390
|
+
if getattr(chunk, "tool_call_id", None):
|
|
391
|
+
return True
|
|
392
|
+
|
|
393
|
+
content = getattr(chunk, "content", None)
|
|
394
|
+
has_tool_request = bool(getattr(chunk, "tool_calls", None) or getattr(chunk, "tool_call_chunks", None))
|
|
395
|
+
return has_tool_request and not content
|
|
396
|
+
|
|
397
|
+
|
|
319
398
|
def extract_answer(value: Any) -> str:
|
|
320
399
|
if value is None:
|
|
321
400
|
return ""
|
{licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/stream_runner.py
RENAMED
|
@@ -13,6 +13,7 @@ class RunOpt:
|
|
|
13
13
|
workflow_debug: bool = False
|
|
14
14
|
stream_mode: str | None = None
|
|
15
15
|
platform_stream_protocol: bool = False
|
|
16
|
+
workflow_ping_interval_seconds: float = 15.0
|
|
16
17
|
|
|
17
18
|
|
|
18
19
|
def _call_with_fallback(method: Any, payload: dict[str, Any], kwargs_list: list[dict[str, Any]]) -> Any:
|
{licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/telemetry.py
RENAMED
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import logging
|
|
4
|
-
|
|
5
|
-
logger = logging.getLogger(__name__)
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def flush() -> None:
|
|
9
|
-
"""Flush platform telemetry when available.
|
|
10
|
-
|
|
11
|
-
This is intentionally a no-op until licos-platform-sdk exposes telemetry.
|
|
12
|
-
The function mirrors the runtime flush hook so request handlers can
|
|
13
|
-
keep the same lifecycle shape.
|
|
14
|
-
"""
|
|
15
|
-
try:
|
|
16
|
-
from licos_platform_sdk import telemetry # type: ignore
|
|
17
|
-
|
|
18
|
-
flush_func = getattr(telemetry, "flush", None)
|
|
19
|
-
if callable(flush_func):
|
|
20
|
-
flush_func()
|
|
21
|
-
except Exception as exc:
|
|
22
|
-
logger.debug("telemetry flush skipped: %s", exc)
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def flush() -> None:
|
|
9
|
+
"""Flush platform telemetry when available.
|
|
10
|
+
|
|
11
|
+
This is intentionally a no-op until licos-platform-sdk exposes telemetry.
|
|
12
|
+
The function mirrors the runtime flush hook so request handlers can
|
|
13
|
+
keep the same lifecycle shape.
|
|
14
|
+
"""
|
|
15
|
+
try:
|
|
16
|
+
from licos_platform_sdk import telemetry # type: ignore
|
|
17
|
+
|
|
18
|
+
flush_func = getattr(telemetry, "flush", None)
|
|
19
|
+
if callable(flush_func):
|
|
20
|
+
flush_func()
|
|
21
|
+
except Exception as exc:
|
|
22
|
+
logger.debug("telemetry flush skipped: %s", exc)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
DEFAULT_ERROR_CODE = 201001
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def workflow_timestamp_ms() -> int:
|
|
12
|
+
return int(time.time() * 1000)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def workflow_log_id(timestamp_ms: int | None = None) -> str:
|
|
16
|
+
timestamp_ms = timestamp_ms if timestamp_ms is not None else workflow_timestamp_ms()
|
|
17
|
+
prefix = time.strftime("%Y%m%d%H%M%S", time.localtime(timestamp_ms / 1000))
|
|
18
|
+
return f"{prefix}{uuid.uuid4().hex[:16].upper()}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def workflow_start_event(
|
|
22
|
+
*,
|
|
23
|
+
run_id: str,
|
|
24
|
+
log_id: str,
|
|
25
|
+
timestamp: int | None = None,
|
|
26
|
+
) -> dict[str, Any]:
|
|
27
|
+
return {
|
|
28
|
+
"type": "workflow_start",
|
|
29
|
+
"timestamp": timestamp if timestamp is not None else workflow_timestamp_ms(),
|
|
30
|
+
"run_id": run_id,
|
|
31
|
+
"log_id": log_id,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def workflow_end_event(
|
|
36
|
+
*,
|
|
37
|
+
run_id: str,
|
|
38
|
+
log_id: str,
|
|
39
|
+
output: Any,
|
|
40
|
+
time_cost_ms: int,
|
|
41
|
+
timestamp: int | None = None,
|
|
42
|
+
) -> dict[str, Any]:
|
|
43
|
+
return {
|
|
44
|
+
"type": "workflow_end",
|
|
45
|
+
"timestamp": timestamp if timestamp is not None else workflow_timestamp_ms(),
|
|
46
|
+
"run_id": run_id,
|
|
47
|
+
"log_id": log_id,
|
|
48
|
+
"output": _object_output(output),
|
|
49
|
+
"time_cost_ms": time_cost_ms,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def workflow_node_start_event(
|
|
54
|
+
*,
|
|
55
|
+
run_id: str,
|
|
56
|
+
log_id: str,
|
|
57
|
+
node_name: str,
|
|
58
|
+
input: Any,
|
|
59
|
+
timestamp: int | None = None,
|
|
60
|
+
) -> dict[str, Any]:
|
|
61
|
+
return {
|
|
62
|
+
"type": "node_start",
|
|
63
|
+
"timestamp": timestamp if timestamp is not None else workflow_timestamp_ms(),
|
|
64
|
+
"run_id": run_id,
|
|
65
|
+
"log_id": log_id,
|
|
66
|
+
"node_name": node_name,
|
|
67
|
+
"input": _object_output(input),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def workflow_node_end_event(
|
|
72
|
+
*,
|
|
73
|
+
run_id: str,
|
|
74
|
+
log_id: str,
|
|
75
|
+
node_name: str,
|
|
76
|
+
output: Any,
|
|
77
|
+
time_cost_ms: int,
|
|
78
|
+
timestamp: int | None = None,
|
|
79
|
+
) -> dict[str, Any]:
|
|
80
|
+
return {
|
|
81
|
+
"type": "node_end",
|
|
82
|
+
"timestamp": timestamp if timestamp is not None else workflow_timestamp_ms(),
|
|
83
|
+
"run_id": run_id,
|
|
84
|
+
"log_id": log_id,
|
|
85
|
+
"node_name": node_name,
|
|
86
|
+
"output": _object_output(output),
|
|
87
|
+
"time_cost_ms": time_cost_ms,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def workflow_error_event(
|
|
92
|
+
*,
|
|
93
|
+
run_id: str,
|
|
94
|
+
log_id: str,
|
|
95
|
+
code: int,
|
|
96
|
+
message: str,
|
|
97
|
+
timestamp: int | None = None,
|
|
98
|
+
) -> dict[str, Any]:
|
|
99
|
+
del run_id, log_id
|
|
100
|
+
return {
|
|
101
|
+
"type": "error",
|
|
102
|
+
"code": code,
|
|
103
|
+
"message": message,
|
|
104
|
+
"timestamp": timestamp if timestamp is not None else workflow_timestamp_ms(),
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def workflow_ping_event(
|
|
109
|
+
*,
|
|
110
|
+
run_id: str,
|
|
111
|
+
log_id: str,
|
|
112
|
+
timestamp: int | None = None,
|
|
113
|
+
) -> dict[str, Any]:
|
|
114
|
+
del run_id, log_id
|
|
115
|
+
return {
|
|
116
|
+
"type": "ping",
|
|
117
|
+
"timestamp": timestamp if timestamp is not None else workflow_timestamp_ms(),
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _object_output(value: Any) -> Any:
|
|
122
|
+
if value is None:
|
|
123
|
+
return {}
|
|
124
|
+
model_dump = getattr(value, "model_dump", None)
|
|
125
|
+
if callable(model_dump):
|
|
126
|
+
dumped = model_dump()
|
|
127
|
+
return dumped if isinstance(dumped, dict) else {"value": dumped}
|
|
128
|
+
if isinstance(value, dict):
|
|
129
|
+
return value
|
|
130
|
+
return {"value": value}
|
|
@@ -10,6 +10,7 @@ from licos_agent_runtime.graph_loader import (
|
|
|
10
10
|
_iter_nodes,
|
|
11
11
|
get_graph_node_func_with_inout,
|
|
12
12
|
is_agent_project,
|
|
13
|
+
is_workflow_project,
|
|
13
14
|
project_root,
|
|
14
15
|
)
|
|
15
16
|
|
|
@@ -20,6 +21,17 @@ class GraphLoaderTests(unittest.TestCase):
|
|
|
20
21
|
with patch.dict(os.environ, {"AGENT_PROJECT_TYPE": "AGENT"}, clear=True):
|
|
21
22
|
self.assertTrue(is_agent_project(Path(tmp)))
|
|
22
23
|
|
|
24
|
+
def test_workflow_project_type_env_wins(self):
|
|
25
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
26
|
+
root = Path(tmp)
|
|
27
|
+
agent_file = root / "src" / "agents" / "agent.py"
|
|
28
|
+
agent_file.parent.mkdir(parents=True)
|
|
29
|
+
agent_file.write_text("agent = object()\n", encoding="utf-8")
|
|
30
|
+
|
|
31
|
+
with patch.dict(os.environ, {"AGENT_PROJECT_TYPE": "WORKFLOW"}, clear=True):
|
|
32
|
+
self.assertFalse(is_agent_project(root))
|
|
33
|
+
self.assertTrue(is_workflow_project(root))
|
|
34
|
+
|
|
23
35
|
def test_project_root_derives_from_workspace_path(self):
|
|
24
36
|
with tempfile.TemporaryDirectory() as tmp:
|
|
25
37
|
with patch.dict(os.environ, {"LICOS_WORKSPACE_PATH": tmp}, clear=True):
|
|
@@ -36,6 +48,29 @@ class GraphLoaderTests(unittest.TestCase):
|
|
|
36
48
|
with patch.dict(os.environ, {}, clear=True):
|
|
37
49
|
self.assertTrue(is_agent_project(root))
|
|
38
50
|
|
|
51
|
+
def test_licos_template_workflow_is_workflow_project(self):
|
|
52
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
53
|
+
root = Path(tmp)
|
|
54
|
+
(root / ".licos").write_text(
|
|
55
|
+
'[project]\ntemplate = "workflow"\n',
|
|
56
|
+
encoding="utf-8",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
with patch.dict(os.environ, {}, clear=True):
|
|
60
|
+
self.assertFalse(is_agent_project(root))
|
|
61
|
+
self.assertTrue(is_workflow_project(root))
|
|
62
|
+
|
|
63
|
+
def test_graph_module_marks_workflow_project(self):
|
|
64
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
65
|
+
root = Path(tmp)
|
|
66
|
+
graph_file = root / "src" / "graphs" / "graph.py"
|
|
67
|
+
graph_file.parent.mkdir(parents=True)
|
|
68
|
+
graph_file.write_text("graph = object()\n", encoding="utf-8")
|
|
69
|
+
|
|
70
|
+
with patch.dict(os.environ, {}, clear=True):
|
|
71
|
+
self.assertFalse(is_agent_project(root))
|
|
72
|
+
self.assertTrue(is_workflow_project(root))
|
|
73
|
+
|
|
39
74
|
def test_licos_config_dir_template_agent_is_agent_project(self):
|
|
40
75
|
with tempfile.TemporaryDirectory() as tmp:
|
|
41
76
|
root = Path(tmp)
|
|
@@ -60,6 +60,55 @@ class StreamProtocolTests(unittest.TestCase):
|
|
|
60
60
|
self.assertEqual(request["content"]["tool_request"]["parameters"], {"q": "x"})
|
|
61
61
|
self.assertEqual(response["content"]["tool_response"]["result"], '{"ok": true}')
|
|
62
62
|
|
|
63
|
+
def test_chunk_to_events_extracts_tool_call_chunks(self):
|
|
64
|
+
class ToolCallChunk:
|
|
65
|
+
id = "call-1"
|
|
66
|
+
name = "search"
|
|
67
|
+
args = '{"q":"x"}'
|
|
68
|
+
index = 0
|
|
69
|
+
|
|
70
|
+
class AIMessageChunk:
|
|
71
|
+
content = ""
|
|
72
|
+
tool_call_chunks = [ToolCallChunk()]
|
|
73
|
+
|
|
74
|
+
events = chunk_to_events(AIMessageChunk())
|
|
75
|
+
|
|
76
|
+
self.assertEqual(len(events), 1)
|
|
77
|
+
request = events[0]["content"]["tool_request"]
|
|
78
|
+
self.assertEqual(request["tool_call_id"], "call-1")
|
|
79
|
+
self.assertEqual(request["tool_name"], "search")
|
|
80
|
+
self.assertEqual(request["parameters"], {"q": "x"})
|
|
81
|
+
self.assertEqual(request["stream_parameters"], '{"q":"x"}')
|
|
82
|
+
|
|
83
|
+
def test_chunk_to_events_extracts_object_tool_calls(self):
|
|
84
|
+
class ToolCall:
|
|
85
|
+
id = "call-1"
|
|
86
|
+
name = "search"
|
|
87
|
+
args = {"q": "x"}
|
|
88
|
+
|
|
89
|
+
class AIMessage:
|
|
90
|
+
content = ""
|
|
91
|
+
tool_calls = [ToolCall()]
|
|
92
|
+
|
|
93
|
+
events = chunk_to_events(AIMessage())
|
|
94
|
+
|
|
95
|
+
self.assertEqual(len(events), 1)
|
|
96
|
+
request = events[0]["content"]["tool_request"]
|
|
97
|
+
self.assertEqual(request["tool_call_id"], "call-1")
|
|
98
|
+
self.assertEqual(request["parameters"], {"q": "x"})
|
|
99
|
+
|
|
100
|
+
def test_chunk_to_events_does_not_emit_answer_for_tool_response_message(self):
|
|
101
|
+
class ToolMessage:
|
|
102
|
+
tool_call_id = "call-1"
|
|
103
|
+
name = "search"
|
|
104
|
+
content = "tool result"
|
|
105
|
+
|
|
106
|
+
events = chunk_to_events(ToolMessage())
|
|
107
|
+
|
|
108
|
+
self.assertEqual(len(events), 1)
|
|
109
|
+
self.assertEqual(events[0]["type"], "tool_response")
|
|
110
|
+
self.assertEqual(events[0]["content"]["tool_response"]["result"], "tool result")
|
|
111
|
+
|
|
63
112
|
def test_chunk_to_events_extracts_answer_from_langgraph_node_update(self):
|
|
64
113
|
events = chunk_to_events({"assistant": {"output": "hello"}})
|
|
65
114
|
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import unittest
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
6
|
+
from licos_agent_runtime.context import new_context
|
|
7
|
+
from licos_agent_runtime.service import GraphService
|
|
8
|
+
from licos_agent_runtime.stream_runner import RunOpt
|
|
9
|
+
from licos_agent_runtime.workflow_protocol import (
|
|
10
|
+
workflow_end_event,
|
|
11
|
+
workflow_error_event,
|
|
12
|
+
workflow_node_end_event,
|
|
13
|
+
workflow_node_start_event,
|
|
14
|
+
workflow_ping_event,
|
|
15
|
+
workflow_start_event,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _event_data(raw_event):
|
|
20
|
+
data = None
|
|
21
|
+
event_id = None
|
|
22
|
+
for line in raw_event.splitlines():
|
|
23
|
+
if line.startswith("id:"):
|
|
24
|
+
event_id = int(line[3:].strip())
|
|
25
|
+
if line.startswith("data:"):
|
|
26
|
+
data = json.loads(line[5:].strip())
|
|
27
|
+
if data is None:
|
|
28
|
+
raise AssertionError(f"No data line in event: {raw_event}")
|
|
29
|
+
return event_id, data
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class WorkflowProtocolTests(unittest.TestCase):
|
|
33
|
+
def test_workflow_event_shapes(self):
|
|
34
|
+
self.assertEqual(workflow_start_event(run_id="r1", log_id="l1", timestamp=1)["type"], "workflow_start")
|
|
35
|
+
self.assertEqual(
|
|
36
|
+
workflow_node_start_event(
|
|
37
|
+
run_id="r1",
|
|
38
|
+
log_id="l1",
|
|
39
|
+
node_name="search",
|
|
40
|
+
input={"topic": "x"},
|
|
41
|
+
timestamp=2,
|
|
42
|
+
),
|
|
43
|
+
{
|
|
44
|
+
"type": "node_start",
|
|
45
|
+
"timestamp": 2,
|
|
46
|
+
"run_id": "r1",
|
|
47
|
+
"log_id": "l1",
|
|
48
|
+
"node_name": "search",
|
|
49
|
+
"input": {"topic": "x"},
|
|
50
|
+
},
|
|
51
|
+
)
|
|
52
|
+
self.assertEqual(
|
|
53
|
+
workflow_node_end_event(
|
|
54
|
+
run_id="r1",
|
|
55
|
+
log_id="l1",
|
|
56
|
+
node_name="search",
|
|
57
|
+
output={"items": []},
|
|
58
|
+
time_cost_ms=3,
|
|
59
|
+
timestamp=4,
|
|
60
|
+
)["type"],
|
|
61
|
+
"node_end",
|
|
62
|
+
)
|
|
63
|
+
self.assertEqual(
|
|
64
|
+
workflow_end_event(
|
|
65
|
+
run_id="r1",
|
|
66
|
+
log_id="l1",
|
|
67
|
+
output={"ok": True},
|
|
68
|
+
time_cost_ms=5,
|
|
69
|
+
timestamp=6,
|
|
70
|
+
)["output"],
|
|
71
|
+
{"ok": True},
|
|
72
|
+
)
|
|
73
|
+
self.assertEqual(workflow_error_event(run_id="r1", log_id="l1", code=201001, message="bad")["type"], "error")
|
|
74
|
+
self.assertEqual(workflow_ping_event(run_id="r1", log_id="l1")["type"], "ping")
|
|
75
|
+
|
|
76
|
+
def test_stream_sse_outputs_workflow_protocol_events(self):
|
|
77
|
+
class Node:
|
|
78
|
+
metadata = {"title": "网络搜索"}
|
|
79
|
+
|
|
80
|
+
class RawGraph:
|
|
81
|
+
nodes = {"search": Node()}
|
|
82
|
+
|
|
83
|
+
class Graph:
|
|
84
|
+
def get_graph(self):
|
|
85
|
+
return RawGraph()
|
|
86
|
+
|
|
87
|
+
async def astream_events(self, payload, **kwargs):
|
|
88
|
+
del kwargs
|
|
89
|
+
yield {
|
|
90
|
+
"event": "on_chain_start",
|
|
91
|
+
"name": "search",
|
|
92
|
+
"run_id": "node-run",
|
|
93
|
+
"data": {"input": payload},
|
|
94
|
+
}
|
|
95
|
+
yield {
|
|
96
|
+
"event": "on_chain_end",
|
|
97
|
+
"name": "search",
|
|
98
|
+
"run_id": "node-run",
|
|
99
|
+
"data": {"output": {"result": "node-ok"}},
|
|
100
|
+
}
|
|
101
|
+
yield {
|
|
102
|
+
"event": "on_chain_end",
|
|
103
|
+
"name": "LangGraph",
|
|
104
|
+
"run_id": "graph-run",
|
|
105
|
+
"data": {"output": {"answer": "done"}},
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
class Service(GraphService):
|
|
109
|
+
def _get_graph(self, ctx=None):
|
|
110
|
+
del ctx
|
|
111
|
+
return Graph()
|
|
112
|
+
|
|
113
|
+
async def run():
|
|
114
|
+
service = Service()
|
|
115
|
+
ctx = new_context("stream_run")
|
|
116
|
+
events = []
|
|
117
|
+
async for item in service.stream_sse(
|
|
118
|
+
{"topic": "AI"},
|
|
119
|
+
ctx=ctx,
|
|
120
|
+
run_opt=RunOpt(platform_stream_protocol=True, workflow_ping_interval_seconds=0),
|
|
121
|
+
):
|
|
122
|
+
events.append(_event_data(item))
|
|
123
|
+
return events
|
|
124
|
+
|
|
125
|
+
with patch("licos_agent_runtime.service.is_agent_project", return_value=False):
|
|
126
|
+
events = asyncio.run(run())
|
|
127
|
+
|
|
128
|
+
ids = [event_id for event_id, _data in events]
|
|
129
|
+
types = [data["type"] for _event_id, data in events]
|
|
130
|
+
self.assertEqual(ids, [0, 1, 2, 3])
|
|
131
|
+
self.assertEqual(types, ["workflow_start", "node_start", "node_end", "workflow_end"])
|
|
132
|
+
self.assertEqual(events[1][1]["node_name"], "网络搜索")
|
|
133
|
+
self.assertEqual(events[-1][1]["output"], {"answer": "done"})
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == "__main__":
|
|
137
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/run_config.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/tests/test_service_stream_protocol.py
RENAMED
|
File without changes
|
|
File without changes
|