pagent 0.3.2__tar.gz → 0.4.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.
- {pagent-0.3.2 → pagent-0.4.0}/PKG-INFO +15 -2
- {pagent-0.3.2 → pagent-0.4.0}/README.md +9 -0
- pagent-0.4.0/pyproject.toml +101 -0
- pagent-0.4.0/src/app/Dockerfile +45 -0
- pagent-0.4.0/src/app/Dockerfile.browser +51 -0
- pagent-0.4.0/src/app/__init__.py +15 -0
- pagent-0.4.0/src/app/__main__.py +3 -0
- pagent-0.4.0/src/app/clean.py +126 -0
- pagent-0.4.0/src/app/concurrent_repl.py +250 -0
- pagent-0.4.0/src/app/config.py +280 -0
- pagent-0.4.0/src/app/layout_terminal.py +164 -0
- pagent-0.4.0/src/app/pagent.toml +41 -0
- pagent-0.4.0/src/app/render.py +485 -0
- pagent-0.4.0/src/app/repl.py +261 -0
- pagent-0.4.0/src/app/terminal.py +154 -0
- pagent-0.4.0/src/app/tool_permit.py +119 -0
- pagent-0.4.0/src/pagentv4/__init__.py +176 -0
- pagent-0.4.0/src/pagentv4/adapters/__init__.py +11 -0
- pagent-0.4.0/src/pagentv4/adapters/acp.py +65 -0
- pagent-0.4.0/src/pagentv4/core/__init__.py +75 -0
- pagent-0.4.0/src/pagentv4/core/agent.py +91 -0
- pagent-0.4.0/src/pagentv4/core/agent_restored.py +91 -0
- pagent-0.4.0/src/pagentv4/core/events.py +64 -0
- pagent-0.4.0/src/pagentv4/core/hacked_agent.py +17 -0
- pagent-0.4.0/src/pagentv4/core/message.py +413 -0
- pagent-0.4.0/src/pagentv4/core/provider.py +101 -0
- pagent-0.4.0/src/pagentv4/core/tool.py +184 -0
- pagent-0.4.0/src/pagentv4/core/turn_result.py +39 -0
- pagent-0.4.0/src/pagentv4/runners/__init__.py +14 -0
- pagent-0.4.0/src/pagentv4/runners/agentic.py +51 -0
- pagent-0.4.0/src/pagentv4/runners/code.py +84 -0
- pagent-0.4.0/src/pagentv4/runners/config.py +66 -0
- pagent-0.4.0/src/pagentv4/runners/loop.py +80 -0
- pagent-0.4.0/src/pagentv4/runners/simple.py +25 -0
- pagent-0.4.0/src/pagentv4/runtime/__init__.py +61 -0
- pagent-0.4.0/src/pagentv4/runtime/conversation.py +162 -0
- pagent-0.4.0/src/pagentv4/runtime/hooks.py +84 -0
- pagent-0.4.0/src/pagentv4/runtime/inbound.py +252 -0
- pagent-0.4.0/src/pagentv4/runtime/runner.py +478 -0
- pagent-0.4.0/src/pagentv4/runtime/thread.py +185 -0
- pagent-0.4.0/src/pagentv4/sandbox/__init__.py +54 -0
- pagent-0.4.0/src/pagentv4/sandbox/backends/__init__.py +3 -0
- pagent-0.4.0/src/pagentv4/sandbox/backends/container.py +198 -0
- pagent-0.4.0/src/pagentv4/sandbox/backends/docker.py +16 -0
- pagent-0.4.0/src/pagentv4/sandbox/backends/local.py +154 -0
- pagent-0.4.0/src/pagentv4/sandbox/backends/podman.py +14 -0
- pagent-0.4.0/src/pagentv4/sandbox/backends/ssh.py +380 -0
- pagent-0.4.0/src/pagentv4/sandbox/base.py +150 -0
- pagent-0.4.0/src/pagentv4/sandbox/description.py +107 -0
- pagent-0.4.0/src/pagentv4/sandbox/guard.py +112 -0
- pagent-0.4.0/src/pagentv4/sandbox/policy.py +159 -0
- pagent-0.4.0/src/pagentv4/sandbox/sandbox.py +529 -0
- pagent-0.4.0/src/pagentv4/sandbox/text.py +70 -0
- pagent-0.4.0/src/pagentv4/sandbox/tools.py +320 -0
- pagent-0.4.0/src/pagentv4/sandbox/workspace.py +44 -0
- pagent-0.4.0/src/pagentv4/skills/__init__.py +35 -0
- pagent-0.4.0/src/pagentv4/skills/skill.py +293 -0
- pagent-0.4.0/src/pagentv4/tools/__init__.py +7 -0
- pagent-0.4.0/src/pagentv4/tools/web.py +167 -0
- pagent-0.3.2/pyproject.toml +0 -54
- {pagent-0.3.2 → pagent-0.4.0}/src/pagent/__init__.py +0 -0
- {pagent-0.3.2 → pagent-0.4.0}/src/pagent/agent.py +0 -0
- {pagent-0.3.2 → pagent-0.4.0}/src/pagent/defaults.py +0 -0
- {pagent-0.3.2 → pagent-0.4.0}/src/pagent/events.py +0 -0
- {pagent-0.3.2 → pagent-0.4.0}/src/pagent/feature.py +0 -0
- {pagent-0.3.2 → pagent-0.4.0}/src/pagent/llm.py +0 -0
- {pagent-0.3.2 → pagent-0.4.0}/src/pagent/memory.py +0 -0
- {pagent-0.3.2 → pagent-0.4.0}/src/pagent/py.typed +0 -0
- {pagent-0.3.2 → pagent-0.4.0}/src/pagent/session.py +0 -0
- {pagent-0.3.2 → pagent-0.4.0}/src/pagent/tokens.py +0 -0
- {pagent-0.3.2 → pagent-0.4.0}/src/pagent/tool.py +0 -0
- {pagent-0.3.2 → pagent-0.4.0}/src/pagent/wire.py +0 -0
|
@@ -1,21 +1,25 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: pagent
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Async Python agent library and terminal REPL (OpenAI-compatible Chat Completions + sandbox).
|
|
5
5
|
Author: gongyulei
|
|
6
6
|
Author-email: gongyulei <gongyulei@stu.xmu.edu.cn>
|
|
7
|
+
Requires-Dist: asyncssh>=2.24.0
|
|
7
8
|
Requires-Dist: docstring-parser>=0.18.0
|
|
8
9
|
Requires-Dist: openai>=2.31.0
|
|
10
|
+
Requires-Dist: prompt-toolkit>=3.0.52
|
|
9
11
|
Requires-Dist: tiktoken>=0.7.0
|
|
10
12
|
Requires-Dist: agent-client-protocol>=0.10 ; extra == 'acp'
|
|
11
13
|
Requires-Dist: ddgs>=7 ; extra == 'acp'
|
|
12
14
|
Requires-Dist: ddgs>=7 ; extra == 'search'
|
|
15
|
+
Requires-Dist: asyncssh>=2.14 ; extra == 'ssh'
|
|
13
16
|
Requires-Dist: transformers>=4.40 ; extra == 'tokens'
|
|
14
17
|
Requires-Python: >=3.11
|
|
15
18
|
Project-URL: Documentation, https://synclionpaw.github.io/pagent/
|
|
16
19
|
Project-URL: Homepage, https://github.com/SyncLionPaw/pagent
|
|
17
20
|
Provides-Extra: acp
|
|
18
21
|
Provides-Extra: search
|
|
22
|
+
Provides-Extra: ssh
|
|
19
23
|
Provides-Extra: tokens
|
|
20
24
|
Description-Content-Type: text/markdown
|
|
21
25
|
|
|
@@ -26,6 +30,7 @@ Description-Content-Type: text/markdown
|
|
|
26
30
|
# pagent (English)
|
|
27
31
|
|
|
28
32
|
[](https://github.com/SyncLionPaw/pagent/actions/workflows/ruff.yml)
|
|
33
|
+
[](https://app.codecov.io/gh/SyncLionPaw/pagent)
|
|
29
34
|
|
|
30
35
|
Language: [中文](./README.zh-CN.md) | [English](./README.en.md) · [Docs](https://synclionpaw.github.io/pagent/) · [For agents](./AGENTS.md) · [llms.txt](./llms.txt)
|
|
31
36
|
|
|
@@ -59,6 +64,14 @@ uv add pagent
|
|
|
59
64
|
uv add "pagent[search]"
|
|
60
65
|
```
|
|
61
66
|
|
|
67
|
+
### uvx (terminal REPL)
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
export DEEPSEEK_API_KEY="your-key"
|
|
71
|
+
uvx pagent
|
|
72
|
+
uvx pagent --thread-id demo
|
|
73
|
+
```
|
|
74
|
+
|
|
62
75
|
### conda
|
|
63
76
|
|
|
64
77
|
```bash
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
# pagent (English)
|
|
6
6
|
|
|
7
7
|
[](https://github.com/SyncLionPaw/pagent/actions/workflows/ruff.yml)
|
|
8
|
+
[](https://app.codecov.io/gh/SyncLionPaw/pagent)
|
|
8
9
|
|
|
9
10
|
Language: [中文](./README.zh-CN.md) | [English](./README.en.md) · [Docs](https://synclionpaw.github.io/pagent/) · [For agents](./AGENTS.md) · [llms.txt](./llms.txt)
|
|
10
11
|
|
|
@@ -38,6 +39,14 @@ uv add pagent
|
|
|
38
39
|
uv add "pagent[search]"
|
|
39
40
|
```
|
|
40
41
|
|
|
42
|
+
### uvx (terminal REPL)
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
export DEEPSEEK_API_KEY="your-key"
|
|
46
|
+
uvx pagent
|
|
47
|
+
uvx pagent --thread-id demo
|
|
48
|
+
```
|
|
49
|
+
|
|
41
50
|
### conda
|
|
42
51
|
|
|
43
52
|
```bash
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pagent"
|
|
3
|
+
version = "0.4.0"
|
|
4
|
+
description = "Async Python agent library and terminal REPL (OpenAI-compatible Chat Completions + sandbox)."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "gongyulei", email = "gongyulei@stu.xmu.edu.cn" },
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"asyncssh>=2.24.0",
|
|
12
|
+
"docstring-parser>=0.18.0",
|
|
13
|
+
"openai>=2.31.0",
|
|
14
|
+
"prompt-toolkit>=3.0.52",
|
|
15
|
+
"tiktoken>=0.7.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.urls]
|
|
19
|
+
Homepage = "https://github.com/SyncLionPaw/pagent"
|
|
20
|
+
Documentation = "https://synclionpaw.github.io/pagent/"
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
search = ["ddgs>=7"]
|
|
24
|
+
tokens = ["transformers>=4.40"]
|
|
25
|
+
acp = ["agent-client-protocol>=0.10", "ddgs>=7"]
|
|
26
|
+
ssh = ["asyncssh>=2.14"]
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
pagent = "app.repl:main"
|
|
30
|
+
|
|
31
|
+
[build-system]
|
|
32
|
+
requires = ["uv_build>=0.8.20,<0.9.0"]
|
|
33
|
+
build-backend = "uv_build"
|
|
34
|
+
|
|
35
|
+
[tool.uv.build-backend]
|
|
36
|
+
module-name = ["pagent", "pagentv4", "app"]
|
|
37
|
+
|
|
38
|
+
[dependency-groups]
|
|
39
|
+
dev = [
|
|
40
|
+
"agent-client-protocol>=0.10",
|
|
41
|
+
"asyncssh>=2.14",
|
|
42
|
+
"coverage[toml]>=7.15.0",
|
|
43
|
+
"ddgs>=7",
|
|
44
|
+
"pre-commit>=4",
|
|
45
|
+
"pytest>=8",
|
|
46
|
+
"pytest-asyncio>=0.24",
|
|
47
|
+
"pytest-cov>=7.1.0",
|
|
48
|
+
"ruff>=0.9",
|
|
49
|
+
]
|
|
50
|
+
[tool.pytest.ini_options]
|
|
51
|
+
pythonpath = ["src"]
|
|
52
|
+
|
|
53
|
+
[tool.coverage.run]
|
|
54
|
+
# 只统计业务代码,排除测试、示例、文档
|
|
55
|
+
source = ["src"]
|
|
56
|
+
branch = true # 分支覆盖(if/else 两条腿都算),比行覆盖更严格
|
|
57
|
+
omit = [
|
|
58
|
+
"*/tests/*",
|
|
59
|
+
"*/examples/*",
|
|
60
|
+
"*/docs/*",
|
|
61
|
+
"*/__main__.py",
|
|
62
|
+
# 故意的非法语法 demo,无法被 import
|
|
63
|
+
"src/pagentv4/core/hacked_agent.py",
|
|
64
|
+
# v3 准备淘汰,整体不计入覆盖率(保留测试直到彻底移除)
|
|
65
|
+
"src/pagentv3/*",
|
|
66
|
+
# 故意不测的旧 Agent 实现(已被 core/agent.py 取代)
|
|
67
|
+
"src/pagentv4/core/agent_restored.py",
|
|
68
|
+
# 需要真实环境(docker / ssh / ACP server)才能测,纯单测覆盖不到
|
|
69
|
+
"src/pagentv4/sandbox/backends/container.py",
|
|
70
|
+
"src/pagentv4/sandbox/backends/ssh.py",
|
|
71
|
+
"src/pagentv4/adapters/acp.py",
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
[tool.coverage.report]
|
|
75
|
+
# 终端报告里按行列出未覆盖的代码行
|
|
76
|
+
show_missing = true
|
|
77
|
+
skip_covered = false
|
|
78
|
+
# 排除琐碎的、不算真正逻辑的行
|
|
79
|
+
exclude_lines = [
|
|
80
|
+
"pragma: no cover",
|
|
81
|
+
"raise NotImplementedError",
|
|
82
|
+
"if __name__ == .__main__.:",
|
|
83
|
+
"if TYPE_CHECKING:",
|
|
84
|
+
"@(abc\\.)?abstractmethod",
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
[tool.ruff]
|
|
88
|
+
target-version = "py311"
|
|
89
|
+
src = ["src"]
|
|
90
|
+
extend-exclude = ["src/pagentv4/core/hacked_agent.py"] # intentional invalid syntax demo
|
|
91
|
+
|
|
92
|
+
[tool.ruff.lint]
|
|
93
|
+
select = ["E4", "E7", "E9", "F", "I"]
|
|
94
|
+
|
|
95
|
+
[tool.ruff.lint.per-file-ignores]
|
|
96
|
+
"examples/demo2/*.py" = ["E402"] # sys.path adjusted before local / sibling imports
|
|
97
|
+
"examples/acp_agent/main.py" = ["E402"]
|
|
98
|
+
"tests/test_acp_tools.py" = ["E402"]
|
|
99
|
+
|
|
100
|
+
[tool.ruff.format]
|
|
101
|
+
quote-style = "double"
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# pagent app 默认 agent 镜像 —— Alpine + uv(Python) + Node。
|
|
2
|
+
#
|
|
3
|
+
# 不用 python:* 基础镜像,避免系统 Python 与 uv 重复;Python 由 uv 安装。
|
|
4
|
+
# Alpine 比 Debian slim 小很多;Node 走 apk(比 nvm 更瘦,无版本切换)。
|
|
5
|
+
# 预装:curl、jq、git、uv、node/npm。
|
|
6
|
+
#
|
|
7
|
+
# 注意:musl 环境,少数带 glibc 二进制的 wheel 可能不兼容。
|
|
8
|
+
#
|
|
9
|
+
# 用法:
|
|
10
|
+
# docker build -t pagent:latest -f src/app/Dockerfile src/app
|
|
11
|
+
# # 或
|
|
12
|
+
# cd src/app && docker build -t pagent:latest .
|
|
13
|
+
#
|
|
14
|
+
# bundled pagent.toml 默认 backend=docker, image=pagent:latest
|
|
15
|
+
|
|
16
|
+
FROM alpine:3.21
|
|
17
|
+
|
|
18
|
+
ARG PYTHON_VERSION=3.12
|
|
19
|
+
|
|
20
|
+
RUN apk add --no-cache \
|
|
21
|
+
bash \
|
|
22
|
+
ca-certificates \
|
|
23
|
+
curl \
|
|
24
|
+
git \
|
|
25
|
+
jq \
|
|
26
|
+
nodejs \
|
|
27
|
+
npm \
|
|
28
|
+
tar \
|
|
29
|
+
xz
|
|
30
|
+
|
|
31
|
+
RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
|
|
32
|
+
&& mv /root/.local/bin/uv /usr/local/bin/uv \
|
|
33
|
+
&& mv /root/.local/bin/uvx /usr/local/bin/uvx
|
|
34
|
+
|
|
35
|
+
ENV UV_PYTHON_PREFERENCE=only-managed
|
|
36
|
+
RUN uv python install "$PYTHON_VERSION" \
|
|
37
|
+
&& ln -sf "$(uv python find "$PYTHON_VERSION")" /usr/local/bin/python3 \
|
|
38
|
+
&& ln -sf python3 /usr/local/bin/python \
|
|
39
|
+
&& uv cache clean \
|
|
40
|
+
&& rm -rf /root/.cache
|
|
41
|
+
|
|
42
|
+
RUN mkdir -p /home/agent
|
|
43
|
+
WORKDIR /home/agent
|
|
44
|
+
|
|
45
|
+
CMD ["sleep", "infinity"]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# pagent browser 镜像 —— 在默认 agent 镜像基础上加 headless Chromium。
|
|
2
|
+
#
|
|
3
|
+
# 预装:curl、jq、git、uv、node/npm、Chromium、Noto CJK 字体。
|
|
4
|
+
# 渲染 HTML、导出 PDF 时用;日常开发继续用 Dockerfile。
|
|
5
|
+
#
|
|
6
|
+
# 用法:
|
|
7
|
+
# docker build -t pagent:browser -f src/app/Dockerfile.browser src/app
|
|
8
|
+
# # pagent.toml: image = "pagent:browser"
|
|
9
|
+
#
|
|
10
|
+
# 容器内 headless 示例:
|
|
11
|
+
# chromium-browser --headless --disable-gpu --no-sandbox --disable-dev-shm-usage \
|
|
12
|
+
# --screenshot=/home/agent/out.png https://example.com
|
|
13
|
+
|
|
14
|
+
FROM alpine:3.21
|
|
15
|
+
|
|
16
|
+
ARG PYTHON_VERSION=3.12
|
|
17
|
+
|
|
18
|
+
RUN apk add --no-cache \
|
|
19
|
+
bash \
|
|
20
|
+
ca-certificates \
|
|
21
|
+
chromium \
|
|
22
|
+
curl \
|
|
23
|
+
font-noto-cjk \
|
|
24
|
+
git \
|
|
25
|
+
jq \
|
|
26
|
+
nodejs \
|
|
27
|
+
npm \
|
|
28
|
+
tar \
|
|
29
|
+
xz
|
|
30
|
+
|
|
31
|
+
RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
|
|
32
|
+
&& mv /root/.local/bin/uv /usr/local/bin/uv \
|
|
33
|
+
&& mv /root/.local/bin/uvx /usr/local/bin/uvx
|
|
34
|
+
|
|
35
|
+
ENV UV_PYTHON_PREFERENCE=only-managed
|
|
36
|
+
RUN uv python install "$PYTHON_VERSION" \
|
|
37
|
+
&& ln -sf "$(uv python find "$PYTHON_VERSION")" /usr/local/bin/python3 \
|
|
38
|
+
&& ln -sf python3 /usr/local/bin/python \
|
|
39
|
+
&& uv cache clean \
|
|
40
|
+
&& rm -rf /root/.cache
|
|
41
|
+
|
|
42
|
+
# Alpine 的 chromium 包;Puppeteer/Playwright 可指向这个二进制。
|
|
43
|
+
ENV CHROME_BIN=/usr/bin/chromium-browser \
|
|
44
|
+
CHROMIUM_PATH=/usr/bin/chromium-browser \
|
|
45
|
+
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser \
|
|
46
|
+
CHROMIUM_FLAGS="--headless --disable-gpu --no-sandbox --disable-dev-shm-usage"
|
|
47
|
+
|
|
48
|
+
RUN mkdir -p /home/agent
|
|
49
|
+
WORKDIR /home/agent
|
|
50
|
+
|
|
51
|
+
CMD ["sleep", "infinity"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""pagent — terminal REPL on pagentv4."""
|
|
2
|
+
|
|
3
|
+
from .config import ReplConfig, build_parser, config_from_args, load_config
|
|
4
|
+
from .repl import format_banner, main, open_runner, run_repl
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"ReplConfig",
|
|
8
|
+
"build_parser",
|
|
9
|
+
"config_from_args",
|
|
10
|
+
"format_banner",
|
|
11
|
+
"load_config",
|
|
12
|
+
"main",
|
|
13
|
+
"open_runner",
|
|
14
|
+
"run_repl",
|
|
15
|
+
]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pagentv4.core.message import Messages
|
|
8
|
+
from pagentv4.runtime.conversation import (
|
|
9
|
+
JsonlConversationStore,
|
|
10
|
+
default_conversations_root,
|
|
11
|
+
)
|
|
12
|
+
from pagentv4.runtime.thread import (
|
|
13
|
+
MESSAGES_CONVERSATION_ID,
|
|
14
|
+
SPEC_FILENAME,
|
|
15
|
+
WORKSPACE_DIRNAME,
|
|
16
|
+
default_threads_root,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
MESSAGES_FILENAME = "messages.jsonl"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class CleanReport:
|
|
24
|
+
removed_threads: list[str] = field(default_factory=list)
|
|
25
|
+
removed_conversations: list[str] = field(default_factory=list)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def user_message_count(path: Path) -> int:
|
|
29
|
+
if not path.is_file():
|
|
30
|
+
return 0
|
|
31
|
+
messages = Messages.load_from_jsonl(path)
|
|
32
|
+
return sum(1 for message in messages.data if message.role == "user")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def workspace_is_empty(workspace: Path) -> bool:
|
|
36
|
+
if not workspace.is_dir():
|
|
37
|
+
return True
|
|
38
|
+
return not any(workspace.iterdir())
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def thread_is_useless(thread_dir: Path) -> bool:
|
|
42
|
+
if not (thread_dir / SPEC_FILENAME).is_file():
|
|
43
|
+
return False
|
|
44
|
+
if user_message_count(thread_dir / MESSAGES_FILENAME) > 0:
|
|
45
|
+
return False
|
|
46
|
+
return workspace_is_empty(thread_dir / WORKSPACE_DIRNAME)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def conversation_is_useless(path: Path) -> bool:
|
|
50
|
+
if not path.is_file():
|
|
51
|
+
return False
|
|
52
|
+
if path.stat().st_size == 0:
|
|
53
|
+
return True
|
|
54
|
+
return user_message_count(path) == 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def iter_thread_dirs(root: Path) -> list[Path]:
|
|
58
|
+
if not root.is_dir():
|
|
59
|
+
return []
|
|
60
|
+
return [
|
|
61
|
+
child
|
|
62
|
+
for child in root.iterdir()
|
|
63
|
+
if child.is_dir() and (child / SPEC_FILENAME).is_file()
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def clean_pagent(
|
|
68
|
+
*,
|
|
69
|
+
threads_root: Path | str | None = None,
|
|
70
|
+
conversations_root: Path | str | None = None,
|
|
71
|
+
keep_thread_ids: set[str] | frozenset[str] = frozenset(),
|
|
72
|
+
remove: bool = True,
|
|
73
|
+
) -> CleanReport:
|
|
74
|
+
"""Remove empty threads and orphan conversations under `.pagent/`.
|
|
75
|
+
|
|
76
|
+
A thread is useless when it has no user messages and an empty workspace.
|
|
77
|
+
A standalone conversation file is useless when empty or has no user messages.
|
|
78
|
+
"""
|
|
79
|
+
report = CleanReport()
|
|
80
|
+
threads_base = (
|
|
81
|
+
Path(threads_root) if threads_root is not None else default_threads_root()
|
|
82
|
+
)
|
|
83
|
+
conversations_base = (
|
|
84
|
+
Path(conversations_root)
|
|
85
|
+
if conversations_root is not None
|
|
86
|
+
else Path(default_conversations_root())
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
for thread_dir in iter_thread_dirs(threads_base):
|
|
90
|
+
thread_id = thread_dir.name
|
|
91
|
+
if thread_id in keep_thread_ids:
|
|
92
|
+
continue
|
|
93
|
+
if not thread_is_useless(thread_dir):
|
|
94
|
+
continue
|
|
95
|
+
report.removed_threads.append(thread_id)
|
|
96
|
+
if remove:
|
|
97
|
+
shutil.rmtree(thread_dir)
|
|
98
|
+
|
|
99
|
+
if conversations_base.is_dir():
|
|
100
|
+
store = JsonlConversationStore(root=conversations_base)
|
|
101
|
+
for conversation_id in store.list():
|
|
102
|
+
path = store.path_for(conversation_id)
|
|
103
|
+
if conversation_id == MESSAGES_CONVERSATION_ID:
|
|
104
|
+
continue
|
|
105
|
+
if not conversation_is_useless(path):
|
|
106
|
+
continue
|
|
107
|
+
report.removed_conversations.append(conversation_id)
|
|
108
|
+
if remove:
|
|
109
|
+
path.unlink()
|
|
110
|
+
|
|
111
|
+
return report
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def format_clean_report(report: CleanReport) -> str | None:
|
|
115
|
+
parts: list[str] = []
|
|
116
|
+
if report.removed_threads:
|
|
117
|
+
names = ", ".join(report.removed_threads)
|
|
118
|
+
parts.append(f"清理 {len(report.removed_threads)} 条空 thread: {names}")
|
|
119
|
+
if report.removed_conversations:
|
|
120
|
+
names = ", ".join(report.removed_conversations)
|
|
121
|
+
parts.append(
|
|
122
|
+
f"清理 {len(report.removed_conversations)} 个空 conversation: {names}"
|
|
123
|
+
)
|
|
124
|
+
if not parts:
|
|
125
|
+
return None
|
|
126
|
+
return " · ".join(parts)
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""底栏固定输入 REPL — 全屏布局,输入行钉在终端视口最底。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from pagentv4 import Runner
|
|
9
|
+
|
|
10
|
+
from .config import ReplConfig
|
|
11
|
+
from .layout_terminal import LayoutTerminal
|
|
12
|
+
from .render import (
|
|
13
|
+
DIM,
|
|
14
|
+
RED,
|
|
15
|
+
YELLOW,
|
|
16
|
+
RenderState,
|
|
17
|
+
c,
|
|
18
|
+
consume_run,
|
|
19
|
+
emit_user_line,
|
|
20
|
+
format_banner,
|
|
21
|
+
)
|
|
22
|
+
from .repl import (
|
|
23
|
+
format_fatal_error,
|
|
24
|
+
handle_command,
|
|
25
|
+
handle_prefixed_command,
|
|
26
|
+
open_runner,
|
|
27
|
+
say_goodbye,
|
|
28
|
+
)
|
|
29
|
+
from .terminal import emit, layout_terminal
|
|
30
|
+
from .tool_permit import apply_permit_answer, parse_permit_answer
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def dispatch_user_line(
|
|
34
|
+
line: str,
|
|
35
|
+
*,
|
|
36
|
+
runner: Runner,
|
|
37
|
+
run_task: asyncio.Task | None,
|
|
38
|
+
color: bool,
|
|
39
|
+
) -> tuple[str, asyncio.Task | None]:
|
|
40
|
+
"""单元测试用:steer 分支。"""
|
|
41
|
+
text = line.strip()
|
|
42
|
+
if not text:
|
|
43
|
+
return "continue", run_task
|
|
44
|
+
if run_task is not None and not run_task.done():
|
|
45
|
+
runner.steer(text)
|
|
46
|
+
emit(c(f"↳ steer: {text}", DIM, on=color))
|
|
47
|
+
return "continue", run_task
|
|
48
|
+
return "start_run", run_task
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def run_layout_loop(
|
|
52
|
+
runner: Runner,
|
|
53
|
+
terminal: LayoutTerminal,
|
|
54
|
+
run_state: dict,
|
|
55
|
+
*,
|
|
56
|
+
color: bool,
|
|
57
|
+
user_label: str,
|
|
58
|
+
assistant_label: str,
|
|
59
|
+
permit_auto: bool = False,
|
|
60
|
+
) -> bool:
|
|
61
|
+
run_task: asyncio.Task | None = None
|
|
62
|
+
had_user_turn = False
|
|
63
|
+
input_task = asyncio.create_task(terminal.input_queue.get())
|
|
64
|
+
terminal.set_prefix(f"{user_label}> ")
|
|
65
|
+
|
|
66
|
+
while True:
|
|
67
|
+
run_state["active"] = run_task is not None and not run_task.done()
|
|
68
|
+
idle_prefix = f"{user_label}> "
|
|
69
|
+
if run_state.get("permit") is not None:
|
|
70
|
+
terminal.set_prefix("permit> ")
|
|
71
|
+
elif run_state["active"]:
|
|
72
|
+
terminal.set_prefix("steer> ")
|
|
73
|
+
else:
|
|
74
|
+
terminal.set_prefix(idle_prefix)
|
|
75
|
+
|
|
76
|
+
wait_set: set[asyncio.Task] = {input_task}
|
|
77
|
+
if run_state["active"] and run_task is not None:
|
|
78
|
+
wait_set.add(run_task)
|
|
79
|
+
|
|
80
|
+
done, _ = await asyncio.wait(
|
|
81
|
+
wait_set,
|
|
82
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if run_task is not None and run_task in done:
|
|
86
|
+
run_task.result()
|
|
87
|
+
run_task = None
|
|
88
|
+
had_user_turn = True
|
|
89
|
+
if input_task.done():
|
|
90
|
+
input_task = asyncio.create_task(terminal.input_queue.get())
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
if input_task not in done:
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
line = input_task.result()
|
|
98
|
+
except asyncio.CancelledError:
|
|
99
|
+
break
|
|
100
|
+
|
|
101
|
+
input_task = asyncio.create_task(terminal.input_queue.get())
|
|
102
|
+
|
|
103
|
+
if line is None:
|
|
104
|
+
terminal.write()
|
|
105
|
+
say_goodbye(color=color)
|
|
106
|
+
break
|
|
107
|
+
|
|
108
|
+
text = line.strip()
|
|
109
|
+
if not text:
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
if await handle_prefixed_command(text, runner, color=color):
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
if text.startswith("/"):
|
|
116
|
+
if run_task is not None and not run_task.done():
|
|
117
|
+
emit(c("run 进行中,/ 命令请等结束后再用", YELLOW, on=color))
|
|
118
|
+
continue
|
|
119
|
+
if await handle_command(text, runner, color=color):
|
|
120
|
+
say_goodbye(color=color)
|
|
121
|
+
break
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
permit_event = run_state.get("permit")
|
|
125
|
+
if permit_event is not None:
|
|
126
|
+
answer = parse_permit_answer(text)
|
|
127
|
+
if answer is None:
|
|
128
|
+
emit(c("输入 y 批准 / n 拒绝", DIM, on=color))
|
|
129
|
+
continue
|
|
130
|
+
apply_permit_answer(runner, permit_event.tool_call_id, answer)
|
|
131
|
+
label = "已批准" if answer else "已拒绝"
|
|
132
|
+
emit(c(label, DIM, on=color))
|
|
133
|
+
wait = run_state.get("permit_wait")
|
|
134
|
+
if wait is not None:
|
|
135
|
+
wait.set()
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
if run_task is not None and not run_task.done():
|
|
139
|
+
runner.steer(text)
|
|
140
|
+
emit(c(f"↳ steer: {text}", DIM, on=color))
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
emit_user_line(text, color=color, user_label=user_label)
|
|
144
|
+
state = RenderState(
|
|
145
|
+
color=color,
|
|
146
|
+
user_label=user_label,
|
|
147
|
+
assistant_label=assistant_label,
|
|
148
|
+
)
|
|
149
|
+
run_task = asyncio.create_task(
|
|
150
|
+
consume_run(
|
|
151
|
+
runner,
|
|
152
|
+
text,
|
|
153
|
+
state,
|
|
154
|
+
run_state=run_state,
|
|
155
|
+
permit_auto=permit_auto,
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
had_user_turn = True
|
|
159
|
+
|
|
160
|
+
return had_user_turn
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
async def run_concurrent_repl(config: ReplConfig, *, color: bool | None = None) -> int:
|
|
164
|
+
use_color = sys.stdout.isatty() if color is None else color
|
|
165
|
+
if not sys.stdout.isatty():
|
|
166
|
+
from .repl import run_blocking_repl
|
|
167
|
+
|
|
168
|
+
return await run_blocking_repl(config, color=use_color)
|
|
169
|
+
|
|
170
|
+
from .clean import clean_pagent, format_clean_report
|
|
171
|
+
|
|
172
|
+
runner: Runner | None = None
|
|
173
|
+
exit_code = 0
|
|
174
|
+
had_user_turn = False
|
|
175
|
+
try:
|
|
176
|
+
runner = await open_runner(config)
|
|
177
|
+
run_state: dict = {"active": False, "permit": None}
|
|
178
|
+
terminal = LayoutTerminal(color=use_color)
|
|
179
|
+
app = terminal.build_application(run_state=run_state, runner=runner)
|
|
180
|
+
|
|
181
|
+
token = layout_terminal.set(terminal)
|
|
182
|
+
try:
|
|
183
|
+
terminal.write(format_banner(runner, color=use_color))
|
|
184
|
+
terminal.write(
|
|
185
|
+
c(
|
|
186
|
+
"输入行固定在最底;run 中 Enter=steer"
|
|
187
|
+
+ ("" if config.permission_auto() else ",危险工具需 permit> 审批")
|
|
188
|
+
+ ",Esc/Ctrl+C=cancel",
|
|
189
|
+
DIM,
|
|
190
|
+
on=use_color,
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
loop_task = asyncio.create_task(
|
|
195
|
+
run_layout_loop(
|
|
196
|
+
runner,
|
|
197
|
+
terminal,
|
|
198
|
+
run_state,
|
|
199
|
+
color=use_color,
|
|
200
|
+
user_label=config.resolved_user_label(),
|
|
201
|
+
assistant_label=config.resolved_assistant_label(),
|
|
202
|
+
permit_auto=config.permission_auto(),
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
app_task = asyncio.create_task(app.run_async())
|
|
206
|
+
had_user_turn = await loop_task
|
|
207
|
+
app.exit()
|
|
208
|
+
await app_task
|
|
209
|
+
finally:
|
|
210
|
+
layout_terminal.reset(token)
|
|
211
|
+
except BaseException as exc:
|
|
212
|
+
if isinstance(exc, SystemExit):
|
|
213
|
+
raise
|
|
214
|
+
if isinstance(exc, KeyboardInterrupt):
|
|
215
|
+
emit()
|
|
216
|
+
say_goodbye(color=use_color)
|
|
217
|
+
else:
|
|
218
|
+
message = format_fatal_error(exc, phase="start")
|
|
219
|
+
emit(c(message, RED, on=use_color), file=sys.stderr, flush=True)
|
|
220
|
+
exit_code = 1
|
|
221
|
+
finally:
|
|
222
|
+
if runner is not None:
|
|
223
|
+
try:
|
|
224
|
+
await runner.close()
|
|
225
|
+
except BaseException as exc:
|
|
226
|
+
if isinstance(exc, SystemExit):
|
|
227
|
+
raise
|
|
228
|
+
if exit_code == 0:
|
|
229
|
+
message = format_fatal_error(exc, phase="close")
|
|
230
|
+
emit(c(message, RED, on=use_color), file=sys.stderr, flush=True)
|
|
231
|
+
exit_code = 1
|
|
232
|
+
keep = {runner.thread.id} if had_user_turn else set()
|
|
233
|
+
report = clean_pagent(keep_thread_ids=keep)
|
|
234
|
+
clean_message = format_clean_report(report)
|
|
235
|
+
if clean_message:
|
|
236
|
+
emit(c(clean_message, DIM, on=use_color), flush=True)
|
|
237
|
+
return exit_code
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def main(argv: list[str] | None = None) -> None:
|
|
241
|
+
from .repl import build_parser, config_from_args
|
|
242
|
+
|
|
243
|
+
parser = build_parser()
|
|
244
|
+
config = config_from_args(parser.parse_args(argv))
|
|
245
|
+
try:
|
|
246
|
+
code = asyncio.run(run_concurrent_repl(config))
|
|
247
|
+
except KeyboardInterrupt:
|
|
248
|
+
emit()
|
|
249
|
+
raise SystemExit(0) from None
|
|
250
|
+
raise SystemExit(code)
|