agentchat-task-agent 0.1.1__tar.gz → 0.1.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 (35) hide show
  1. {agentchat_task_agent-0.1.1/src/agentchat_task_agent.egg-info → agentchat_task_agent-0.1.2}/PKG-INFO +1 -1
  2. {agentchat_task_agent-0.1.1 → agentchat_task_agent-0.1.2}/pyproject.toml +1 -1
  3. {agentchat_task_agent-0.1.1 → agentchat_task_agent-0.1.2/src/agentchat_task_agent.egg-info}/PKG-INFO +1 -1
  4. {agentchat_task_agent-0.1.1 → agentchat_task_agent-0.1.2}/src/agentchat_task_agent.egg-info/SOURCES.txt +1 -0
  5. {agentchat_task_agent-0.1.1 → agentchat_task_agent-0.1.2}/src/task_agent/config.py +3 -0
  6. agentchat_task_agent-0.1.2/src/task_agent/llm.py +30 -0
  7. {agentchat_task_agent-0.1.1 → agentchat_task_agent-0.1.2}/src/task_agent/nodes.py +12 -5
  8. agentchat_task_agent-0.1.2/tests/test_llm.py +49 -0
  9. {agentchat_task_agent-0.1.1 → agentchat_task_agent-0.1.2}/tests/test_task_agent.py +61 -0
  10. agentchat_task_agent-0.1.1/src/task_agent/llm.py +0 -19
  11. {agentchat_task_agent-0.1.1 → agentchat_task_agent-0.1.2}/LICENSE +0 -0
  12. {agentchat_task_agent-0.1.1 → agentchat_task_agent-0.1.2}/README.md +0 -0
  13. {agentchat_task_agent-0.1.1 → agentchat_task_agent-0.1.2}/setup.cfg +0 -0
  14. {agentchat_task_agent-0.1.1 → agentchat_task_agent-0.1.2}/src/agentchat_task_agent.egg-info/dependency_links.txt +0 -0
  15. {agentchat_task_agent-0.1.1 → agentchat_task_agent-0.1.2}/src/agentchat_task_agent.egg-info/entry_points.txt +0 -0
  16. {agentchat_task_agent-0.1.1 → agentchat_task_agent-0.1.2}/src/agentchat_task_agent.egg-info/requires.txt +0 -0
  17. {agentchat_task_agent-0.1.1 → agentchat_task_agent-0.1.2}/src/agentchat_task_agent.egg-info/top_level.txt +0 -0
  18. {agentchat_task_agent-0.1.1 → agentchat_task_agent-0.1.2}/src/task_agent/__init__.py +0 -0
  19. {agentchat_task_agent-0.1.1 → agentchat_task_agent-0.1.2}/src/task_agent/cli.py +0 -0
  20. {agentchat_task_agent-0.1.1 → agentchat_task_agent-0.1.2}/src/task_agent/demo.py +0 -0
  21. {agentchat_task_agent-0.1.1 → agentchat_task_agent-0.1.2}/src/task_agent/executor.py +0 -0
  22. {agentchat_task_agent-0.1.1 → agentchat_task_agent-0.1.2}/src/task_agent/graph.py +0 -0
  23. {agentchat_task_agent-0.1.1 → agentchat_task_agent-0.1.2}/src/task_agent/judge.py +0 -0
  24. {agentchat_task_agent-0.1.1 → agentchat_task_agent-0.1.2}/src/task_agent/memory.py +0 -0
  25. {agentchat_task_agent-0.1.1 → agentchat_task_agent-0.1.2}/src/task_agent/prompts.py +0 -0
  26. {agentchat_task_agent-0.1.1 → agentchat_task_agent-0.1.2}/src/task_agent/py.typed +0 -0
  27. {agentchat_task_agent-0.1.1 → agentchat_task_agent-0.1.2}/src/task_agent/state.py +0 -0
  28. {agentchat_task_agent-0.1.1 → agentchat_task_agent-0.1.2}/src/task_agent/telemetry.py +0 -0
  29. {agentchat_task_agent-0.1.1 → agentchat_task_agent-0.1.2}/src/task_agent/tools.py +0 -0
  30. {agentchat_task_agent-0.1.1 → agentchat_task_agent-0.1.2}/tests/test_cli.py +0 -0
  31. {agentchat_task_agent-0.1.1 → agentchat_task_agent-0.1.2}/tests/test_judge.py +0 -0
  32. {agentchat_task_agent-0.1.1 → agentchat_task_agent-0.1.2}/tests/test_memory.py +0 -0
  33. {agentchat_task_agent-0.1.1 → agentchat_task_agent-0.1.2}/tests/test_resilience.py +0 -0
  34. {agentchat_task_agent-0.1.1 → agentchat_task_agent-0.1.2}/tests/test_telemetry.py +0 -0
  35. {agentchat_task_agent-0.1.1 → agentchat_task_agent-0.1.2}/tests/test_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentchat-task-agent
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: 自主任务 Agent:面向模糊长目标的多步自主执行引擎(LangGraph),零业务依赖
5
5
  Author: Zhuliqx
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agentchat-task-agent"
7
- version = "0.1.1"
7
+ version = "0.1.2"
8
8
  description = "自主任务 Agent:面向模糊长目标的多步自主执行引擎(LangGraph),零业务依赖"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentchat-task-agent
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: 自主任务 Agent:面向模糊长目标的多步自主执行引擎(LangGraph),零业务依赖
5
5
  Author: Zhuliqx
