aasai 0.1.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.
aasai-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 ma-xiaodun
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
aasai-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: aasai
3
+ Version: 0.1.0
4
+ Summary: A minimal CLI agent
5
+ Author: ma-xiaodun
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://gitee.com/ma-xiaodun/ass
8
+ Project-URL: Source, https://gitee.com/ma-xiaodun/ass
9
+ Keywords: ai,agent,cli,tool-calling,openai
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Python: >=3.11
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: openai
24
+ Requires-Dist: pydantic
25
+ Requires-Dist: ipython
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest; extra == "dev"
28
+ Requires-Dist: pyright; extra == "dev"
29
+ Dynamic: license-file
30
+
31
+ # aas
32
+
33
+ 一个基于 OpenAI tool-calling 的轻量 CLI 智能体,可通过工具调用读写文件、执行代码。
34
+
35
+ ## 定位
36
+
37
+ | 是 | 不是 |
38
+ |---|---|
39
+ | 信任 AI、方便第一 | 防备 AI、安全优先 |
40
+ | 一句话提示词 + 通用工具 | 几万字工具定义 + 受限工具 |
41
+ | 极简、单用户、个人工具 | 重武器、多用户、通用产品 |
42
+
43
+ > 价值在"轻",不在"功能多"。任何增加重量的优化,都是对它的背叛。
44
+ > 期待被更简单的方案吞噬——被吞噬是成功,不是失败。
45
+
46
+ ## 安装
47
+
48
+ | 步骤 | 命令 | 说明 |
49
+ |---|---|---|
50
+ | 1 | `pip install pipx` | 装 pipx(已有则跳过) |
51
+ | 2 | `pipx install git+https://gitee.com/ma-xiaodun/ass.git` | 从 Gitee 装 aas + 全部依赖 |
52
+ | 3 | 见下方 | Windows 配置 PATH;macOS/Linux 跳过 |
53
+
54
+ **Windows 第三步**(在 PowerShell 执行,然后重开终端):
55
+
56
+ ```powershell
57
+ $userPath = [Environment]::GetEnvironmentVariable("Path", "User")
58
+ [Environment]::SetEnvironmentVariable("Path", $userPath + ";$env:USERPROFILE\.local\bin", "User")
59
+ ```
60
+
61
+ 装完在任何目录运行 `aas` 或 `aas --web`。更新用 `pipx upgrade aas`。
62
+
63
+ ## 配置
64
+
65
+ 配置文件为 `~/.aas/config.json`(用户目录,与源码分离)。首次使用前创建:
66
+
67
+ ```json
68
+ {
69
+ "MODEL": "deepseek-chat",
70
+ "DEEPSEEK_API_KEY": "你的密钥",
71
+ "DEEPSEEK_BASE_URL": "https://api.deepseek.com/v1",
72
+ "DEEPSEEK_MODELS": "deepseek-chat"
73
+ }
74
+ ```
75
+
76
+ - `MODEL`:当前模型名(必设,须在某供应商 `{P}_MODELS` 中登记)
77
+ - 每个供应商三项:`{P}_API_KEY` / `{P}_BASE_URL` / `{P}_MODELS`(模型名逗号分隔)
78
+ - `P` = 供应商名大写、连字符转下划线,如 `ark-plan` → `ARK_PLAN`
79
+
80
+ ## 使用
81
+
82
+ **终端模式:**
83
+ ```bash
84
+ aas
85
+ ```
86
+ 启动后输入内容对话;`exit` 退出。会话命令:`/new` 新会话、`/list` 列出、`/switch N` 切换、`/model` 切换默认模型。
87
+
88
+ **Web 界面模式:**
89
+ ```bash
90
+ aas --web
91
+ ```
92
+ 浏览器打开 <http://127.0.0.1:8765>,获得带 Markdown 渲染、流式输出、工具调用可见、会话切换的聊天界面。
93
+
94
+ ## 开发者
95
+
96
+ ```bash
97
+ pip install -e . # 可编辑安装,改代码即生效
98
+ ```
99
+
100
+ - 配置与会话存于 `~/.aas/`,与源码分离,删除源码不影响数据。
101
+ - 新增工具:新建 `aas/tools/xxx.py` → 在 `aas/tools/__init__.py` 加一行 → 生效。
102
+ - 设计决策遵循 [CONSTITUTION.md](CONSTITUTION.md)。
103
+
104
+ ## 依赖
105
+
106
+ - Python >= 3.11
107
+ - openai
108
+ - pydantic
109
+ - ipython
aasai-0.1.0/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # aas
2
+
3
+ 一个基于 OpenAI tool-calling 的轻量 CLI 智能体,可通过工具调用读写文件、执行代码。
4
+
5
+ ## 定位
6
+
7
+ | 是 | 不是 |
8
+ |---|---|
9
+ | 信任 AI、方便第一 | 防备 AI、安全优先 |
10
+ | 一句话提示词 + 通用工具 | 几万字工具定义 + 受限工具 |
11
+ | 极简、单用户、个人工具 | 重武器、多用户、通用产品 |
12
+
13
+ > 价值在"轻",不在"功能多"。任何增加重量的优化,都是对它的背叛。
14
+ > 期待被更简单的方案吞噬——被吞噬是成功,不是失败。
15
+
16
+ ## 安装
17
+
18
+ | 步骤 | 命令 | 说明 |
19
+ |---|---|---|
20
+ | 1 | `pip install pipx` | 装 pipx(已有则跳过) |
21
+ | 2 | `pipx install git+https://gitee.com/ma-xiaodun/ass.git` | 从 Gitee 装 aas + 全部依赖 |
22
+ | 3 | 见下方 | Windows 配置 PATH;macOS/Linux 跳过 |
23
+
24
+ **Windows 第三步**(在 PowerShell 执行,然后重开终端):
25
+
26
+ ```powershell
27
+ $userPath = [Environment]::GetEnvironmentVariable("Path", "User")
28
+ [Environment]::SetEnvironmentVariable("Path", $userPath + ";$env:USERPROFILE\.local\bin", "User")
29
+ ```
30
+
31
+ 装完在任何目录运行 `aas` 或 `aas --web`。更新用 `pipx upgrade aas`。
32
+
33
+ ## 配置
34
+
35
+ 配置文件为 `~/.aas/config.json`(用户目录,与源码分离)。首次使用前创建:
36
+
37
+ ```json
38
+ {
39
+ "MODEL": "deepseek-chat",
40
+ "DEEPSEEK_API_KEY": "你的密钥",
41
+ "DEEPSEEK_BASE_URL": "https://api.deepseek.com/v1",
42
+ "DEEPSEEK_MODELS": "deepseek-chat"
43
+ }
44
+ ```
45
+
46
+ - `MODEL`:当前模型名(必设,须在某供应商 `{P}_MODELS` 中登记)
47
+ - 每个供应商三项:`{P}_API_KEY` / `{P}_BASE_URL` / `{P}_MODELS`(模型名逗号分隔)
48
+ - `P` = 供应商名大写、连字符转下划线,如 `ark-plan` → `ARK_PLAN`
49
+
50
+ ## 使用
51
+
52
+ **终端模式:**
53
+ ```bash
54
+ aas
55
+ ```
56
+ 启动后输入内容对话;`exit` 退出。会话命令:`/new` 新会话、`/list` 列出、`/switch N` 切换、`/model` 切换默认模型。
57
+
58
+ **Web 界面模式:**
59
+ ```bash
60
+ aas --web
61
+ ```
62
+ 浏览器打开 <http://127.0.0.1:8765>,获得带 Markdown 渲染、流式输出、工具调用可见、会话切换的聊天界面。
63
+
64
+ ## 开发者
65
+
66
+ ```bash
67
+ pip install -e . # 可编辑安装,改代码即生效
68
+ ```
69
+
70
+ - 配置与会话存于 `~/.aas/`,与源码分离,删除源码不影响数据。
71
+ - 新增工具:新建 `aas/tools/xxx.py` → 在 `aas/tools/__init__.py` 加一行 → 生效。
72
+ - 设计决策遵循 [CONSTITUTION.md](CONSTITUTION.md)。
73
+
74
+ ## 依赖
75
+
76
+ - Python >= 3.11
77
+ - openai
78
+ - pydantic
79
+ - ipython
@@ -0,0 +1 @@
1
+ """aas 智能体包。"""
@@ -0,0 +1,102 @@
1
+ """智能体运行时:工具调用循环。"""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+
6
+ from aas.context import compact_context, truncate
7
+ from aas.display import Display, TerminalDisplay
8
+ from aas.provider import get_client_and_model
9
+ from aas.tools import TOOL_DEFINITIONS, _TOOLS
10
+
11
+ SYSTEM_PROMPT = "你是一个有用的助手,可以使用工具完成任务。"
12
+ MAX_TURNS = 30
13
+
14
+ # ── 工具执行 ──────────────────────────────────────
15
+
16
+ def _run_tool(tc: dict, display: Display) -> str:
17
+ name = tc["function"]["name"]
18
+ try:
19
+ args = json.loads(tc["function"]["arguments"])
20
+ except json.JSONDecodeError as e:
21
+ return f"错误:参数 JSON 解析失败 — {e}"
22
+ func = _TOOLS.get(name)
23
+ if func is None:
24
+ return f"错误:未知工具 {name}"
25
+ display.tool_call(name, tc["function"]["arguments"])
26
+ try:
27
+ result = func(**args)
28
+ except Exception as e:
29
+ return f"错误:工具执行失败 — {e}"
30
+ return truncate(str(result))
31
+
32
+
33
+ # ── 流式解析 ──────────────────────────────────────
34
+
35
+ def _stream_parse(client, model, messages: list, display: Display) -> dict:
36
+ stream = client.chat.completions.create(
37
+ model=model, messages=messages, tools=TOOL_DEFINITIONS, stream=True
38
+ )
39
+ content = ""
40
+ reasoning = ""
41
+ tool_calls: list[dict] = []
42
+ for chunk in stream:
43
+ delta = chunk.choices[0].delta
44
+ if delta.content:
45
+ content += delta.content
46
+ display.delta(delta.content)
47
+ reasoning_chunk = getattr(delta, "reasoning_content", None)
48
+ if reasoning_chunk:
49
+ reasoning += reasoning_chunk
50
+ display.thinking(reasoning_chunk)
51
+ if delta.tool_calls:
52
+ for tc_chunk in delta.tool_calls:
53
+ idx = tc_chunk.index
54
+ while len(tool_calls) <= idx:
55
+ tool_calls.append(
56
+ {"id": "", "type": "function", "function": {"name": "", "arguments": ""}}
57
+ )
58
+ if tc_chunk.id:
59
+ tool_calls[idx]["id"] = tc_chunk.id
60
+ if tc_chunk.function is not None:
61
+ if tc_chunk.function.name:
62
+ tool_calls[idx]["function"]["name"] = tc_chunk.function.name
63
+ if tc_chunk.function.arguments:
64
+ tool_calls[idx]["function"]["arguments"] += tc_chunk.function.arguments
65
+ return {"content": content, "tool_calls": tool_calls, "reasoning": reasoning}
66
+
67
+
68
+ def _run_tool_calls(tool_calls: list[dict], display: Display, messages: list) -> None:
69
+ for tc in tool_calls:
70
+ name = tc["function"]["name"]
71
+ result = _run_tool(tc, display)
72
+ display.tool_result(name, str(result))
73
+ messages.append({"role": "tool", "tool_call_id": tc["id"], "content": str(result)})
74
+
75
+
76
+ # ── 主循环 ────────────────────────────────────────
77
+
78
+ def run_agent(user_input: str, messages: list, display: Display | None = None) -> list:
79
+ display = display or TerminalDisplay()
80
+ messages = [*messages, {"role": "user", "content": user_input}]
81
+ client, model = get_client_and_model()
82
+
83
+ for _ in range(MAX_TURNS):
84
+ messages = compact_context(messages)
85
+ msg = _stream_parse(client, model, messages, display)
86
+ if not msg["tool_calls"]:
87
+ display.flush()
88
+ messages.append({"role": "assistant", "content": msg["content"]})
89
+ return messages
90
+
91
+ assistant = {
92
+ "role": "assistant",
93
+ "content": msg["content"],
94
+ "tool_calls": msg["tool_calls"],
95
+ }
96
+ if msg.get("reasoning"):
97
+ assistant["reasoning_content"] = msg["reasoning"]
98
+ messages.append(assistant)
99
+ _run_tool_calls(msg["tool_calls"], display, messages)
100
+
101
+ display.notice("\n已达到最大工具调用轮数,停止。")
102
+ return messages
@@ -0,0 +1,91 @@
1
+ """斜杠命令处理:/new /list /switch /model,经 Display 输出(终端/Web 统一可见)。
2
+
3
+ 命令反馈不再直接 print,而是走 Display 协议——Web 模式经 SSE 推给浏览器,
4
+ 终端模式打印到 stdout。单一执行路径(宪法硬约束)。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from aas.display import Display
9
+ from aas.history import (
10
+ load as load_history,
11
+ save as save_history,
12
+ current_id,
13
+ set_current,
14
+ list_sessions,
15
+ new_session,
16
+ )
17
+ from aas.agent import SYSTEM_PROMPT
18
+ from aas.context import compact_context
19
+ from aas.provider import list_models, resolve_provider, set_default_model, get_default_model
20
+
21
+
22
+ def handle(cmd: str, messages: list, display: Display) -> tuple[list, bool]:
23
+ """处理斜杠命令。返回 (新消息列表, 是否已处理需继续)。
24
+
25
+ display 用于输出命令反馈;命令处理与显示层解耦。
26
+ """
27
+ cmd = cmd.strip().lower()
28
+ if cmd == "/help":
29
+ display.notice("\n可用命令:")
30
+ display.notice(" /new 新建会话")
31
+ display.notice(" /list 列出所有会话")
32
+ display.notice(" /switch N 切换到会话 N")
33
+ display.notice(" /model 查看/切换默认模型")
34
+ display.notice(" /compact 压缩对话历史(LLM 摘要)")
35
+ display.notice(" /help 显示此帮助")
36
+ display.notice(" exit 退出")
37
+ return messages, True
38
+ if cmd == "/new":
39
+ sid = new_session()
40
+ display.notice(f"\n已新建会话 {sid}(清空消息)")
41
+ return [{"role": "system", "content": SYSTEM_PROMPT}], True
42
+ if cmd == "/compact":
43
+ display.notice("\n正在压缩对话历史(LLM 摘要)…")
44
+ try:
45
+ new_messages = compact_context(messages, llm=True)
46
+ if new_messages == messages:
47
+ display.notice("\n压缩未产生变化(可能失败或已精简)")
48
+ else:
49
+ save_history(current_id(), new_messages)
50
+ display.notice("\n压缩完成,历史已精简为摘要。")
51
+ return new_messages, True
52
+ except Exception as e:
53
+ display.notice(f"\n压缩失败:{e}")
54
+ return messages, True
55
+ if cmd == "/list":
56
+ current = current_id()
57
+ for s in list_sessions():
58
+ mark = " ← 当前" if s == current else ""
59
+ display.notice(f" 会话 {s}{mark}")
60
+ return messages, True
61
+ if cmd.startswith("/switch"):
62
+ parts = cmd.split()
63
+ if len(parts) == 2 and parts[1].isdigit():
64
+ sid = parts[1]
65
+ all_ids = list_sessions()
66
+ if sid in all_ids:
67
+ set_current(sid)
68
+ display.notice(f"\n已切换到会话 {sid}")
69
+ return load_history(sid) or [{"role": "system", "content": SYSTEM_PROMPT}], True
70
+ display.notice(f"\n会话 {sid} 不存在(可用 /list 查看)")
71
+ return messages, True
72
+ display.notice("\n用法:/switch <会话号>")
73
+ return messages, True
74
+ if cmd.startswith("/model"):
75
+ parts = cmd.split()
76
+ if len(parts) == 1:
77
+ display.notice(f"\n当前模型:{get_default_model()}")
78
+ display.notice(f"可用模型:{'、'.join(list_models())}")
79
+ display.notice("用法:/model <模型名>(持久保存到 config.json)")
80
+ return messages, True
81
+ if len(parts) > 2:
82
+ display.notice("\n用法:/model <模型名>")
83
+ return messages, True
84
+ token = parts[1]
85
+ if resolve_provider(token) is None:
86
+ display.notice(f"\n模型不存在:{token}(可用:{'、'.join(list_models())})")
87
+ return messages, True
88
+ set_default_model(token)
89
+ display.notice(f"\n已切换默认模型:{token}(已保存到 config.json,重启后保留)")
90
+ return messages, True
91
+ return messages, False
@@ -0,0 +1,49 @@
1
+ 你正在对一个 AI 编程助手的对话历史做结构化压缩。
2
+ 你的输出会替换对话,压缩后模型应能无缝继续工作。
3
+
4
+ 压缩不是降采样式摘要(那会丢核心思想),而是两层并行:
5
+ ① 事实层——原样照抄,不转述、不诠释、不"优化措辞";
6
+ ② 提炼层——在事实之上做受控抽象,把隐含的结构提炼出来。两层分开放,不混。
7
+
8
+ 事实层(原样照抄):
9
+ - 文件路径、文件名
10
+ - 错误信息、堆栈跟踪、测试输出、关键执行结果
11
+ - 已修改文件的具体变更、关键代码片段
12
+ - 必须遵守硬约束
13
+ - 用户提出但尚未响应的请求
14
+
15
+ 提炼层(基于上述事实,执行三个动作,不脱离事实外推):
16
+ - 析微:把纠缠的问题拆成基本命题,挑明对话里隐含但从未明说的前提与约束
17
+ - 取骨:剥离一次性细节,提取"内容会变而结构不变"的内核——这件事的骨架是什么
18
+ - 溯源:把分散的踩坑与决策归因到根本前提,而非罗列表面现象
19
+
20
+ 判断什么该留:若不知道某条,后续决策会不会改变?会变就留,不变才删。
21
+
22
+ 严格按此格式输出:
23
+
24
+ ## 已完成
25
+ [逐条列出已改的文件、已完成的功能]
26
+
27
+ ## 当前状态
28
+ [正在进行的任务及进度]
29
+
30
+ ## 用户约束
31
+ [用户明说的硬约束、边界条件、不能违反的规则——原样照抄用户措辞]
32
+
33
+ ## 相关文件
34
+ [本次工作涉及的文件路径清单——纯路径,不含代码内容]
35
+
36
+ ## 关键事实
37
+ [错误信息、代码片段、配置变更、执行结果等,原样照抄。不含文件清单——已单列]
38
+
39
+ ## 问题骨架
40
+ [析微+取骨+溯源的产物:当前问题拆解后的基本命题、不变的结构内核、踩坑与决策的根本原因]
41
+
42
+ ## 避坑
43
+ [试过但不通的路、被否定的假设、中途发现的约束——让接续者不重蹈覆辙]
44
+
45
+ ## 待办
46
+ [模型自己的下一步计划]
47
+
48
+ ## 用户未决
49
+ [用户提过但还没处理的请求——原样照抄用户措辞,避免下一轮遗漏]
@@ -0,0 +1,126 @@
1
+ """上下文管理:确定性截断 + LLM 摘要 的统一入口。
2
+
3
+ agent(主循环自动)与 commands(/compact 手动)都从这里取压缩能力,
4
+ 避免命令模块反向依赖编排核心。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from pathlib import Path
10
+
11
+ from aas.provider import get_client_and_model
12
+
13
+ MAX_CONTEXT_CHARS = 300_000
14
+
15
+ # ── 上下文压缩:确定性纯函数 ──────────────────────
16
+
17
+ _CTX_TOOL_CONTENT_LIMIT = 800
18
+ _HOT_TOOL_TURNS = 3
19
+ _WARM_TOOL_TURNS = 7
20
+
21
+
22
+ def truncate(text: str, max_chars: int = 30000) -> str:
23
+ """超长文本统一截断:保留头/中/尾 40%/20%/40%,并明确标注截断。
24
+
25
+ agent 工具结果与上下文压缩共用此函数,截断策略单一来源。
26
+ """
27
+ if len(text) <= max_chars:
28
+ return text
29
+ head_n = int(max_chars * 0.4)
30
+ middle_n = int(max_chars * 0.2)
31
+ tail_n = max_chars - head_n - middle_n
32
+ middle_start = (len(text) - middle_n) // 2
33
+ return (
34
+ f"{text[:head_n]}\n"
35
+ f"\n[... 截断 {len(text) - max_chars:,} 字符,保留头/中/尾各 40%/20/40% ...]\n\n"
36
+ f"{text[middle_start:middle_start + middle_n]}\n"
37
+ f"\n[... 截断继续 ...]\n\n"
38
+ f"{text[-tail_n:]}"
39
+ )
40
+
41
+
42
+ def _total_chars(messages: list) -> int:
43
+ """上下文字符量:content + reasoning_content(工具轮思维链真实参与上下文)。"""
44
+ return sum(
45
+ (len(m.get("content", "")) if isinstance(m.get("content"), str) else 0)
46
+ + (len(m.get("reasoning_content", "")) if isinstance(m.get("reasoning_content"), str) else 0)
47
+ for m in messages
48
+ )
49
+
50
+
51
+ def _placeholder(text: str) -> str:
52
+ """冷层占位:首行(含路径/操作标识)+ 压缩标记。"""
53
+ first = text.split("\n", 1)[0][:200]
54
+ return f"[已压缩,原 {len(text)} 字符] {first}"
55
+
56
+
57
+ def _compact_tools(messages: list) -> list:
58
+ """按轮次年龄分层 tool 消息:热全保留 / 温截断 / 冷占位。"""
59
+ users_seen = 0
60
+ out = []
61
+ for m in reversed(messages):
62
+ if m.get("role") == "user":
63
+ users_seen += 1
64
+ elif (
65
+ m.get("role") == "tool"
66
+ and isinstance(m.get("content"), str)
67
+ and len(m["content"]) > _CTX_TOOL_CONTENT_LIMIT
68
+ ):
69
+ age = users_seen
70
+ if age >= _HOT_TOOL_TURNS + _WARM_TOOL_TURNS:
71
+ m = {**m, "content": _placeholder(m["content"])}
72
+ elif age >= _HOT_TOOL_TURNS:
73
+ m = {**m, "content": truncate(m["content"], _CTX_TOOL_CONTENT_LIMIT)}
74
+ out.append(m)
75
+ out.reverse()
76
+ return out
77
+
78
+
79
+ def compact_deterministic(messages: list, max_chars: int) -> list:
80
+ """超限时仅分层压缩 tool 消息,不自动丢对话轮(兜底由用户 /compact)。"""
81
+ if _total_chars(messages) <= max_chars:
82
+ return messages
83
+ return _compact_tools(messages)
84
+
85
+
86
+ # ── 上下文压缩:LLM 摘要 ──────────────────────────
87
+
88
+ _COMPACT_PROMPT = (Path(__file__).parent / "compact_prompt.md").read_text("utf-8")
89
+
90
+
91
+ def _llm_summarize(client, model: str, messages: list) -> str:
92
+ payload = json.dumps(messages, ensure_ascii=False)
93
+ resp = client.chat.completions.create(
94
+ model=model,
95
+ messages=[
96
+ {"role": "system", "content": _COMPACT_PROMPT},
97
+ {"role": "user", "content": payload},
98
+ ],
99
+ )
100
+ return resp.choices[0].message.content or ""
101
+
102
+
103
+ def compact_llm(messages: list) -> list:
104
+ """LLM 语义摘要压缩。失败返回原样。"""
105
+ try:
106
+ client, resolved_model = get_client_and_model()
107
+ reply = _llm_summarize(client, resolved_model, messages)
108
+ if not reply:
109
+ return messages
110
+ system = [m for m in messages if m.get("role") == "system"]
111
+ return system + [{"role": "assistant", "content": reply}]
112
+ except Exception:
113
+ return messages
114
+
115
+
116
+ # ── 对外统一入口 ──────────────────────────────────
117
+
118
+ def compact_context(messages: list, *, llm: bool = False) -> list:
119
+ """上下文压缩统一入口。
120
+
121
+ llm=False:确定性纯函数压缩(零成本,自动触发)
122
+ llm=True:LLM 语义摘要压缩(有成本,手动触发)
123
+ """
124
+ if llm:
125
+ return compact_llm(messages)
126
+ return compact_deterministic(messages, MAX_CONTEXT_CHARS)
@@ -0,0 +1,69 @@
1
+ """显示接口:agent 循环通过它通知显示层,自身不渲染。
2
+
3
+ 编排层(agent.py)只产生事件;显示层决定如何呈现(终端直接打印、未来 Web 推送)。
4
+ 当前终端实现保持流式打字机效果;工具调用/返回以摘要显示,不刷屏。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+ from typing import Protocol
10
+
11
+ # 工具调用/返回摘要最大字符数:完整内容仍喂给模型,显示层只展示摘要
12
+ _MAX_SUMMARY = 200
13
+
14
+
15
+ def _summary(text: str, limit: int = _MAX_SUMMARY) -> str:
16
+ """压缩空白并截断为单行摘要;超长保留头尾 + 长度提示。"""
17
+ text = " ".join(text.split())
18
+ if len(text) <= limit:
19
+ return text
20
+ head_n = limit // 2
21
+ return f"{text[:head_n]}…(共 {len(text)} 字符)…{text[-head_n:]}"
22
+
23
+
24
+ class Display(Protocol):
25
+ """显示协议:delta/thinking/tool_call/tool_result/notice/flush 六类事件。"""
26
+
27
+ def delta(self, token: str) -> None: ...
28
+ def thinking(self, token: str) -> None: ...
29
+ def tool_call(self, name: str, arguments: str) -> None: ...
30
+ def tool_result(self, name: str, content: str) -> None: ...
31
+ def notice(self, text: str) -> None: ...
32
+ def flush(self) -> None: ...
33
+
34
+
35
+ class TerminalDisplay(Display):
36
+ """终端实现:流式打字机 + 工具调用/返回摘要。"""
37
+
38
+ def __init__(self, stream=None) -> None:
39
+ self._stream = stream or sys.stdout
40
+
41
+ def delta(self, token: str) -> None:
42
+ """流式内容增量:保持打字机效果(不换行、立即 flush)。"""
43
+ self._stream.write(token)
44
+ self._stream.flush()
45
+
46
+ def thinking(self, token: str) -> None:
47
+ """思考内容增量:弱化色显示,不换行、立即 flush。"""
48
+ self._stream.write(f"\033[90m{token}\033[0m")
49
+ self._stream.flush()
50
+
51
+ def tool_call(self, name: str, arguments: str) -> None:
52
+ """工具被调用:显示工具名与参数摘要。"""
53
+ self._stream.write(f"\n\n◈ 调用工具 {name}({_summary(arguments)})\n")
54
+ self._stream.flush()
55
+
56
+ def tool_result(self, name: str, content: str) -> None:
57
+ """工具返回:显示结果摘要(截断,避免大输出刷屏)。"""
58
+ self._stream.write(f"◈ {name} 返回:{_summary(content)}\n")
59
+ self._stream.flush()
60
+
61
+ def notice(self, text: str) -> None:
62
+ """非流式提示消息(如达到最大轮数)。"""
63
+ self._stream.write(text + "\n")
64
+ self._stream.flush()
65
+
66
+ def flush(self) -> None:
67
+ """一段输出结束:换行。"""
68
+ self._stream.write("\n")
69
+ self._stream.flush()