licos-agent-runtime 0.2.6__tar.gz → 0.2.8__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 (32) hide show
  1. {licos_agent_runtime-0.2.6 → licos_agent_runtime-0.2.8}/PKG-INFO +1 -1
  2. {licos_agent_runtime-0.2.6 → licos_agent_runtime-0.2.8}/pyproject.toml +1 -1
  3. {licos_agent_runtime-0.2.6 → licos_agent_runtime-0.2.8}/src/licos_agent_runtime/graph_loader.py +18 -11
  4. {licos_agent_runtime-0.2.6 → licos_agent_runtime-0.2.8}/src/licos_agent_runtime/service.py +77 -16
  5. {licos_agent_runtime-0.2.6 → licos_agent_runtime-0.2.8}/src/licos_agent_runtime/workflow_canvas.py +3 -1
  6. {licos_agent_runtime-0.2.6 → licos_agent_runtime-0.2.8}/tests/test_graph_loader.py +60 -0
  7. {licos_agent_runtime-0.2.6 → licos_agent_runtime-0.2.8}/tests/test_workflow_protocol.py +140 -0
  8. {licos_agent_runtime-0.2.6 → licos_agent_runtime-0.2.8}/.gitignore +0 -0
  9. {licos_agent_runtime-0.2.6 → licos_agent_runtime-0.2.8}/src/licos_agent_runtime/__init__.py +0 -0
  10. {licos_agent_runtime-0.2.6 → licos_agent_runtime-0.2.8}/src/licos_agent_runtime/__main__.py +0 -0
  11. {licos_agent_runtime-0.2.6 → licos_agent_runtime-0.2.8}/src/licos_agent_runtime/agent_canvas.py +0 -0
  12. {licos_agent_runtime-0.2.6 → licos_agent_runtime-0.2.8}/src/licos_agent_runtime/agent_config.py +0 -0
  13. {licos_agent_runtime-0.2.6 → licos_agent_runtime-0.2.8}/src/licos_agent_runtime/app.py +0 -0
  14. {licos_agent_runtime-0.2.6 → licos_agent_runtime-0.2.8}/src/licos_agent_runtime/attachments.py +0 -0
  15. {licos_agent_runtime-0.2.6 → licos_agent_runtime-0.2.8}/src/licos_agent_runtime/context.py +0 -0
  16. {licos_agent_runtime-0.2.6 → licos_agent_runtime-0.2.8}/src/licos_agent_runtime/errors.py +0 -0
  17. {licos_agent_runtime-0.2.6 → licos_agent_runtime-0.2.8}/src/licos_agent_runtime/logging.py +0 -0
  18. {licos_agent_runtime-0.2.6 → licos_agent_runtime-0.2.8}/src/licos_agent_runtime/openai.py +0 -0
  19. {licos_agent_runtime-0.2.6 → licos_agent_runtime-0.2.8}/src/licos_agent_runtime/parser.py +0 -0
  20. {licos_agent_runtime-0.2.6 → licos_agent_runtime-0.2.8}/src/licos_agent_runtime/payload.py +0 -0
  21. {licos_agent_runtime-0.2.6 → licos_agent_runtime-0.2.8}/src/licos_agent_runtime/run_config.py +0 -0
  22. {licos_agent_runtime-0.2.6 → licos_agent_runtime-0.2.8}/src/licos_agent_runtime/stream_protocol.py +0 -0
  23. {licos_agent_runtime-0.2.6 → licos_agent_runtime-0.2.8}/src/licos_agent_runtime/stream_runner.py +0 -0
  24. {licos_agent_runtime-0.2.6 → licos_agent_runtime-0.2.8}/src/licos_agent_runtime/telemetry.py +0 -0
  25. {licos_agent_runtime-0.2.6 → licos_agent_runtime-0.2.8}/src/licos_agent_runtime/workflow_protocol.py +0 -0
  26. {licos_agent_runtime-0.2.6 → licos_agent_runtime-0.2.8}/tests/test_agent_config.py +0 -0
  27. {licos_agent_runtime-0.2.6 → licos_agent_runtime-0.2.8}/tests/test_context.py +0 -0
  28. {licos_agent_runtime-0.2.6 → licos_agent_runtime-0.2.8}/tests/test_parser.py +0 -0
  29. {licos_agent_runtime-0.2.6 → licos_agent_runtime-0.2.8}/tests/test_payload.py +0 -0
  30. {licos_agent_runtime-0.2.6 → licos_agent_runtime-0.2.8}/tests/test_service_stream_protocol.py +0 -0
  31. {licos_agent_runtime-0.2.6 → licos_agent_runtime-0.2.8}/tests/test_stream_protocol.py +0 -0
  32. {licos_agent_runtime-0.2.6 → licos_agent_runtime-0.2.8}/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.6
