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.
Files changed (27) hide show
  1. {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/.gitignore +4 -1
  2. {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/PKG-INFO +1 -1
  3. {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/pyproject.toml +29 -29
  4. {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/graph_loader.py +21 -2
  5. {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/service.py +227 -10
  6. {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/stream_protocol.py +86 -7
  7. {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/stream_runner.py +1 -0
  8. {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/telemetry.py +22 -22
  9. licos_agent_runtime-0.2.2/src/licos_agent_runtime/workflow_protocol.py +130 -0
  10. {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/tests/test_graph_loader.py +35 -0
  11. {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/tests/test_stream_protocol.py +49 -0
  12. licos_agent_runtime-0.2.2/tests/test_workflow_protocol.py +137 -0
  13. {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/__init__.py +0 -0
  14. {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/__main__.py +0 -0
  15. {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/app.py +0 -0
  16. {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/context.py +0 -0
  17. {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/errors.py +0 -0
  18. {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/logging.py +0 -0
  19. {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/openai.py +0 -0
  20. {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/parser.py +0 -0
  21. {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/payload.py +0 -0
  22. {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/src/licos_agent_runtime/run_config.py +0 -0
  23. {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/tests/test_context.py +0 -0
  24. {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/tests/test_parser.py +0 -0
  25. {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/tests/test_payload.py +0 -0
  26. {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/tests/test_service_stream_protocol.py +0 -0
  27. {licos_agent_runtime-0.2.0 → licos_agent_runtime-0.2.2}/tests/test_stream_runner.py +0 -0
@@ -31,6 +31,7 @@ Thumbs.db
31
31
 
32
32
  # Build
33
33
  *.log
34
+ *.pid
34
35
  .licos
35
36
 
36
37
  /tmp
@@ -43,4 +44,6 @@ Thumbs.db
43
44
 
44
45
  dist
45
46
 
46
- project-20260423_130440
47
+ project-20260423_130440
48
+ projects-20260425_workflow
49
+ coze-skills
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: licos-agent-runtime
3
- Version: 0.2.0
3
+ Version: 0.2.2
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
@@ -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.0"
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"]
@@ -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
- if project_type.upper() == "AGENT":
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
- return not is_agent_project(root)
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
- run_config = init_agent_config(graph, ctx) if is_agent_project() else init_run_config(graph, ctx)
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
- async for item in self._stream_sse_platform(payload, ctx=ctx, run_opt=run_opt):
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 _stream_sse_platform(
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
- parser = LangGraphParser(graph)
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
@@ -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
- is_parallel = len(tool_calls) > 1
286
- for index, call in enumerate(tool_calls):
287
- if isinstance(call, dict):
288
- events.append(_tool_request_from_mapping(call, index, is_parallel))
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 or {},
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(value.get("stream_parameters") or ""),
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 ""
@@ -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:
@@ -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()