6
6
  License-Expression: MIT
@@ -24,6 +24,7 @@ src/task_agent/telemetry.py
24
24
  src/task_agent/tools.py
25
25
  tests/test_cli.py
26
26
  tests/test_judge.py
27
+ tests/test_llm.py
27
28
  tests/test_memory.py
28
29
  tests/test_resilience.py
29
30
  tests/test_task_agent.py
@@ -19,6 +19,7 @@ class TaskAgentConfig:
19
19
  hitl: bool = True # 节点级人工确认(依赖 checkpointer,无则自动降级全自主)
20
20
  max_retries: int = 2 # verify 容错:单个子任务失败后自检的最大重试次数
21
21
  max_steps: int = 8 # replan 模式步数上限(防循环)
22
+ replan_retries: int = 1 # replan 解析不到下一步动作时的重试次数(空响应/非 JSON 兜底)
22
23
  llm_timeout: float = 60.0 # 单次 LLM 调用超时(节点级 timeout 用)
23
24
  llm_max_retries: int = 2 # 节点级瞬时错误重试次数
24
25
  findings_budget: int | None = None # findings 保留条数上限;超限把历史压缩进 findings_summary(None=不限)
@@ -28,5 +29,7 @@ class TaskAgentConfig:
28
29
  raise ValueError(f"未知任务模式: {self.mode!r}(支持 replan / fixed)")
29
30
  if self.max_retries < 0 or self.max_steps <= 0:
30
31
  raise ValueError("max_retries >= 0 且 max_steps > 0")
32
+ if self.replan_retries < 0:
33
+ raise ValueError("replan_retries >= 0")
31
34
  if self.findings_budget is not None and self.findings_budget <= 0:
32
35
  raise ValueError("findings_budget 必须为正整数或 None(不限制)")
@@ -0,0 +1,30 @@
1
+ """LLM 最小协议与文本抽取工具(不绑定具体厂商 SDK)。"""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Callable, Protocol
5
+
6
+
7
+ class LLM(Protocol):
8
+ """宿主/默认实现只需提供 async ainvoke(prompt) -> 带 .content 的响应。"""
9
+
10
+ async def ainvoke(self, prompt: str) -> Any: ...
11
+
12
+
13
+ LLMFactory = Callable[[], LLM]
14
+
15
+
16
+ async def llm_text(llm: LLM, prompt: str, empty_retries: int = 2) -> str:
17
+ """单次 LLM 调用并抽取文本;失败直接抛异常(由节点级 retry/error_handler 处理)。
18
+
19
+ 空/纯空白响应视为瞬时失败(上游 LLM 偶发空内容),重试 ``empty_retries`` 次;
20
+ 重试耗尽仍为空则返回空串,由上层解析/降级逻辑决定语义(如 replan 的空
21
+ ``next_action`` 被当作"无下一步")。
22
+ """
23
+ last = ""
24
+ for _ in range(empty_retries + 1):
25
+ resp = await llm.ainvoke(prompt)
26
+ text = resp.content if isinstance(resp.content, str) else str(resp.content)
27
+ if text and text.strip():
28
+ return text
29
+ last = text
30
+ return last
@@ -127,8 +127,13 @@ def make_nodes(runtime: Runtime) -> dict[str, Node]:
127
127
  goal = state["goal"]