3
+ Version: 0.2.8
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "licos-agent-runtime"
7
- version = "0.2.6"
7
+ version = "0.2.8"
8
8
  description = "LICOS Python agent runtime for LangGraph/LangChain projects"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -5,7 +5,7 @@ import inspect
5
5
  import os
6
6
  import sys
7
7
  from pathlib import Path
8
- from typing import Any
8
+ from typing import Any, get_type_hints
9
9
 
10
10
  from .context import Context
11
11
 
@@ -217,7 +217,7 @@ def _node_data(node: Any) -> Any:
217
217
  for attr in ("data", "func", "runnable", "node", "callable"):
218
218
  try:
219
219
  value = inspect.getattr_static(node, attr)
220
- if isinstance(value, property):
220
+ if isinstance(value, property) or type(value).__name__ == "_tuplegetter":
221
221
  value = getattr(node, attr, None)
222
222
  except AttributeError:
223
223
  value = getattr(node, attr, None)
@@ -229,14 +229,16 @@ def _node_data(node: Any) -> Any:
229
229
  def _resolve_callable(value: Any) -> Any | None:
230
230
  if value is None:
231
231
  return None
232
- if callable(value) or hasattr(value, "invoke") or hasattr(value, "ainvoke"):
233
- return value
234
232
  for attr in ("func", "afunc", "bound", "runnable", "node", "callable"):
235
233
  nested = getattr(value, attr, None)
234
+ if inspect.ismethod(nested) and getattr(nested, "__self__", None) is value:
235
+ nested = nested.__func__
236
236
  if nested is not None and nested is not value:
237
237
  resolved = _resolve_callable(nested)
238
238
  if resolved is not None:
239
239
  return resolved
240
+ if callable(value) or hasattr(value, "invoke") or hasattr(value, "ainvoke"):
241
+ return value
240
242
  return None
241
243
 
242
244
 
@@ -257,18 +259,23 @@ def _schema_from_signature(value: Any, kind: str) -> Any | None:
257
259
  signature = inspect.signature(value)
258
260
  except (TypeError, ValueError):
259
261
  return None
262
+ try:
263
+ type_hints = get_type_hints(value)
264
+ except Exception:
265
+ type_hints = {}
260
266
 
261
267
  if kind == "input":
262
268
  for parameter in signature.parameters.values():
263
- if parameter.name in {"self", "config", "context", "ctx"}:
269
+ if parameter.name in {"self", "config", "context", "ctx", "runtime"}:
264
270
  continue
265
- if parameter.annotation is not inspect.Signature.empty:
266
- return parameter.annotation
271
+ annotation = type_hints.get(parameter.name, parameter.annotation)
272
+ if annotation is not inspect.Signature.empty and not isinstance(annotation, str):
273
+ return annotation
267
274
  return None
268
275
  return None
269
276
 
270
- annotation = signature.return_annotation
271
- if annotation is inspect.Signature.empty:
277
+ annotation = type_hints.get("return", signature.return_annotation)
278
+ if annotation is inspect.Signature.empty or isinstance(annotation, str):
272
279
  return None
