plaita-nodes 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. plaita_nodes-0.3.0/.github/workflows/publish.yml +25 -0
  2. plaita_nodes-0.3.0/.gitignore +10 -0
  3. plaita_nodes-0.3.0/AGENTS.md +47 -0
  4. plaita_nodes-0.3.0/PKG-INFO +98 -0
  5. plaita_nodes-0.3.0/README.md +80 -0
  6. plaita_nodes-0.3.0/pyproject.toml +46 -0
  7. plaita_nodes-0.3.0/src/plaita_nodes/__init__.py +53 -0
  8. plaita_nodes-0.3.0/src/plaita_nodes/agent_run.py +232 -0
  9. plaita_nodes-0.3.0/src/plaita_nodes/api.py +120 -0
  10. plaita_nodes-0.3.0/src/plaita_nodes/capture.py +96 -0
  11. plaita_nodes-0.3.0/src/plaita_nodes/config.py +178 -0
  12. plaita_nodes-0.3.0/src/plaita_nodes/database.py +95 -0
  13. plaita_nodes-0.3.0/src/plaita_nodes/email.py +64 -0
  14. plaita_nodes-0.3.0/src/plaita_nodes/gate.py +83 -0
  15. plaita_nodes-0.3.0/src/plaita_nodes/hitl.py +126 -0
  16. plaita_nodes-0.3.0/src/plaita_nodes/hitl_await.py +110 -0
  17. plaita_nodes-0.3.0/src/plaita_nodes/hitl_poller.py +96 -0
  18. plaita_nodes-0.3.0/src/plaita_nodes/llm.py +130 -0
  19. plaita_nodes-0.3.0/src/plaita_nodes/notify.py +35 -0
  20. plaita_nodes-0.3.0/src/plaita_nodes/rate_limit.py +79 -0
  21. plaita_nodes-0.3.0/src/plaita_nodes/report.py +89 -0
  22. plaita_nodes-0.3.0/src/plaita_nodes/webhooks.py +116 -0
  23. plaita_nodes-0.3.0/src/plaita_nodes/write_file.py +52 -0
  24. plaita_nodes-0.3.0/tests/conftest.py +65 -0
  25. plaita_nodes-0.3.0/tests/fake_exec.py +15 -0
  26. plaita_nodes-0.3.0/tests/test_agent_run.py +83 -0
  27. plaita_nodes-0.3.0/tests/test_capture.py +55 -0
  28. plaita_nodes-0.3.0/tests/test_config.py +75 -0
  29. plaita_nodes-0.3.0/tests/test_connectors.py +205 -0
  30. plaita_nodes-0.3.0/tests/test_hitl.py +142 -0
  31. plaita_nodes-0.3.0/tests/test_hitl_await_distributed.py +129 -0
  32. plaita_nodes-0.3.0/tests/test_llm.py +103 -0
  33. plaita_nodes-0.3.0/tests/test_registry.py +11 -0
