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.
Files changed (72) hide show
  1. {pagent-0.3.2 → pagent-0.4.0}/PKG-INFO +15 -2
  2. {pagent-0.3.2 → pagent-0.4.0}/README.md +9 -0
  3. pagent-0.4.0/pyproject.toml +101 -0
  4. pagent-0.4.0/src/app/Dockerfile +45 -0
  5. pagent-0.4.0/src/app/Dockerfile.browser +51 -0
  6. pagent-0.4.0/src/app/__init__.py +15 -0
  7. pagent-0.4.0/src/app/__main__.py +3 -0
  8. pagent-0.4.0/src/app/clean.py +126 -0
  9. pagent-0.4.0/src/app/concurrent_repl.py +250 -0
  10. pagent-0.4.0/src/app/config.py +280 -0
  11. pagent-0.4.0/src/app/layout_terminal.py +164 -0
  12. pagent-0.4.0/src/app/pagent.toml +41 -0
  13. pagent-0.4.0/src/app/render.py +485 -0
  14. pagent-0.4.0/src/app/repl.py +261 -0
  15. pagent-0.4.0/src/app/terminal.py +154 -0
  16. pagent-0.4.0/src/app/tool_permit.py +119 -0
  17. pagent-0.4.0/src/pagentv4/__init__.py +176 -0
  18. pagent-0.4.0/src/pagentv4/adapters/__init__.py +11 -0
  19. pagent-0.4.0/src/pagentv4/adapters/acp.py +65 -0
  20. pagent-0.4.0/src/pagentv4/core/__init__.py +75 -0
  21. pagent-0.4.0/src/pagentv4/core/agent.py +91 -0
  22. pagent-0.4.0/src/pagentv4/core/agent_restored.py +91 -0
  23. pagent-0.4.0/src/pagentv4/core/events.py +64 -0
  24. pagent-0.4.0/src/pagentv4/core/hacked_agent.py +17 -0
  25. pagent-0.4.0/src/pagentv4/core/message.py +413 -0
  26. pagent-0.4.0/src/pagentv4/core/provider.py +101 -0
  27. pagent-0.4.0/src/pagentv4/core/tool.py +184 -0
  28. pagent-0.4.0/src/pagentv4/core/turn_result.py +39 -0
  29. pagent-0.4.0/src/pagentv4/runners/__init__.py +14 -0
  30. pagent-0.4.0/src/pagentv4/runners/agentic.py +51 -0
  31. pagent-0.4.0/src/pagentv4/runners/code.py +84 -0
  32. pagent-0.4.0/src/pagentv4/runners/config.py +66 -0
  33. pagent-0.4.0/src/pagentv4/runners/loop.py +80 -0
  34. pagent-0.4.0/src/pagentv4/runners/simple.py +25 -0
  35. pagent-0.4.0/src/pagentv4/runtime/__init__.py +61 -0
  36. pagent-0.4.0/src/pagentv4/runtime/conversation.py +162 -0
  37. pagent-0.4.0/src/pagentv4/runtime/hooks.py +84 -0
  38. pagent-0.4.0/src/pagentv4/runtime/inbound.py +252 -0
  39. pagent-0.4.0/src/pagentv4/runtime/runner.py +478 -0
  40. pagent-0.4.0/src/pagentv4/runtime/thread.py +185 -0
  41. pagent-0.4.0/src/pagentv4/sandbox/__init__.py +54 -0
  42. pagent-0.4.0/src/pagentv4/sandbox/backends/__init__.py +3 -0
  43. pagent-0.4.0/src/pagentv4/sandbox/backends/container.py +198 -0
  44. pagent-0.4.0/src/pagentv4/sandbox/backends/docker.py +16 -0
  45. pagent-0.4.0/src/pagentv4/sandbox/backends/local.py +154 -0
  46. pagent-0.4.0/src/pagentv4/sandbox/backends/podman.py +14 -0
  47. pagent-0.4.0/src/pagentv4/sandbox/backends/ssh.py +380 -0
  48. pagent-0.4.0/src/pagentv4/sandbox/base.py +150 -0
  49. pagent-0.4.0/src/pagentv4/sandbox/description.py +107 -0
  50. pagent-0.4.0/src/pagentv4/sandbox/guard.py +112 -0
  51. pagent-0.4.0/src/pagentv4/sandbox/policy.py +159 -0
  52. pagent-0.4.0/src/pagentv4/sandbox/sandbox.py +529 -0
  53. pagent-0.4.0/src/pagentv4/sandbox/text.py +70 -0
  54. pagent-0.4.0/src/pagentv4/sandbox/tools.py +320 -0
  55. pagent-0.4.0/src/pagentv4/sandbox/workspace.py +44 -0
  56. pagent-0.4.0/src/pagentv4/skills/__init__.py +35 -0
  57. pagent-0.4.0/src/pagentv4/skills/skill.py +293 -0
  58. pagent-0.4.0/src/pagentv4/tools/__init__.py +7 -0
  59. pagent-0.4.0/src/pagentv4/tools/web.py +167 -0
  60. pagent-0.3.2/pyproject.toml +0 -54
  61. {pagent-0.3.2 → pagent-0.4.0}/src/pagent/__init__.py +0 -0
  62. {pagent-0.3.2 → pagent-0.4.0}/src/pagent/agent.py +0 -0
  63. {pagent-0.3.2 → pagent-0.4.0}/src/pagent/defaults.py +0 -0
  64. {pagent-0.3.2 → pagent-0.4.0}/src/pagent/events.py +0 -0
  65. {pagent-0.3.2 → pagent-0.4.0}/src/pagent/feature.py +0 -0
  66. {pagent-0.3.2 → pagent-0.4.0}/src/pagent/llm.py +0 -0
  67. {pagent-0.3.2 → pagent-0.4.0}/src/pagent/memory.py +0 -0
  68. {pagent-0.3.2 → pagent-0.4.0}/src/pagent/py.typed +0 -0
  69. {pagent-0.3.2 → pagent-0.4.0}/src/pagent/session.py +0 -0
  70. {pagent-0.3.2 → pagent-0.4.0}/src/pagent/tokens.py +0 -0
  71. {pagent-0.3.2 → pagent-0.4.0}/src/pagent/tool.py +0 -0
  72. {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.3.2
4
- Summary: Minimal OpenAI Chat Completions agent (session + tools).
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
  [![CI](https://github.com/SyncLionPaw/pagent/actions/workflows/ruff.yml/badge.svg)](https://github.com/SyncLionPaw/pagent/actions/workflows/ruff.yml)
33
+ [![Coverage](https://codecov.io/gh/SyncLionPaw/pagent/graph/badge.svg)](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
  [![CI](https://github.com/SyncLionPaw/pagent/actions/workflows/ruff.yml/badge.svg)](https://github.com/SyncLionPaw/pagent/actions/workflows/ruff.yml)
8
+ [![Coverage](https://codecov.io/gh/SyncLionPaw/pagent/graph/badge.svg)](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,3 @@
1
+ from .repl import main
2
+
3
+ main()
@@ -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)