128
128
  findings = state.get("findings") or []
129
129
  mem = await _memory_ctx(runtime, goal)
130
- action, source = _parse_next_action(
131
- await llm_text(
130
+ # 首次进入(step=0)语义上是初始规划,发 "plan";后续才发 "replan"
131
+ kind = "replan" if int(state.get("step") or 0) > 0 else "plan"
132
+ # 解析不到下一步动作时重试(覆盖空响应与非 JSON 输出),
133
+ # 避免上游 LLM 偶发异常被误判为"任务已完成"而提前结束。
134
+ action, source = "", "default"
135
+ for _ in range(config.replan_retries + 1):
136
+ raw = await llm_text(
132
137
  runtime.llm_factory(),
133
138
  REPLAN_PROMPT.format(
134
139
  goal=goal,
@@ -136,12 +141,14 @@ def make_nodes(runtime: Runtime) -> dict[str, Node]:
136
141
  memory=mem,
137
142
  ),
138
143
  )
139
- )
144
+ action, source = _parse_next_action(raw)
145
+ if action:
146
+ break
140
147
  if not action:
141
148
  # 无下一步 → 视为可完成
142
- runtime.emit("replan", {"action": "", "source": source})
149
+ runtime.emit(kind, {"action": "", "source": source})
143
150
  return {"current_action": "", "done": True, "retries": 0}
144
- runtime.emit("replan", {"action": action, "source": source})
151
+ runtime.emit(kind, {"action": action, "source": source})
145
152
  return {"current_action": action, "expected_source": source, "retries": 0}
146
153
 
147
154
  async def execute_action_node(state: dict) -> dict:
@@ -0,0 +1,49 @@
1
+ """llm_text 空响应重试行为测试。"""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ from types import SimpleNamespace
6
+
7
+ from task_agent.llm import llm_text
8
+
9
+
10
+ class _FakeLLM:
11
+ """按调用顺序返回预设文本的假 LLM(超出后重复最后一条)。"""
12
+
13
+ def __init__(self, *contents: str):
14
+ self._contents = list(contents)
15
+ self.calls = 0
16
+
17
+ async def ainvoke(self, prompt):
18
+ self.calls += 1
19
+ return SimpleNamespace(
20
+ content=self._contents[min(self.calls - 1, len(self._contents) - 1)]
21
+ )
22
+
23
+
24
+ def _run(coro):
25
+ return asyncio.run(coro)
26
+
27
+
28
+ def test_llm_text_returns_content_without_retry():
29
+ llm = _FakeLLM("答案")
30
+ assert _run(llm_text(llm, "p")) == "答案"
31
+ assert llm.calls == 1
32
+
33
+
34
+ def test_llm_text_retries_empty_then_succeeds():
35
+ llm = _FakeLLM("", " ", "答案")
36
+ assert _run(llm_text(llm, "p")) == "答案"
37
+ assert llm.calls == 3 # 首次 + 2 次重试
38
+
39
+
40
+ def test_llm_text_returns_empty_after_retries_exhausted():
41
+ llm = _FakeLLM("", "", "")
42
+ assert _run(llm_text(llm, "p")) == ""
43
+ assert llm.calls == 3
44
+
45
+
46
+ def test_llm_text_respects_custom_retries():
47
+ llm = _FakeLLM("", "")
48
+ assert _run(llm_text(llm, "p", empty_retries=1)) == ""
49
+ assert llm.calls == 2
@@ -464,3 +464,64 @@ def test_demo_flow_offline():
464
464
  assert result.get("final_answer")
465
465
  assert "2020" in result["final_answer"]
466
466
  assert len(result.get("findings") or []) >= 2
467
+
468
+
469
+ def test_replan_empty_llm_response_is_not_premature_done():
470
+ """LLM 连续空响应后给出合法动作:不应被当作“没有下一步”提前结束。"""
471
+ llm = _SequenceLLM(
472
+ "", "", '{"next_action": "计算", "expected_source": "code"}'
473
+ )
474
+ nodes = _nodes(llm=llm)
475
+ out = asyncio.run(
476
+ nodes["replan_node"]({"goal": "计算 12 和 18 的最小公倍数", "findings": []})
477
+ )
478
+ assert out.get("current_action") == "计算"
479
+ assert not out.get("done", False)
480
+
481
+
482
+ def test_replan_persistently_empty_falls_back_to_done():
483
+ """LLM 持续空响应(重试耗尽)才按“无下一步”结束。"""
484
+ llm = _SequenceLLM("", "", "")
485
+ nodes = _nodes(llm=llm)
486
+ out = asyncio.run(nodes["replan_node"]({"goal": "目标", "findings": []}))
487
+ assert out.get("done") is True
488
+ assert not out.get("current_action")
489
+
490
+
491
+ def test_replan_unparseable_then_valid_action():
492
+ """非 JSON 输出不应被当作“没有下一步”:重试一次后解析出动作。"""
493
+ llm = _SequenceLLM("抱歉,我无法完成该任务。", '{"next_action": "计算", "expected_source": "code"}')
494
+ nodes = _nodes(llm=llm)
495
+ out = asyncio.run(
496
+ nodes["replan_node"]({"goal": "计算 12 和 18 的最小公倍数", "findings": []})
497
+ )
498
+ assert out.get("current_action") == "计算"
499
+ assert not out.get("done", False)
500
+
501
+
502
+ def test_replan_persistently_unparseable_falls_back_to_done():
503
+ """持续非 JSON 输出(重试耗尽)才按“无下一步”结束。"""
504
+ llm = _SequenceLLM("抱歉,我无法完成该任务。", "无法解析")
505
+ nodes = _nodes(llm=llm)
506
+ out = asyncio.run(nodes["replan_node"]({"goal": "目标", "findings": []}))
507
+ assert out.get("done") is True
508
+ assert not out.get("current_action")
509
+
510
+
511
+ def test_replan_emits_plan_then_replan():
512
+ """首次进入 replan 节点发 "plan"(初始规划),后续轮次才发 "replan"。"""
513
+ events: list[tuple[str, dict]] = []
514
+ llm = _SequenceLLM(
515
+ '{"next_action": "步骤一", "expected_source": "default"}',
516
+ '{"next_action": "步骤二", "expected_source": "default"}',
517
+ )
518
+ runtime = Runtime(
519
+ config=TaskAgentConfig(),
520
+ llm_factory=lambda: llm,
521
+ executor=_FakeExecutor(),
522
+ on_event=lambda kind, data: events.append((kind, data)),
523
+ )
524
+ nodes = make_nodes(runtime)
525
+ asyncio.run(nodes["replan_node"]({"goal": "目标", "findings": [], "step": 0}))
526
+ asyncio.run(nodes["replan_node"]({"goal": "目标", "findings": [], "step": 1}))
527
+ assert [k for k, _ in events] == ["plan", "replan"]
@@ -1,19 +0,0 @@
1
- """LLM 最小协议与文本抽取工具(不绑定具体厂商 SDK)。"""
2
- from __future__ import annotations
3
-
4
- from typing import Any, Callable, Protocol
5
-
6
-
7
- class LLM(Protocol):
8
- """宿主/默认实现只需提供 async ainvoke(prompt) -> 带 .content 的响应。"""
9
-
10
- async def ainvoke(self, prompt: str) -> Any: ...
11
-
12
-
13
- LLMFactory = Callable[[], LLM]
14
-
15
-
16
- async def llm_text(llm: LLM, prompt: str) -> str:
17
- """单次 LLM 调用并抽取文本;失败直接抛异常(由节点级 retry/error_handler 处理)。"""
18
- resp = await llm.ainvoke(prompt)
19
- return resp.content if isinstance(resp.content, str) else str(resp.content)