273
280
  return annotation
274
281
 
@@ -315,6 +322,6 @@ def get_graph_node_func_with_inout(graph: Any, node_id: str) -> tuple[Any | None
315
322
 
316
323
  data = _node_data(node)
317
324
  func = _resolve_callable(data)
318
- input_cls = _schema_from(node, "input") or _schema_from(data, "input") or _schema_from(func, "input")
319
- output_cls = _schema_from(node, "output") or _schema_from(data, "output") or _schema_from(func, "output")
325
+ input_cls = _schema_from(node, "input") or _schema_from(func, "input") or _schema_from(data, "input")
326
+ output_cls = _schema_from(node, "output") or _schema_from(func, "output") or _schema_from(data, "output")
320
327
  return func, input_cls, output_cls
@@ -109,6 +109,80 @@ def _select_supported_kwargs(method: Any, kwargs_list: list[dict[str, Any]]) ->
109
109
  return {}
110
110
 
111
111
 
112
+ def _runtime_for_context(ctx: Context) -> Any:
113
+ try:
114
+ from langgraph.runtime import Runtime
115
+ except Exception:
116
+ return None
117
+ return Runtime(context=ctx)
118
+
119
+
120
+ def _coerce_node_payload(payload: dict[str, Any], input_cls: Any | None) -> Any:
121
+ if input_cls is None or input_cls is dict or input_cls is Any:
122
+ return payload
123
+ if not isinstance(payload, dict):
124
+ return payload
125
+
126
+ validate = getattr(input_cls, "model_validate", None)
127
+ if callable(validate):
128
+ return validate(payload)
129
+
130
+ if inspect.isclass(input_cls):
131
+ try:
132
+ return input_cls(**payload)
133
+ except Exception:
134
+ return payload
135
+
136
+ return payload
137
+
138
+
139
+ def _normalize_node_result(result: Any) -> Any:
140
+ dump = getattr(result, "model_dump", None)
141
+ if callable(dump):
142
+ return dump()
143
+ return result
144
+
145
+
146
+ async def _call_node_callable(func: Any, payload: Any, run_config: dict[str, Any], ctx: Context) -> Any:
147
+ if hasattr(func, "ainvoke") and callable(func.ainvoke):
148
+ kwargs = _select_supported_kwargs(
149
+ func.ainvoke,
150
+ [
151
+ {"config": run_config, "context": ctx},
152
+ {"config": run_config},
153
+ ],
154
+ )
155
+ return _normalize_node_result(await func.ainvoke(payload, **kwargs))
156
+
157
+ if hasattr(func, "invoke") and callable(func.invoke):
158
+ kwargs = _select_supported_kwargs(
159
+ func.invoke,
160
+ [
161
+ {"config": run_config, "context": ctx},
162
+ {"config": run_config},
163
+ ],
164
+ )
165
+ return _normalize_node_result(func.invoke(payload, **kwargs))
166
+
167
+ if not callable(func):
168
+ raise TypeError("node is not callable")
169
+
170
+ runtime = _runtime_for_context(ctx)
171
+ kwargs = _select_supported_kwargs(
172
+ func,
173
+ [
174
+ {"config": run_config, "runtime": runtime, "context": ctx},
175
+ {"config": run_config, "runtime": runtime},
176
+ {"config": run_config, "context": ctx},
177
+ {"config": run_config},
178
+ ],
179
+ )
180
+ result = func(payload, **kwargs)
181
+ if inspect.isawaitable(result):
182
+ result = await result
183
+ return _normalize_node_result(result)
184
+
185
+
112
186
  class GraphService:
113
187
  def __init__(
114
188
  self,
@@ -501,22 +575,9 @@ class GraphService:
501
575
  node_func, input_cls, output_cls = get_graph_node_func_with_inout(raw_graph, node_id)
502
576
  if node_func is None:
503
577
  raise KeyError(f"node_id '{node_id}' not found")
504
- if input_cls is None:
505
- input_cls = dict
506
-
507
- from langgraph.graph import END, StateGraph
508
-
509
- parser = LangGraphParser(graph)
510
- metadata = parser.get_node_metadata(node_id) or {}
511
- state_graph_kwargs: dict[str, Any] = {"input_schema": input_cls}
512
- if output_cls is not None:
513
- state_graph_kwargs["output_schema"] = output_cls
514
- single_node_graph = StateGraph(input_cls, **state_graph_kwargs)
515
- single_node_graph.add_node("sn", node_func, metadata=metadata)
516
- single_node_graph.set_entry_point("sn")
517
- single_node_graph.add_edge("sn", END)
518
- compiled = single_node_graph.compile()
519
- return await _call_ainvoke(compiled, payload, init_run_config(compiled, ctx), ctx)
578
+ del output_cls
579
+ node_payload = _coerce_node_payload(payload, input_cls)
580
+ return await _call_node_callable(node_func, node_payload, init_run_config(graph, ctx), ctx)
520
581
 
521
582
  def graph_inout_schema(self) -> dict[str, Any]:
522
583
  graph = self._get_graph()
@@ -158,6 +158,8 @@ def build_query_text(before_nodes: list[dict[str, Any]], diff: dict[str, Any]) -
158
158
  "2) 阅读 summary / instructions / stats,快速理解总体变更。\n"
159
159
  "3) 新增/删除节点:查看 adds/removes 中的 tree(带层级缩进)和 after/before(完整嵌套结构)了解节点详情,node 了解节点基本信息,container/prev_id/next_id 了解位置。\n"
160
160
  "4) 字段更新/约束/类型/顺序调整:查看 updates/reorders。\n\n"
161
+ "硬性要求:每个节点都必须保留 after.type / node.type 中的画布节点类型,并同步写入 graph.py 的 add_node metadata.type 与 AGENTS.md 节点清单;"
162
+ "例如 type=parallel 必须保持为 parallel,不能改写成 action。\n\n"
161
163
  "注意:prev_id/next_id 表示节点在流程中的前后顺序位置,不代表图的边连接关系。subtree 为辅助信息,以 tree 和 after/before 的层级结构为准。\n\n"
162
164
  "QueryDiff JSON:\n"
163
165
  f"{diff_json}"
@@ -246,7 +248,7 @@ def _dependencies(metadata: dict[str, Any]) -> dict[str, Any]:
246
248
  if isinstance(raw, dict):
247
249
  return {key: value for key, value in raw.items() if value not in (None, "", [], {})}
248
250
  result: dict[str, Any] = {}
249
- llm_config = metadata.get("llm_config")
251
+ llm_config = metadata.get("llm_cfg") or metadata.get("llm_config")
250
252
  if isinstance(llm_config, str) and llm_config.strip():
251
253
  result["llm_config"] = [llm_config.strip()]
252
254
  elif isinstance(llm_config, list):
@@ -1,6 +1,7 @@
1
1
  import os
2
2
  import tempfile
3
3
  import unittest
4
+ from collections import namedtuple
4
5
  from pathlib import Path
5
6
  from unittest.mock import patch
6
7
 
@@ -140,6 +141,65 @@ class GraphLoaderTests(unittest.TestCase):
140
141
 
141
142
  self.assertIs(func, real_node_name)
142
143
 
144
+ def test_get_graph_node_func_extracts_namedtuple_node_data(self):
145
+ GraphNode = namedtuple("GraphNode", ["id", "name", "data", "metadata"])
146
+
147
+ def node_func(payload):
148
+ return payload
149
+
150
+ class RawGraph:
151
+ nodes = {"node_a": GraphNode("node_a", "node_a", node_func, {})}
152
+
153
+ func, _input_cls, _output_cls = get_graph_node_func_with_inout(RawGraph(), "node_a")
154
+
155
+ self.assertIs(func, node_func)
156
+
157
+ def test_get_graph_node_func_prefers_wrapped_callable_func(self):
158
+ def node_func(payload):
159
+ return payload
160
+
161
+ class RunnableLike:
162
+ func = node_func
163
+
164
+ def __call__(self, payload):
165
+ return self.func(payload)
166
+
167
+ class Node:
168
+ data = RunnableLike()
169
+
170
+ class RawGraph:
171
+ nodes = {"node_a": Node()}
172
+
173
+ func, _input_cls, _output_cls = get_graph_node_func_with_inout(RawGraph(), "node_a")
174
+
175
+ self.assertIs(func, node_func)
176
+
177
+ def test_get_graph_node_func_resolves_future_annotations(self):
178
+ namespace = {}
179
+ exec(
180
+ "from __future__ import annotations\n"
181
+ "class Input: pass\n"
182
+ "class Output: pass\n"
183
+ "def node_func(state: Input) -> Output:\n"
184
+ " return Output()\n",
185
+ namespace,
186
+ )
187
+ node_func = namespace["node_func"]
188
+ input_cls = namespace["Input"]
189
+ output_cls = namespace["Output"]
190
+
191
+ class Node:
192
+ data = node_func
193
+
194
+ class RawGraph:
195
+ nodes = {"node_a": Node()}
196
+
197
+ func, resolved_input, resolved_output = get_graph_node_func_with_inout(RawGraph(), "node_a")
198
+
199
+ self.assertIs(func, node_func)
200
+ self.assertIs(resolved_input, input_cls)
201
+ self.assertIs(resolved_output, output_cls)
202
+
143
203
  def test_call_factory_does_not_swallow_internal_type_error(self):
144
204
  def factory(_ctx):
145
205
  raise TypeError("internal")
@@ -94,6 +94,145 @@ class WorkflowProtocolTests(unittest.TestCase):
94
94
  self.assertIn("#L", nodes[1]["script"]["content"])
95
95
  self.assertEqual(nodes[2]["definition"]["inputs"]["properties"]["answer"]["type"], "string")
96
96
 
97
+ def test_workflow_canvas_maps_llm_cfg_to_llm_config_dependency(self):
98
+ def analyze_topic(state):
99
+ return {"answer": state.get("topic", "")}
100
+
101
+ class Node:
102
+ metadata = {
103
+ "title": "智能分析",
104
+ "type": "agent",
105
+ "llm_cfg": "config/analyze_topic_cfg.json",
106
+ }
107
+ data = analyze_topic
108
+
109
+ class RawGraph:
110
+ nodes = {"analyze_topic": Node()}
111
+
112
+ class Graph:
113
+ def get_graph(self):
114
+ return RawGraph()
115
+
116
+ class Service(GraphService):
117
+ def _get_graph(self, ctx=None):
118
+ del ctx
119
+ return Graph()
120
+
121
+ with patch("licos_agent_runtime.service.is_agent_project", return_value=False):
122
+ result = Service().agent_canvas()
123
+
124
+ nodes = json.loads(result["data"]["data"])["nodes"]
125
+ self.assertEqual(nodes[1]["type"], "agent")
126
+ self.assertEqual(
127
+ nodes[1]["definition"]["info"]["dependencies"]["llm_config"],
128
+ ["config/analyze_topic_cfg.json"],
129
+ )
130
+
131
+ def test_run_node_executes_node_callable_directly(self):
132
+ def generate_answer(state):
133
+ return {"answer": state.get("topic", "")}
134
+
135
+ class Node:
136
+ metadata = {"title": "生成结果", "type": "action"}
137
+ data = generate_answer
138
+
139
+ class RawGraph:
140
+ nodes = {"generate_answer": Node()}
141
+
142
+ class Graph:
143
+ def get_graph(self):
144
+ return RawGraph()
145
+
146
+ class Service(GraphService):
147
+ def _get_graph(self, ctx=None):
148
+ del ctx
149
+ return Graph()
150
+
151
+ result = asyncio.run(
152
+ Service().run_node("generate_answer", {"topic": "单节点测试"}, new_context("test_node_run"))
153
+ )
154
+
155
+ self.assertEqual(result, {"answer": "单节点测试"})
156
+
157
+ def test_run_node_passes_config_and_runtime_when_supported(self):
158
+ runtime_marker = object()
159
+
160
+ def generate_answer(state, config, runtime):
161
+ return {
162
+ "answer": state.get("topic", ""),
163
+ "has_config": "configurable" in config,
164
+ "has_runtime": runtime is runtime_marker,
165
+ }
166
+
167
+ class Node:
168
+ metadata = {"title": "生成结果", "type": "action"}
169
+ data = generate_answer
170
+
171
+ class RawGraph:
172
+ nodes = {"generate_answer": Node()}
173
+
174
+ class Graph:
175
+ def get_graph(self):
176
+ return RawGraph()
177
+
178
+ class Service(GraphService):
179
+ def _get_graph(self, ctx=None):
180
+ del ctx
181
+ return Graph()
182
+
183
+ with patch("licos_agent_runtime.service._runtime_for_context", return_value=runtime_marker):
184
+ result = asyncio.run(
185
+ Service().run_node("generate_answer", {"topic": "单节点测试"}, new_context("test_node_run"))
186
+ )
187
+
188
+ self.assertEqual(result["answer"], "单节点测试")
189
+ self.assertTrue(result["has_config"])
190
+ self.assertTrue(result["has_runtime"])
191
+
192
+ def test_run_node_coerces_payload_to_node_input_schema(self):
193
+ class Input:
194
+ def __init__(self, topic):
195
+ self.topic = topic
196
+
197
+ @classmethod
198
+ def model_validate(cls, payload):
199
+ return cls(payload["topic"])
200
+
201
+ class Output:
202
+ def __init__(self, answer):
203
+ self.answer = answer
204
+
205
+ def model_dump(self):
206
+ return {"answer": self.answer}
207
+
208
+ def generate_answer(state):
209
+ return Output(state.topic)
210
+
211
+ generate_answer.input_schema = Input
212
+ generate_answer.output_schema = Output
213
+
214
+ class Node:
215
+ metadata = {"title": "生成结果", "type": "action"}
216
+ data = generate_answer
217
+
218
+ class RawGraph:
219
+ nodes = {"generate_answer": Node()}
220
+
221
+ class Graph:
222
+ def get_graph(self):
223
+ return RawGraph()
224
+
225
+ class Service(GraphService):
226
+ def _get_graph(self, ctx=None):
227
+ del ctx
228
+ return Graph()
229
+
230
+ result = asyncio.run(
231
+ Service().run_node("generate_answer", {"topic": "单节点测试"}, new_context("test_node_run"))
232
+ )
233
+
234
+ self.assertEqual(result, {"answer": "单节点测试"})
235
+
97
236
  def test_canvas_submit_builds_query_diff_for_agent_execution(self):
98
237
  before = {
99
238
  "nodes": [
@@ -147,6 +286,7 @@ class WorkflowProtocolTests(unittest.TestCase):
147
286
  self.assertEqual(result["msg"], "")
148
287
  self.assertIn("当前流程结构", result["query"])
149
288
  self.assertIn("QueryDiff JSON", result["query"])
289
+ self.assertIn("必须保留 after.type", result["query"])
150
290
  self.assertEqual(result["display_text"], "2 个节点变更")
151
291
  self.assertEqual(result["draft_text"], "2 个节点已编辑")
152
292
  self.assertEqual(result["reference"]["type"], "workflow_canvas_edit")