@@ -0,0 +1,25 @@
1
+ name: Publish to PyPI
2
+
3
+ # 推送 v* 标签时发布 plaita-nodes 到 PyPI。
4
+ # 前置一次性配置:pypi.org 上为 plaita-nodes 添加 Pending Publisher,
5
+ # owner=jeffkit repo=plaita-nodes workflow=Publish to PyPI environment=pypi
6
+ on:
7
+ push:
8
+ tags: ["v*"]
9
+ workflow_dispatch:
10
+
11
+ permissions:
12
+ contents: read
13
+ id-token: write
14
+
15
+ jobs:
16
+ publish:
17
+ runs-on: ubuntu-latest
18
+ environment: pypi
19
+ steps:
20
+ - uses: actions/checkout@v6
21
+ - uses: actions/setup-python@v6
22
+ with:
23
+ python-version: "3.10"
24
+ - run: python -m pip install --upgrade build && python -m build
25
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .pytest_cache/
7
+ .venv/
8
+ mutants/
9
+ .mutmut-cache/
10
+ .DS_Store
@@ -0,0 +1,47 @@
1
+ # AGENTS.md — plaita-nodes
2
+
3
+ > plaita 通用节点集:AgentRun(经 agentproc)/ Capture / Hitl / Notify / WriteFile。
4
+ > 大仓 ADR-2026-08-27(编排收敛 plaita + agentproc)的节点承载层。
5
+
6
+ ## 项目概述
7
+
8
+ 为 plaita 流程提供"接到真实世界"的通用节点:Agent CLI 调用(复用 flowcast 的
9
+ agents/providers 配置,执行走 agentproc Python SDK)、本地命令、微信 HITL、通知、写文件。
10
+ 业务粘接节点(读 persona/pool 等)**不放本仓**,放业务仓(如 mediaflow/plaita_flows)。
11
+
12
+ **技术栈:** Python 3.10+ · plaita(兄弟仓 editable)· agentproc(兄弟仓 editable)· requests
13
+ **主仓库:** `git@github.com:jeffkit/plaita-nodes.git`
14
+
15
+ ## 架构地图
16
+
17
+ 依赖方向:`config.py`(flowcast 兼容配置层)← `agent_run.py`(agentproc executor 注册 + 节点)← 其余节点独立。
18
+ 对 plaita 只依赖 `plaita.Node` 基类与 `NodeExecutionContext` 窄接口;对 agentproc 只依赖 `runner.run` + `EXECUTORS` 注册表。
19
+
20
+ 关键路径:
21
+ - `src/plaita_nodes/config.py` — agents/providers 加载(flowcast 搜索顺序 + 深合并 + `${VAR}` 插值 + provider→env 翻译)
22
+ - `src/plaita_nodes/agent_run.py` — AgentRunNode + `recursive-direct` executor(语义 = flowcast runRecursiveDirect)
23
+ - `pyproject.toml` — `[project.entry-points."plaita.nodes"]` 注册表
24
+
25
+ ## 开发约定
26
+
27
+ **分支:** main(dev/test/prod 同支)。
28
+ **禁止事项:**
29
+ - 业务逻辑入仓(业务节点放业务仓)
30
+ - 日志打印 apiKey / ANTHROPIC_AUTH_TOKEN
31
+ - 依赖 plaita/agentproc 的内部私有 API(只走公开窄接口)
32
+
33
+ **dry-run 契约:** 有副作用的节点一律尊重 `globalContext.dry_run`;writefile 例外(照常写,便于检查草稿)。
34
+
35
+ ## 常用命令
36
+
37
+ ```bash
38
+ pip install -e ".[dev]"
39
+ pytest
40
+ ```
41
+
42
+ ## 深入阅读
43
+
44
+ | 文档 | 说明 |
45
+ |------|------|
46
+ | `README.md` | 节点表 + 配置兼容性说明 |
47
+ | `../docs/ADR-2026-08-27-orchestration-converge-on-plaita.md` | 决议背景 |
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.5
2
+ Name: plaita-nodes
3
+ Version: 0.3.0
4
+ Summary: plaita 通用节点集:AgentRun(经 agentproc)/ Capture / Hitl / Notify / WriteFile
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: agentproc>=0.14.0
8
+ Requires-Dist: plaita>=0.5.0
9
+ Requires-Dist: requests>=2.31
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=8; extra == 'dev'
12
+ Requires-Dist: pyyaml; extra == 'dev'
13
+ Provides-Extra: sql
14
+ Requires-Dist: psycopg2-binary>=2.9; extra == 'sql'
15
+ Requires-Dist: pymysql>=1.1; extra == 'sql'
16
+ Requires-Dist: sqlalchemy>=2.0; extra == 'sql'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # plaita-nodes
20
+
21
+ plaita 的**通用节点集**(infra 级):把 plaita 声明式流程接到真实世界——Agent CLI、本地命令、微信人工确认、通知、文件。
22
+
23
+ > 背景:[ADR-2026-08-27 编排双轨收敛](../docs/ADR-2026-08-27-orchestration-converge-on-plaita.md)
24
+ > ——编排内核收敛到 plaita,Agent 执行层统一走 agentproc;本仓是这层决议的节点承载。
25
+
26
+ ## 节点一览
27
+
28
+ | type | 节点 | 说明 |
29
+ |------|------|------|
30
+ | `agentrun` | Agent 运行 | **Agent 原子**:多步工具循环(模型可调工具自主多轮)。经 [agentproc](../agentproc) 调用 Agent CLI(recursive / claude);配置复用 flowcast 的 `agents.json` / `providers.json` |
31
+ | `llm` | LLM 补全 | **LLM 原子**:单次 chat/completions 生成文本(OpenAI 兼容端点)。与 agentrun 的边界见下 |
32
+ | `capture` | 命令执行 | 跑本地命令捕获输出;失败不抛错(`exit_code` 返回,流程自行分支) |
33
+ | `hitl` | 人工确认 | 直连 hitl-server(iLink 微信通道):发消息 → 轮询回复 |
34
+ | `notify` | 通知 | terminal 后端(stdout) |
35
+ | `writefile` | 写文件 | UTF-8 写文件,支持 JSON 序列化 |
36
+
37
+ 节点经 pyproject 的 `[project.entry-points."plaita.nodes"]` 自动注册;`plaita_nodes.register_all()` 可手动注册。
38
+
39
+ ## 原子节点设计原则
40
+
41
+ 抽象一条硬标准——**原子性**(一个节点只做一件不可再分的事)、**通用性**
42
+ (不绑定业务语义与特定凭证)、**普适性**(覆盖一类外部交互):
43
+
44
+ - **Agent ≠ LLM**:`agentrun` 是多步工具循环的 Agent 原子(重);`llm` 是单次
45
+ 补全的 LLM 原子(轻)。流程里"摘要/改写/抽取/分类"用 `llm`,"多步编码/
46
+ 工具任务"用 `agentrun`。
47
+ - 纯文本变换(判决提取、frontmatter 解析等)**不做节点**——注册为表达式
48
+ `F.*` 函数(`ExpressionRegistry.register`),在 assignment 里一行使用。
49
+ - 业务领域的状态机(如内容池销账)属于业务仓,不放本仓。
50
+
51
+ ## 快速上手
52
+
53
+ ```bash
54
+ # monorepo 内可编辑安装(plaita / agentproc 均为兄弟仓)
55
+ pip install -e ../plaita[http] -e ../agentproc/sdk/python -e .
56
+ ```
57
+
58
+ ```python
59
+ from plaita import Flow
60
+
61
+ flow = Flow.from_string(open("flow.json").read())
62
+ result = flow.run(track="default", platforms=["twitter"], topic="", repo="/path/to/repo")
63
+ ```
64
+
65
+ JSON 用法示例(agentrun + 模板表达式):
66
+
67
+ ```json
68
+ {
69
+ "type": "agentrun", "id": "brief",
70
+ "agent": "glm-52",
71
+ "prompt": "{% $F.concat($INPUT.item.brief_prompt) %}",
72
+ "next": "write"
73
+ }
74
+ ```
75
+
76
+ ## agents.json / providers.json 兼容性
77
+
78
+ 配置搜索顺序与 flowcast 一致:`~/.flowx → ~/.flowcast → <repo>/.flowcast`(深合并)。
79
+
80
+ 与 flowcast 的两处行为差异(有意为之):
81
+
82
+ 1. agents.json 里的 `env` 字段 flowcast 白名单会**静默丢弃**,本仓按配置透传
83
+ (如 glm-52 的 `RECURSIVE_MAX_TOKENS`)。
84
+ 2. recursive 直路径 flowcast 默认无超时,本仓 `timeout_secs` 默认 1800。
85
+
86
+ ## 设计边界
87
+
88
+ - **安全**:任何日志不打 apiKey / ANTHROPIC_AUTH_TOKEN。
89
+ - **dry-run**:所有有副作用的节点尊重 `globalContext.dry_run`——agentrun/capture/hitl 返回 fake 结果,writefile 照常写(草稿便于检查)。
90
+ - **断点续跑**:hitl 为阻塞版(Normal 模式);崩溃级恢复走 plaita Distributed + EventNode 模式(见 ADR phase 2)。
91
+ - 新增执行器:在 `agentproc` executor 层扩展 + `config.EXECUTOR_ALIASES` 加映射,本仓节点无需改动。
92
+
93
+ ## 开发
94
+
95
+ ```bash
96
+ pip install -e ".[dev]"
97
+ pytest
98
+ ```
@@ -0,0 +1,80 @@
1
+ # plaita-nodes
2
+
3
+ plaita 的**通用节点集**(infra 级):把 plaita 声明式流程接到真实世界——Agent CLI、本地命令、微信人工确认、通知、文件。
4
+
5
+ > 背景:[ADR-2026-08-27 编排双轨收敛](../docs/ADR-2026-08-27-orchestration-converge-on-plaita.md)
6
+ > ——编排内核收敛到 plaita,Agent 执行层统一走 agentproc;本仓是这层决议的节点承载。
7
+
8
+ ## 节点一览
9
+
10
+ | type | 节点 | 说明 |
11
+ |------|------|------|
12
+ | `agentrun` | Agent 运行 | **Agent 原子**:多步工具循环(模型可调工具自主多轮)。经 [agentproc](../agentproc) 调用 Agent CLI(recursive / claude);配置复用 flowcast 的 `agents.json` / `providers.json` |
13
+ | `llm` | LLM 补全 | **LLM 原子**:单次 chat/completions 生成文本(OpenAI 兼容端点)。与 agentrun 的边界见下 |
14
+ | `capture` | 命令执行 | 跑本地命令捕获输出;失败不抛错(`exit_code` 返回,流程自行分支) |
15
+ | `hitl` | 人工确认 | 直连 hitl-server(iLink 微信通道):发消息 → 轮询回复 |
16
+ | `notify` | 通知 | terminal 后端(stdout) |
17
+ | `writefile` | 写文件 | UTF-8 写文件,支持 JSON 序列化 |
18
+
19
+ 节点经 pyproject 的 `[project.entry-points."plaita.nodes"]` 自动注册;`plaita_nodes.register_all()` 可手动注册。
20
+
21
+ ## 原子节点设计原则
22
+
23
+ 抽象一条硬标准——**原子性**(一个节点只做一件不可再分的事)、**通用性**
24
+ (不绑定业务语义与特定凭证)、**普适性**(覆盖一类外部交互):
25
+
26
+ - **Agent ≠ LLM**:`agentrun` 是多步工具循环的 Agent 原子(重);`llm` 是单次
27
+ 补全的 LLM 原子(轻)。流程里"摘要/改写/抽取/分类"用 `llm`,"多步编码/
28
+ 工具任务"用 `agentrun`。
29
+ - 纯文本变换(判决提取、frontmatter 解析等)**不做节点**——注册为表达式
30
+ `F.*` 函数(`ExpressionRegistry.register`),在 assignment 里一行使用。
31
+ - 业务领域的状态机(如内容池销账)属于业务仓,不放本仓。
32
+
33
+ ## 快速上手
34
+
35
+ ```bash
36
+ # monorepo 内可编辑安装(plaita / agentproc 均为兄弟仓)
37
+ pip install -e ../plaita[http] -e ../agentproc/sdk/python -e .
38
+ ```
39
+
40
+ ```python
41
+ from plaita import Flow
42
+
43
+ flow = Flow.from_string(open("flow.json").read())
44
+ result = flow.run(track="default", platforms=["twitter"], topic="", repo="/path/to/repo")
45
+ ```
46
+
47
+ JSON 用法示例(agentrun + 模板表达式):
48
+
49
+ ```json
50
+ {
51
+ "type": "agentrun", "id": "brief",
52
+ "agent": "glm-52",
53
+ "prompt": "{% $F.concat($INPUT.item.brief_prompt) %}",
54
+ "next": "write"
55
+ }
56
+ ```
57
+
58
+ ## agents.json / providers.json 兼容性
59
+
60
+ 配置搜索顺序与 flowcast 一致:`~/.flowx → ~/.flowcast → <repo>/.flowcast`(深合并)。
61
+
62
+ 与 flowcast 的两处行为差异(有意为之):
63
+
64
+ 1. agents.json 里的 `env` 字段 flowcast 白名单会**静默丢弃**,本仓按配置透传
65
+ (如 glm-52 的 `RECURSIVE_MAX_TOKENS`)。
66
+ 2. recursive 直路径 flowcast 默认无超时,本仓 `timeout_secs` 默认 1800。
67
+
68
+ ## 设计边界
69
+
70
+ - **安全**:任何日志不打 apiKey / ANTHROPIC_AUTH_TOKEN。
71
+ - **dry-run**:所有有副作用的节点尊重 `globalContext.dry_run`——agentrun/capture/hitl 返回 fake 结果,writefile 照常写(草稿便于检查)。
72
+ - **断点续跑**:hitl 为阻塞版(Normal 模式);崩溃级恢复走 plaita Distributed + EventNode 模式(见 ADR phase 2)。
73
+ - 新增执行器:在 `agentproc` executor 层扩展 + `config.EXECUTOR_ALIASES` 加映射,本仓节点无需改动。
74
+
75
+ ## 开发
76
+
77
+ ```bash
78
+ pip install -e ".[dev]"
79
+ pytest
80
+ ```
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "plaita-nodes"
7
+ version = "0.3.0"
8
+ description = "plaita 通用节点集:AgentRun(经 agentproc)/ Capture / Hitl / Notify / WriteFile"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ dependencies = [
13
+ "plaita>=0.5.0",
14
+ "agentproc>=0.14.0",
15
+ "requests>=2.31",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ dev = ["pytest>=8", "pyyaml"]
20
+ sql = ["sqlalchemy>=2.0", "psycopg2-binary>=2.9", "pymysql>=1.1"]
21
+
22
+ [project.entry-points."plaita.nodes"]
23
+ agentrun = "plaita_nodes.agent_run:AgentRunNode"
24
+ capture = "plaita_nodes.capture:CaptureNode"
25
+ hitl = "plaita_nodes.hitl:HitlNode"
26
+ llm = "plaita_nodes.llm:LlmNode"
27
+ gate = "plaita_nodes.gate:GateNode"
28
+ rate_limit = "plaita_nodes.rate_limit:RateLimitNode"
29
+ report = "plaita_nodes.report:ReportNode"
30
+ hitl_await = "plaita_nodes.hitl_await:HitlAwaitNode"
31
+ notify = "plaita_nodes.notify:NotifyNode"
32
+ writefile = "plaita_nodes.write_file:WriteFileNode"
33
+ feishu_webhook = "plaita_nodes.webhooks:FeishuWebhookNode"
34
+ wecom_webhook = "plaita_nodes.webhooks:WecomWebhookNode"
35
+ slack_webhook = "plaita_nodes.webhooks:SlackWebhookNode"
36
+ dingtalk_webhook = "plaita_nodes.webhooks:DingtalkWebhookNode"
37
+ generic_webhook = "plaita_nodes.api:GenericWebhookNode"
38
+ api_request = "plaita_nodes.api:ApiRequestNode"
39
+ sql_query = "plaita_nodes.database:SqlQueryNode"
40
+ email_send = "plaita_nodes.email:EmailSendNode"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["src/plaita_nodes"]
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
@@ -0,0 +1,53 @@
1
+ """plaita-nodes —— plaita 通用节点集(infra 级,ADR-2026-08-27)。
2
+
3
+ 节点通过 pyproject 的 ``[project.entry-points."plaita.nodes"]`` 自动注册;
4
+ 若以源码方式引入(未经 pip 安装),可显式调用 :func:`register_all`。
5
+ """
6
+ from .agent_run import AgentRunError, AgentRunNode, extract_recursive_result, register_recursive_direct
7
+ from .api import ApiRequestNode, GenericWebhookNode
8
+ from .database import SqlQueryNode
9
+ from .email import EmailSendNode
10
+ from .capture import CaptureConfigError, CaptureNode
11
+ from .config import (
12
+ EXECUTOR_ALIASES,
13
+ AgentConfigError,
14
+ interpolate,
15
+ load_merged_config,
16
+ resolve_agent,
17
+ resolve_provider,
18
+ )
19
+ from .hitl import HitlError, HitlNode
20
+ from .gate import GateNode
21
+ from .hitl_await import HitlAwaitNode
22
+ from .llm import LlmError, LlmNode
23
+ from .rate_limit import RateLimitNode
24
+ from .report import ReportNode, append_entry, read_entries
25
+ from .notify import NotifyNode
26
+ from .webhooks import DingtalkWebhookNode, FeishuWebhookNode, SlackWebhookNode, WecomWebhookNode
27
+ from .write_file import WriteFileNode
28
+
29
+ __version__ = "0.3.0"
30
+
31
+ _ALL_NODES = [AgentRunNode, ApiRequestNode, CaptureNode, DingtalkWebhookNode, EmailSendNode, FeishuWebhookNode, GateNode, GenericWebhookNode, HitlAwaitNode, HitlNode, LlmNode, NotifyNode, RateLimitNode, ReportNode, SlackWebhookNode, SqlQueryNode, WecomWebhookNode, WriteFileNode]
32
+
33
+
34
+ def register_all() -> None:
35
+ """显式注册全部节点到默认 registry(entry_points 之外的手动路径)。"""
36
+ from plaita.node import get_default_registry
37
+
38
+ registry = get_default_registry()
39
+ for node_cls in _ALL_NODES:
40
+ registry.register(node_cls)
41
+
42
+
43
+ __all__ = [
44
+ "AgentRunNode", "AgentRunError", "extract_recursive_result", "register_recursive_direct",
45
+ "CaptureNode", "CaptureConfigError",
46
+ "HitlNode", "HitlAwaitNode", "HitlError", "LlmNode", "LlmError", "ReportNode", "report_append", "report_read",
47
+ "NotifyNode",
48
+ "WriteFileNode",
49
+ "register_all",
50
+ "load_merged_config", "resolve_agent", "resolve_provider", "interpolate",
51
+ "AgentConfigError", "EXECUTOR_ALIASES",
52
+ "__version__",
53
+ ]
@@ -0,0 +1,232 @@
1
+ """AgentRunNode —— 经 agentproc 调用 Agent CLI(recursive / claude / …)。
2
+
3
+ 设计(ADR-2026-08-27):
4
+ - 配置层复用 flowcast 的 agents.json / providers.json(见 config.py);
5
+ - 执行层走 agentproc Python SDK 的 in-process executor 机制;
6
+ - ``recursive-direct`` 是本包注册的直调执行器,语义照搬 flowcast 的
7
+ ``runRecursiveDirect``:``recursive --workspace . --output-format json
8
+ [--model M] [--max-steps N] run "<prompt>"``,stdout 末尾为单个 JSON
9
+ 对象,取 ``.result`` 为回复、``.is_error`` 为失败。
10
+ - 输出:``{"text", "cli", "session_id", "usage", "dry_run"}``,
11
+ 下游用 ``$NODE.<id>.text`` 引用。
12
+
13
+ 日志脱敏:任何路径都不打印 apiKey / ANTHROPIC_AUTH_TOKEN。
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import subprocess
20
+ from typing import Any, ClassVar, Optional
21
+
22
+ from pydantic import Field
23
+
24
+ from plaita import Node
25
+
26
+ from .config import EXECUTOR_ALIASES, resolve_agent
27
+
28
+
29
+ class AgentRunError(RuntimeError):
30
+ pass
31
+
32
+
33
+ # ── recursive 直调执行器(语义 = flowcast runRecursiveDirect)──────────
34
+
35
+ def _make_recursive_handlers():
36
+ def build_args(message: str, session_id: str, env: dict) -> list:
37
+ bin_name = env.get("RECURSIVE_BIN", "recursive")
38
+ args = [bin_name, "--workspace", env.get("RECURSIVE_WORKSPACE", "."),
39
+ "--output-format", "json"]
40
+ if env.get("RECURSIVE_MODEL"):
41
+ args += ["--model", env["RECURSIVE_MODEL"]]
42
+ if env.get("RECURSIVE_MAX_STEPS"):
43
+ args += ["--max-steps", str(env["RECURSIVE_MAX_STEPS"])]
44
+ args += ["run", message]
45
+ return args
46
+
47
+ return {"build_args": build_args}
48
+
49
+
50
+ def register_recursive_direct() -> None:
51
+ from agentproc import EXECUTORS
52
+
53
+ if "recursive-direct" not in EXECUTORS:
54
+ EXECUTORS["recursive-direct"] = {
55
+ "cli_name": "recursive",
56
+ "install_hint": "brew install kongjie/tap/recursive(或 cargo build --release)",
57
+ # plain:stdout 整体作为 reply(recursive --output-format json 输出单个
58
+ # JSON 对象而非 NDJSON),由本节点再做 .result 提取。
59
+ "plain": True,
60
+ "make_handlers": _make_recursive_handlers,
61
+ }
62
+
63
+
64
+ register_recursive_direct()
65
+
66
+
67
+ def extract_recursive_result(stdout: str) -> dict:
68
+ """从 recursive 的 stdout 提取末尾 JSON 对象(``--output-format json`` 为
69
+ “单个结果对象收尾”)。
70
+
71
+ 策略:优先取**行首 ``{`` 起始**的最后一个可解析对象(日志噪声多出现在行中);
72
+ 退回首个 ``{"`` 的括号配对扫描(跳过字符串内花括号,与 flowcast
73
+ extractRecursiveJson 同款);全部失败则把原文当 result 返回。
74
+ """
75
+ line_starts: list[int] = []
76
+ lines = stdout.split("\n")
77
+ offset = 0
78
+ for line in lines:
79
+ if line.lstrip().startswith("{"):
80
+ line_starts.append(offset)
81
+ offset += len(line) + 1
82
+ find_idx = stdout.find('{"')
83
+
84
+ # 扫描顺序:行首对象从后往前优先(结果对象在末尾),find('{"') 兜底
85
+ scan_order = list(reversed(line_starts))
86
+ if find_idx >= 0:
87
+ scan_order.append(find_idx)
88
+
89
+ for start in scan_order:
90
+ depth = 0
91
+ in_string = False
92
+ escaped = False
93
+ for i in range(start, len(stdout)):
94
+ ch = stdout[i]
95
+ if in_string:
96
+ if escaped:
97
+ escaped = False
98
+ elif ch == "\\":
99
+ escaped = True
100
+ elif ch == '"':
101
+ in_string = False
102
+ continue
103
+ if ch == '"':
104
+ in_string = True
105
+ elif ch == "{":
106
+ depth += 1
107
+ elif ch == "}":
108
+ depth -= 1
109
+ if depth == 0:
110
+ try:
111
+ return json.loads(stdout[start:i + 1])
112
+ except json.JSONDecodeError:
113
+ break
114
+ return {"result": stdout, "is_error": False, "raw": True}
115
+
116
+
117
+ class AgentRunNode(Node):
118
+ """运行一个 Agent CLI 并返回其文本回复。
119
+
120
+ JSON 字段:
121
+ - ``agent``: agents.json 里的名字(默认 ``glm-52``),支持表达式
122
+ - ``prompt``: prompt 文本,支持 ``{% ... %}`` 模板表达式
123
+ - ``repo``: 工作目录(recursive 的 ``--workspace``;默认进程 cwd)
124
+ - ``timeout_secs``: 超时秒数(默认 1800;flowcast 的 recursive 直路径无超时,
125
+ 这里是行为改进)
126
+ - ``dry_run``: 为 true(或流程 globalContext.dry_run=true)时不真正调用,
127
+ 返回 fake 文本
128
+
129
+ 输出:``{"text", "cli", "session_id", "usage", "dry_run"}``。
130
+ """
131
+
132
+ node_type: ClassVar[str] = "agentrun"
133
+ node_name: ClassVar[str] = "Agent 运行"
134
+
135
+ agent: Optional[Any] = "glm-52"
136
+ prompt: Optional[Any] = None
137
+ repo: Optional[Any] = None
138
+ timeout_secs: int = Field(default=1800)
139
+ dry_run: bool = False
140
+
141
+ def execute(self, execution: Any) -> dict:
142
+ agent_name = str(execution.evaluate(self.agent)) if self.agent is not None else "glm-52"
143
+ prompt = execution.evaluate(self.prompt) if self.prompt is not None else ""
144
+ repo = execution.evaluate(self.repo) if self.repo else None
145
+ dry = self.dry_run or bool(execution.get_global_variable("dry_run", False))
146
+
147
+ if dry:
148
+ preview = prompt[:80] + ("…" if len(prompt) > 80 else "")
149
+ return {"text": f"[dry-run] {agent_name} would run: {preview}",
150
+ "cli": agent_name, "session_id": "", "usage": None, "dry_run": True}
151
+
152
+ profile = resolve_agent(agent_name, repo=repo)
153
+ executor = profile["executor"]
154
+
155
+ from agentproc import EXECUTORS as AP_EXECUTORS
156
+
157
+ # 显式别名映射优先;否则若 agentproc 已内置同名 executor(或测试桩)直接使用
158
+ ap_executor = EXECUTOR_ALIASES.get(executor, executor)
159
+ if ap_executor not in AP_EXECUTORS:
160
+ raise AgentRunError(
161
+ f"执行器 '{executor}' 未接入(agentproc 可用:{sorted(AP_EXECUTORS)});"
162
+ f"如需其他 CLI 请在 agentproc executor 层扩展或加 EXECUTOR_ALIASES 映射"
163
+ )
164
+
165
+ from agentproc.runner import RunOptions
166
+ from agentproc.runner import run as agentproc_run
167
+
168
+ extra_env = dict(profile["env"])
169
+ extra_env.setdefault("RECURSIVE_WORKSPACE", repo or ".")
170
+ result = agentproc_run(
171
+ {"executor": ap_executor},
172
+ RunOptions(message=str(prompt), extra_env=extra_env,
173
+ timeout_secs=self.timeout_secs),
174
+ )
175
+ if result.error or result.exit_code != 0:
176
+ raise AgentRunError(result.error or f"{agent_name} 退出码 {result.exit_code}")
177
+
178
+ text = result.reply
179
+ if executor == "recursive":
180
+ parsed = extract_recursive_result(result.reply)
181
+ if parsed.get("is_error"):
182
+ raise AgentRunError(f"recursive is_error: {str(parsed.get('result'))[:300]}")
183
+ text = str(parsed.get("result") or "")
184
+
185
+ return {"text": text, "cli": executor, "session_id": result.session_id,
186
+ "usage": result.usage, "dry_run": False}
187
+
188
+
189
+ def recursive_stream_turn(task: str, *, workspace: str, profile: str = "glm-52",
190
+ model: Optional[str] = None,
191
+ max_steps: Optional[int] = None,
192
+ timeout_secs: int = 1800):
193
+ """以流式方式跑一轮 recursive Agent(生成器:yield 事件 dict)。
194
+
195
+ 事件序列:
196
+ {"type": "line", "text": str} # agent 原始输出行(宿主可实时展示)
197
+ {"type": "done", "ok": bool, "result": str, "error": str}
198
+ 供宿主进程(如 plaita-console 的 AI 流程生成)把编码 Agent 作为生成后端复用。
199
+ """
200
+ import time as _time
201
+
202
+ register_recursive_direct()
203
+ agent = resolve_agent(profile)
204
+ env_extra = dict(agent["env"])
205
+ env_extra.setdefault("RECURSIVE_WORKSPACE", workspace)
206
+ if model:
207
+ env_extra["RECURSIVE_MODEL"] = model
208
+ handlers = _make_recursive_handlers()
209
+ if agent.get("model"):
210
+ env_extra.setdefault("RECURSIVE_MODEL", agent["model"])
211
+ argv = handlers["build_args"](task, "", env_extra)
212
+ # 子进程必须拿到 provider 凭证 env(否则无凭证运行得到空回复)
213
+ proc_env = {**os.environ, **env_extra}
214
+ proc = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
215
+ text=True, cwd=workspace, env=proc_env)
216
+ lines: list[str] = []
217
+ deadline = _time.monotonic() + timeout_secs
218
+ for line in proc.stdout: # type: ignore[union-attr]
219
+ yield {"type": "line", "text": line.rstrip("\n")}
220
+ lines.append(line)
221
+ proc.wait()
222
+ timed_out = _time.monotonic() > deadline
223
+ stdout = "".join(lines)
224
+ if timed_out:
225
+ yield {"type": "done", "ok": False, "result": "", "error": f"agent 超时(>{timeout_secs}s)"}
226
+ return
227
+ parsed = extract_recursive_result(stdout)
228
+ if parsed.get("is_error"):
229
+ yield {"type": "done", "ok": False, "result": "",
230
+ "error": f"recursive is_error: {str(parsed.get('result'))[:300]}"}
231
+ return
232
+ yield {"type": "done", "ok": True, "result": parsed.get("result") or "", "error": ""}