From 6e2c60194660735bc1198fa7645089d01d4b831c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9F=B3=E4=BD=9A?= Date: Thu, 16 Jul 2026 17:01:11 +0800 Subject: [PATCH 1/3] docs: add AgentScope 2.0 tutorials --- tutorials/01_hello_agentscope/README.md | 170 ++ tutorials/01_hello_agentscope/main.py | 65 + tutorials/02_message_and_event/README.md | 111 ++ tutorials/02_message_and_event/main.py | 121 ++ tutorials/03_tools/README.md | 160 ++ tutorials/03_tools/main.py | 317 ++++ tutorials/04_tool_groups/README.md | 144 ++ tutorials/04_tool_groups/main.py | 363 +++++ tutorials/05_mcp_integration/README.md | 176 ++ tutorials/05_mcp_integration/main.py | 373 +++++ tutorials/06_skills/README.md | 152 ++ tutorials/06_skills/main.py | 264 +++ .../06_skills/skills/chart_generator/SKILL.md | 74 + .../06_skills/skills/report_writer/SKILL.md | 68 + .../templates/report_template.md | 39 + tutorials/07_permissions/README.md | 180 +++ tutorials/07_permissions/main.py | 434 +++++ tutorials/08_human_in_the_loop/README.md | 179 +++ tutorials/08_human_in_the_loop/main.py | 473 ++++++ tutorials/09_streaming_ui/README.md | 148 ++ tutorials/09_streaming_ui/main.py | 473 ++++++ tutorials/10_context_management/README.md | 151 ++ tutorials/10_context_management/main.py | 359 +++++ tutorials/11_middleware/README.md | 178 +++ tutorials/11_middleware/main.py | 513 ++++++ tutorials/12_workspace/README.md | 152 ++ tutorials/12_workspace/main.py | 338 ++++ tutorials/13_agent_service/README.md | 300 ++++ tutorials/13_agent_service/client.py | 252 +++ tutorials/13_agent_service/main.py | 286 ++++ tutorials/14_scheduling/README.md | 190 +++ tutorials/14_scheduling/main.py | 279 ++++ tutorials/15_multi_agent/README.md | 158 ++ tutorials/15_multi_agent/main.py | 489 ++++++ tutorials/16_complete_datamuse/README.md | 180 +++ tutorials/16_complete_datamuse/index.html | 697 ++++++++ tutorials/16_complete_datamuse/main.py | 171 ++ tutorials/16_complete_datamuse/serve.py | 205 +++ tutorials/16_complete_datamuse/tools.py | 288 ++++ tutorials/16_complete_datamuse/workspace/.mcp | 1 + .../reports/sales_analysis_report.md | 30 + tutorials/MODULE_GUIDE.md | 1417 +++++++++++++++++ tutorials/QUICKSTART.md | 804 ++++++++++ tutorials/README.md | 118 ++ tutorials/data/generate_sales_data.py | 101 ++ tutorials/data/sales_data.csv | 1001 ++++++++++++ 46 files changed, 13142 insertions(+) create mode 100644 tutorials/01_hello_agentscope/README.md create mode 100644 tutorials/01_hello_agentscope/main.py create mode 100644 tutorials/02_message_and_event/README.md create mode 100644 tutorials/02_message_and_event/main.py create mode 100644 tutorials/03_tools/README.md create mode 100644 tutorials/03_tools/main.py create mode 100644 tutorials/04_tool_groups/README.md create mode 100644 tutorials/04_tool_groups/main.py create mode 100644 tutorials/05_mcp_integration/README.md create mode 100644 tutorials/05_mcp_integration/main.py create mode 100644 tutorials/06_skills/README.md create mode 100644 tutorials/06_skills/main.py create mode 100644 tutorials/06_skills/skills/chart_generator/SKILL.md create mode 100644 tutorials/06_skills/skills/report_writer/SKILL.md create mode 100644 tutorials/06_skills/skills/report_writer/templates/report_template.md create mode 100644 tutorials/07_permissions/README.md create mode 100644 tutorials/07_permissions/main.py create mode 100644 tutorials/08_human_in_the_loop/README.md create mode 100644 tutorials/08_human_in_the_loop/main.py create mode 100644 tutorials/09_streaming_ui/README.md create mode 100644 tutorials/09_streaming_ui/main.py create mode 100644 tutorials/10_context_management/README.md create mode 100644 tutorials/10_context_management/main.py create mode 100644 tutorials/11_middleware/README.md create mode 100644 tutorials/11_middleware/main.py create mode 100644 tutorials/12_workspace/README.md create mode 100644 tutorials/12_workspace/main.py create mode 100644 tutorials/13_agent_service/README.md create mode 100644 tutorials/13_agent_service/client.py create mode 100644 tutorials/13_agent_service/main.py create mode 100644 tutorials/14_scheduling/README.md create mode 100644 tutorials/14_scheduling/main.py create mode 100644 tutorials/15_multi_agent/README.md create mode 100644 tutorials/15_multi_agent/main.py create mode 100644 tutorials/16_complete_datamuse/README.md create mode 100644 tutorials/16_complete_datamuse/index.html create mode 100644 tutorials/16_complete_datamuse/main.py create mode 100644 tutorials/16_complete_datamuse/serve.py create mode 100644 tutorials/16_complete_datamuse/tools.py create mode 100644 tutorials/16_complete_datamuse/workspace/.mcp create mode 100644 tutorials/16_complete_datamuse/workspace/reports/sales_analysis_report.md create mode 100644 tutorials/MODULE_GUIDE.md create mode 100644 tutorials/QUICKSTART.md create mode 100644 tutorials/README.md create mode 100644 tutorials/data/generate_sales_data.py create mode 100644 tutorials/data/sales_data.csv diff --git a/tutorials/01_hello_agentscope/README.md b/tutorials/01_hello_agentscope/README.md new file mode 100644 index 0000000..db11b02 --- /dev/null +++ b/tutorials/01_hello_agentscope/README.md @@ -0,0 +1,170 @@ +# Tutorial 01: Hello AgentScope — 你的第一个 Agent + +> **什么时候需要这个?** 第一次接触 AgentScope 2.0,想最快搞清楚"用几行代码创建一个 Agent 并跟它对话"到底涉及哪些组件。后面所有章节都从这里的最小 Agent 出发。 + +## 你将学到 + +- AgentScope 2.0 的设计哲学与核心四要素 +- 如何创建和配置一个最基本的 Agent +- `reply()` 与 `reply_stream()` 两种交互方式 +- 如何切换不同的模型提供商 + +## 前置要求 + +- Python 3.11+ +- 安装 AgentScope:`pip install agentscope` +- 至少一个 LLM API Key(DashScope / OpenAI / Ollama 等) + +## 核心概念 + +### AgentScope 2.0 设计哲学 + +AgentScope 2.0 的核心理念是**利用模型能力,而非约束模型**。 + +与许多框架用严格的 prompt 和固定编排来"控制"模型不同,AgentScope 2.0 信任模型的推理和工具使用能力,提供最少必要的抽象,让模型自由发挥。 + +### 核心四要素 + +创建一个 Agent 需要四个要素: + +``` +Credential → Model → Toolkit → Agent +(认证凭据) (LLM模型) (工具集) (智能体) +``` + +1. **Credential** — 管理 API 密钥等认证信息 +2. **Model** — 指定使用哪个 LLM(如 qwen-plus, gpt-4o) +3. **Toolkit** — 注册 Agent 可以使用的工具(本教程暂不添加) +4. **Agent** — 将上述组件组合起来的智能体 + +### 异步编程 + +AgentScope 2.0 是**全异步**的。所有 Agent 方法都需要使用 `async/await` 语法: + +```python +import asyncio + +async def main(): + result = await agent.reply(msg) # await 等待异步操作完成 + +asyncio.run(main()) # 启动异步事件循环 +``` + +### 两种交互方式 + +| 方法 | 返回值 | 适用场景 | +|------|--------|----------| +| `agent.reply(msg)` | 最终的 `Msg` 对象 | 后台自动化,不需要实时输出 | +| `agent.reply_stream(msg)` | `AgentEvent` 事件流 | 交互式 UI,需要实时显示 | + +### 消息类型 + +AgentScope 2.0 提供了三种快捷消息工厂函数: + +```python +from agentscope.message import UserMsg, AssistantMsg, SystemMsg + +# 用户消息 +msg = UserMsg(name="user", content="你好") + +# 助手消息 +msg = AssistantMsg(name="agent", content="你好!有什么可以帮你的?") + +# 系统消息 +msg = SystemMsg(name="system", content="你是一个数据分析助手。") +``` + +## 示例:DataMuse 的诞生 + +本期我们创建 **DataMuse** 的最初版本 —— 一个能进行基本对话的数据分析助手。 + +### 步骤 1:创建最简 Agent + +```python +from agentscope.agent import Agent +from agentscope.credential import DashScopeCredential +from agentscope.model import DashScopeChatModel + +agent = Agent( + name="DataMuse", + system_prompt="You are DataMuse, a helpful data analysis assistant.", + model=DashScopeChatModel( + credential=DashScopeCredential(api_key=os.environ["DASHSCOPE_API_KEY"]), + model="qwen-plus", + ), +) +``` + +### 步骤 2:使用 reply() 进行对话 + +```python +from agentscope.message import UserMsg + +msg = UserMsg(name="user", content="What is the best chart type for showing trends over time?") +result = await agent.reply(msg) +print(result.get_text_content()) +``` + +`reply()` 会阻塞直到 Agent 完成回复,然后返回完整的 `Msg` 对象。 + +### 步骤 3:使用 reply_stream() 进行流式对话 + +```python +from agentscope.event import EventType + +async for event in agent.reply_stream(msg): + match event.type: + case EventType.TEXT_BLOCK_DELTA: + print(event.delta, end="", flush=True) + case EventType.REPLY_END: + print("\n[Done]") +``` + +`reply_stream()` 返回一个异步迭代器,逐步产出事件。你可以实时处理每个事件(如打印文本片段)。 + +### 步骤 4:切换模型提供商 + +只需更换 Credential 和 Model 类即可切换提供商: + +```python +# OpenAI +from agentscope.credential import OpenAICredential +from agentscope.model import OpenAIChatModel + +model = OpenAIChatModel( + credential=OpenAICredential(api_key=os.environ["OPENAI_API_KEY"]), + model="gpt-4o", +) + +# Ollama (本地模型) +from agentscope.credential import OllamaCredential +from agentscope.model import OllamaChatModel + +model = OllamaChatModel( + credential=OllamaCredential(), + model="qwen3:8b", +) +``` + +## 运行示例 + +```bash +# 默认用 DashScope +export DASHSCOPE_API_KEY="your-key-here" + +# 运行 +cd tutorials/01_hello_agentscope +python main.py +``` + +> 想用 OpenAI?按 [main.py](main.py) 顶部 docstring 的提示替换 `main()` 里的 4 行 model 配置即可。后续章节会用一个 `create_model()` helper 自动切换 provider,这里先保持最简结构。 + +## 进一步探索 + +- 尝试修改 `system_prompt`,让 DataMuse 具有不同的个性 +- 尝试使用不同的模型提供商,比较回复质量 +- 观察 `reply()` 返回的 `Msg` 对象有哪些字段 + +## 下一期预告 + +**Tutorial 02: Message & Event** — 深入理解 AgentScope 的消息和事件系统,掌握 Agent 通信的核心协议。 diff --git a/tutorials/01_hello_agentscope/main.py b/tutorials/01_hello_agentscope/main.py new file mode 100644 index 0000000..d0fedda --- /dev/null +++ b/tutorials/01_hello_agentscope/main.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +"""Tutorial 01: Hello AgentScope — Your first Agent. + +This tutorial demonstrates: +- Creating a basic Agent with AgentScope 2.0 +- Using reply() for blocking responses +- Using reply_stream() for streaming responses + +Using OpenAI instead? Swap the 4 model lines in main() for: + from agentscope.credential import OpenAICredential + from agentscope.model import OpenAIChatModel + model = OpenAIChatModel( + credential=OpenAICredential(api_key=os.environ["OPENAI_API_KEY"]), + model="gpt-4o", + ) +""" +# pylint: disable=missing-function-docstring +import asyncio +import os + +from agentscope.agent import Agent +from agentscope.credential import DashScopeCredential +from agentscope.event import EventType +from agentscope.message import UserMsg +from agentscope.model import DashScopeChatModel + + +async def main() -> None: + agent = Agent( + name="DataMuse", + system_prompt=( + "You are DataMuse, a friendly data analysis assistant. " + "Keep responses concise and actionable." + ), + model=DashScopeChatModel( + credential=DashScopeCredential( + api_key=os.environ["DASHSCOPE_API_KEY"], + ), + model="qwen-plus", + ), + ) + + # --- reply(): blocking, returns the full Msg when done --- + print("\n--- reply() ---") + result = await agent.reply( + UserMsg( + name="user", + content="What chart type best shows trends over time? " + "Answer in 2 sentences.", + ), + ) + print(result.get_text_content()) + + # --- reply_stream(): async iterator, yields events as they arrive --- + print("\n--- reply_stream() ---") + async for event in agent.reply_stream( + UserMsg(name="user", content="And for comparing categories?"), + ): + if event.type == EventType.TEXT_BLOCK_DELTA: + print(event.delta, end="", flush=True) + print() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tutorials/02_message_and_event/README.md b/tutorials/02_message_and_event/README.md new file mode 100644 index 0000000..1cd03dd --- /dev/null +++ b/tutorials/02_message_and_event/README.md @@ -0,0 +1,111 @@ +# Tutorial 02: Message & Event — Agent 的通信协议 + +> **什么时候需要这个?** 你要做 UI、做日志、做调试,需要看清 Agent 内部到底发生了什么;或者你想理解 `reply_stream()` 返回的事件长什么样、怎么从事件流重建一条消息。后面所有"流式 UI / 中间件 / 服务" 都依赖你看懂这套协议。 + +## 本章基于前序章节 + +- **T01 — Agent 与 `reply_stream`**:本章在 T01 的最小 Agent 之上,深入看它产出的事件流和最终消息。 + +## 你将学到 + +- `Msg` 消息的完整结构和六种 ContentBlock +- Event 事件系统的生命周期模式(start → delta → end) +- 消息-事件对偶性:事件流如何重建完整消息 +- 用 `append_event()` 从事件流构建消息 + +## 前置要求 + +- 完成 Tutorial 01 +- 理解 async/await 基础 + +## 核心概念 + +### 消息 (Msg) + +`Msg` 是 Agent 之间通信的基本单位。每条消息包含以下字段: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | `str` | 唯一标识符 | +| `name` | `str` | 发送者名称 | +| `role` | `"user" / "assistant" / "system"` | 发送者角色 | +| `content` | `list[ContentBlock]` | 内容块列表 | +| `metadata` | `dict` | 任意键值对元数据 | +| `created_at` | `str` | ISO 8601 创建时间 | +| `finished_at` | `str \| None` | ISO 8601 完成时间 | +| `usage` | `Usage \| None` | Token 使用量 | + +### 六种 ContentBlock + +消息的 `content` 是一个内容块列表。每种块有不同的角色约束: + +| Block 类型 | 说明 | 允许的角色 | +|-----------|------|-----------| +| `TextBlock` | 文本内容 | user, assistant, system | +| `DataBlock` | 二进制数据(图片/音频等) | user, assistant | +| `ThinkingBlock` | 思维链推理过程 | assistant | +| `ToolCallBlock` | 工具调用请求 | assistant | +| `ToolResultBlock` | 工具执行结果 | assistant | +| `HintBlock` | Agent 内部指导提示 | assistant | + +角色约束在构造时强制验证: +- **user** 消息只能包含 `TextBlock` 和 `DataBlock` +- **system** 消息只能包含 `TextBlock` +- **assistant** 消息可以包含所有类型 + +### 事件 (Event) + +Event 是消息的流式视图。Agent 执行过程中会产生一系列事件,这些事件最终组成一条完整的助手消息。 + +**核心原则**:一次 `reply` 调用 = 一条助手消息 = 一个事件流 + +事件遵循 **start → delta → end** 的生命周期模式: + +``` +ReplyStartEvent + ├── ModelCallStartEvent + │ ├── ThinkingBlockStartEvent → ThinkingBlockDeltaEvent... → ThinkingBlockEndEvent + │ ├── TextBlockStartEvent → TextBlockDeltaEvent... → TextBlockEndEvent + │ └── ToolCallStartEvent → ToolCallDeltaEvent... → ToolCallEndEvent + │ ModelCallEndEvent + │ + ├── ToolResultStartEvent → ToolResultTextDeltaEvent... → ToolResultEndEvent + │ + └── (下一轮推理-执行循环...) +ReplyEndEvent +``` + +### 消息-事件对偶性 + +事件流可以用 `msg.append_event(event)` 逐步重建完整消息。这是 AgentScope 前后端分离的基础:后端流式推送事件,前端实时重建消息。 + +```python +msg = AssistantMsg(name="agent", content=[], id=event.reply_id) +async for event in agent.reply_stream(user_msg): + msg.append_event(event) +# msg 现在包含完整的助手回复 +``` + +## 示例:探索 DataMuse 的消息和事件 + +本期示例让 DataMuse 回答数据分析问题,我们在客户端侧: +1. 逐一观察每种事件类型 +2. 用 `append_event()` 从事件流重建消息 +3. 对比流式事件和最终消息的内容 + +## 运行示例 + +```bash +cd tutorials/02_message_and_event +python main.py +``` + +## 进一步探索 + +- 观察不同模型(如支持 thinking 的模型)会产生哪些不同的事件 +- 尝试给 Agent 添加工具,观察 ToolCall / ToolResult 事件 +- 尝试构造一个包含 `DataBlock`(图片)的 UserMsg + +## 下一期预告 + +**Tutorial 03: Tool 系统** — 赋予 DataMuse 读写文件、执行脚本的能力,让它真正能分析数据。 diff --git a/tutorials/02_message_and_event/main.py b/tutorials/02_message_and_event/main.py new file mode 100644 index 0000000..22ce75c --- /dev/null +++ b/tutorials/02_message_and_event/main.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +"""Tutorial 02: Message & Event — The Agent communication protocol. + +This tutorial demonstrates: +- Msg structure and ContentBlock types +- Event lifecycle (start → delta → end) +- Building a complete message from an event stream via append_event() + +Using OpenAI? Swap the 4 model lines in main() — see T01 README. +""" +# pylint: disable=missing-function-docstring +import asyncio +import os +from collections import Counter + +from agentscope.agent import Agent +from agentscope.credential import DashScopeCredential +from agentscope.event import EventType +from agentscope.message import UserMsg, AssistantMsg +from agentscope.model import DashScopeChatModel + + +# ========================================================================= +# Example 1: Inspect Msg structure +# ========================================================================= +async def example_msg_structure() -> None: + print("\n--- Example 1: Msg structure ---") + + msg = UserMsg(name="alice", content="What is a histogram?") + + print(f"id = {msg.id}") + print(f"name = {msg.name}") + print(f"role = {msg.role}") + print(f"#blocks = {len(msg.content)}") + for i, block in enumerate(msg.content): + print(f"block[{i}] = type={block.type} text={block.text!r}") + + print(f"get_text_content() -> {msg.get_text_content()!r}") + print(f"has_content_blocks('text') -> {msg.has_content_blocks('text')}") + + +# ========================================================================= +# Example 2: Observe the event lifecycle +# ========================================================================= +async def example_event_types(agent: Agent) -> None: + print("\n--- Example 2: Event lifecycle ---") + + msg = UserMsg( + name="user", + content="Explain mean vs median vs mode in 2 sentences.", + ) + + counts: Counter[str] = Counter() + async for event in agent.reply_stream(msg): + counts[event.type] += 1 + # Print one line per non-delta event to show the start→...→end pattern + if not event.type.endswith("_delta"): + print(f" {event.type}") + + print("\nEvent counts:") + for etype, n in counts.items(): + print(f" {etype}: {n}") + + +# ========================================================================= +# Example 3: Reconstruct a Msg from the event stream +# ========================================================================= +async def example_reconstruct_msg(agent: Agent) -> None: + print("\n--- Example 3: Reconstruct Msg via append_event() ---") + + msg = UserMsg( + name="user", + content="Name the top 3 Python data-analysis libraries.", + ) + + result_msg: AssistantMsg | None = None + text_buffer = "" + + async for event in agent.reply_stream(msg): + if event.type == EventType.REPLY_START: + result_msg = AssistantMsg( + name=event.name, + content=[], + id=event.reply_id, + ) + if event.type == EventType.TEXT_BLOCK_DELTA: + text_buffer += event.delta + print(event.delta, end="", flush=True) + if result_msg is not None: + result_msg.append_event(event) + print() + + assert result_msg is not None + print(f"\nreconstructed.id = {result_msg.id}") + print(f"reconstructed.#blocks = {len(result_msg.content)}") + print( + f"reconstructed text == streamed text -> " + f"{result_msg.get_text_content() == text_buffer}", + ) + + +async def main() -> None: + await example_msg_structure() + + agent = Agent( + name="DataMuse", + system_prompt="You are DataMuse. Give concise answers.", + model=DashScopeChatModel( + credential=DashScopeCredential( + api_key=os.environ["DASHSCOPE_API_KEY"], + ), + model="qwen-plus", + ), + ) + + await example_event_types(agent) + await example_reconstruct_msg(agent) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tutorials/03_tools/README.md b/tutorials/03_tools/README.md new file mode 100644 index 0000000..0810bd4 --- /dev/null +++ b/tutorials/03_tools/README.md @@ -0,0 +1,160 @@ +# Tutorial 03: Tool 系统 — 赋予 Agent 行动能力 + +> **什么时候需要这个?** 单纯聊天不够了,你要让 Agent 真正做事——读 CSV、跑 Python 脚本、查数据库、调用任何具体动作。Tool 是 Agent 从"会说"走向"会做"的分水岭,后面几乎所有章节都是在这套工具体系之上加约束、加组合、加管理。 + +## 本章基于前序章节 + +- **T01 — Agent / Toolkit 槽位**:在 T01 的 Agent 上挂载 `Toolkit`,让它具备行动能力。 +- **T02 — `ToolCallBlock` / `ToolResultBlock` / 工具事件**:理解工具调用在事件流里长什么样。 + +## 你将学到 + +- Toolkit 架构及其注册、管理、调度机制 +- 内置工具(Bash, Read, Write, Edit, Glob, Grep)的使用 +- `FunctionTool` 适配器:将 Python 函数快速变为 Agent 工具 +- 自定义 `ToolBase` 子类:完整控制权限和执行逻辑 +- 工具执行流程:Schema 验证 → 权限检查 → 执行 → 结果返回 + +## 前置要求 + +- 完成 Tutorial 01-02 +- 准备好 `tutorials/data/sales_data.csv`(仓库已包含;如需重新生成可运行 `tutorials/data/generate_sales_data.py`) + +## 核心概念 + +### Toolkit 架构 + +`Toolkit` 是 Agent 工具能力的**注册表和发现入口**,负责: + +- **注册**:接受 `ToolBase` 实例、MCP 客户端、Skill 加载器,全部并入同一个工具池 +- **发现**:根据当前激活的工具组,通过 `get_tool_schemas()` 把可用工具的 JSON Schema 提供给 LLM +- **定位与调用**:按工具名找到对应实现,并把调用结果统一为 Agent 可消费的工具结果 + +Schema 校验、权限判定,以及根据 `is_concurrency_safe` 决定并行或串行执行,发生在 Agent 的 acting 流程里。也就是说,Toolkit 管“有哪些工具、当前能看见哪些、具体调用谁”,Agent 管“一次 ReAct 迭代里如何安全地执行这些调用”。 + +创建 Toolkit 时可以传入 4 类来源,每一类都对应后续的一章: + +```python +from agentscope.tool import Toolkit, Bash, Read + +toolkit = Toolkit( + tools=[Bash(), Read()], # 本章:ToolBase 实例 + mcps=[], # T05:MCP 客户端 + skills_or_loaders=[], # T06:Skill 加载器 + tool_groups=[], # T04:工具分组,运行时动态切换 +) +``` + +不传 `tool_groups` 时,前三类工具会被自动收进一个名为 `"basic"` 的默认组。 + +> **条件出现的内置元工具**:当存在非 `basic` 工具组时,Schema 中会出现 `reset_tools`,用于切换工具组;当当前可用组中存在 Skill 时,会出现 `Skill`,用于按名称读取完整 Skill 指令。它们不是每个 Toolkit 都固定拥有的两个工具,具体条件分别在 T04、T06 展开。 + +### 内置工具 + +AgentScope 2.0 提供了一组开箱即用的工具: + +| 工具 | 功能 | 只读 | +|------|------|------| +| `Bash` | 执行 Shell 命令 | 否 | +| `Read` | 读取文件内容(带行号) | 是 | +| `Write` | 创建/覆写文件 | 否 | +| `Edit` | 精确字符串替换 | 否 | +| `Glob` | 按 glob 模式查找文件 | 是 | +| `Grep` | 搜索文件内容(ripgrep) | 是 | + +**重要规则**:`Write` 和 `Edit` 要求文件必须先被 `Read` 读取过,防止盲写。 + +### FunctionTool 适配器 + +最快的方式是用 `FunctionTool` 包装一个普通 Python 函数: + +```python +from agentscope.tool import FunctionTool + +def query_sales(category: str, min_total: float = 0.0) -> str: + """Query sales data by category and minimum total. + + Args: + category: Product category to filter by. + min_total: Minimum order total to include. + """ + # ... implementation + return result_string + +tool = FunctionTool(query_sales, is_read_only=True) +``` + +`FunctionTool` 自动从函数签名和 docstring 提取: +- `name` ← 函数名 +- `description` ← docstring 摘要 +- `input_schema` ← 参数类型注解和 Args 描述 + +普通函数可以直接返回 `str` / `dict` / list,`FunctionTool` 会自动转换为 Agent 能消费的 `ToolChunk`。只有需要流式输出、多模态结果或精细状态时,才手动返回 `ToolChunk`。 + +### 自定义 ToolBase + +需要完整控制权限和执行逻辑时,继承 `ToolBase`: + +```python +from agentscope.tool import ToolBase, ToolChunk +from agentscope.permission import PermissionContext, PermissionDecision, PermissionBehavior +from agentscope.message import TextBlock + +class MyTool(ToolBase): + name = "MyTool" + description = "..." + input_schema = { ... } + is_concurrency_safe = True + is_read_only = True + + async def check_permissions(self, tool_input, context): + return PermissionDecision(behavior=PermissionBehavior.ALLOW) + + async def call(self, **kwargs): + result = do_something(**kwargs) + return ToolChunk(content=[TextBlock(text=str(result))]) +``` + +自定义工具推荐覆写 `call()`。`ToolBase.__call__()` 由框架保留,用来包住 tool-level middleware;直接覆写 `__call__()` 会绕过这层包装。 + +### 工具执行流程 + +``` +LLM 生成 ToolCallBlock + ↓ +Schema 验证(jsonschema.validate) + ↓ 失败 → 返回错误给 LLM +权限检查(check_permissions + PermissionEngine) + ↓ DENY → 返回拒绝信息给 LLM + ↓ ASK → 暂停等待用户确认 + ↓ ALLOW → 继续执行 +工具执行(ToolBase.__call__ → tool middleware → call) + ↓ +结果返回到 Agent 上下文 + ↓ +LLM 继续推理 +``` + +## 示例:给 DataMuse 装上数据分析工具 + +本期我们给 DataMuse 添加三层工具能力: +1. **内置工具**:让它读取 CSV 文件、执行 Python 脚本 +2. **FunctionTool**:封装一个数据查询函数 +3. **自定义 ToolBase**:实现一个带统计计算的分析工具 + +## 运行示例 + +```bash +cd tutorials/03_tools +python main.py +``` + +## 进一步探索 + +- 尝试给 `FunctionTool` 添加 `is_read_only=True` 参数观察权限行为变化 +- 自定义一个支持流式输出的工具(`async def call` 返回 `AsyncGenerator`) +- 观察并发安全工具和非并发安全工具在多工具调用时的执行差异 + +## 下一期预告 + +**Tutorial 04: Tool Group** — 将工具按功能域分组,让 DataMuse 根据任务自动切换工具集。 diff --git a/tutorials/03_tools/main.py b/tutorials/03_tools/main.py new file mode 100644 index 0000000..e0ae9c0 --- /dev/null +++ b/tutorials/03_tools/main.py @@ -0,0 +1,317 @@ +# -*- coding: utf-8 -*- +"""Tutorial 03: Tool System — Give your Agent the ability to act. + +This tutorial demonstrates: +- Using built-in tools (Bash, Read, Glob) for file operations +- Wrapping Python functions with FunctionTool +- Creating custom ToolBase subclasses +- The full tool execution lifecycle +""" +# pylint: disable=missing-function-docstring,unused-argument +import asyncio +import csv +import os +from pathlib import Path +from typing import Any + +from agentscope.agent import Agent +from agentscope.credential import DashScopeCredential +from agentscope.event import EventType +from agentscope.message import UserMsg, TextBlock +from agentscope.model import DashScopeChatModel +from agentscope.permission import ( + PermissionBehavior, + PermissionContext, + PermissionDecision, + PermissionMode, +) +from agentscope.state import AgentState +from agentscope.tool import ( + Toolkit, + ToolBase, + ToolChunk, + FunctionTool, + Bash, + Read, + Glob, + Grep, +) + +DATA_DIR = Path(__file__).resolve().parent.parent / "data" +SALES_CSV = DATA_DIR / "sales_data.csv" + + +# ========================================================================= +# Custom tools +# ========================================================================= + + +def query_sales( + category: str = "", + region: str = "", + min_total: float = 0.0, + limit: int = 10, +) -> str: + """Query and filter the sales dataset. + + Args: + category: Product category to filter (e.g. "Electronics"). Empty + string means no filter. + region: Region to filter (e.g. "North"). Empty string means no filter. + min_total: Minimum order total to include in results. + limit: Maximum number of rows to return. + """ + rows = [] + with open(SALES_CSV, "r", encoding="utf-8") as f: + reader = csv.DictReader(f) + for row in reader: + if category and row["category"] != category: + continue + if region and row["region"] != region: + continue + if float(row["total"]) < min_total: + continue + rows.append(row) + if len(rows) >= limit: + break + + if not rows: + return "No matching records found." + + header = " | ".join(rows[0].keys()) + separator = "-" * len(header) + lines = [header, separator] + for row in rows: + lines.append(" | ".join(row.values())) + + return f"Found {len(rows)} records:\n" + "\n".join(lines) + + +class SalesSummary(ToolBase): + """A custom tool that computes aggregate statistics on sales data.""" + + name = "SalesSummary" + description = ( + "Compute summary statistics (count, total revenue, average order " + "value) for the sales dataset, optionally grouped by a column." + ) + input_schema = { + "type": "object", + "properties": { + "group_by": { + "type": "string", + "description": "Column to group by: 'category', 'region', " + "'payment_method', or 'customer_tier'. " + "Leave empty for overall summary.", + "default": "", + }, + }, + "required": [], + } + is_concurrency_safe = True + is_read_only = True + + async def check_permissions( + self, + tool_input: dict[str, Any], + context: PermissionContext, + ) -> PermissionDecision: + return PermissionDecision( + behavior=PermissionBehavior.ALLOW, + message="Read-only analytics tool, always allowed.", + ) + + async def call(self, group_by: str = "") -> ToolChunk: + rows = [] + with open(SALES_CSV, "r", encoding="utf-8") as f: + reader = csv.DictReader(f) + for row in reader: + rows.append(row) + + if not group_by: + total_revenue = sum(float(r["total"]) for r in rows) + avg_order = total_revenue / len(rows) if rows else 0 + text = ( + f"Overall Summary:\n" + f" Total orders: {len(rows)}\n" + f" Total revenue: ${total_revenue:,.2f}\n" + f" Average order value: ${avg_order:,.2f}" + ) + return ToolChunk(content=[TextBlock(text=text)]) + + groups: dict[str, list] = {} + for row in rows: + key = row.get(group_by, "Unknown") + groups.setdefault(key, []).append(row) + + lines = [f"Summary grouped by '{group_by}':\n"] + lines.append(f"{'Group':<20} {'Count':>6} {'Revenue':>14} {'Avg':>10}") + lines.append("-" * 55) + + for key in sorted(groups.keys()): + group_rows = groups[key] + count = len(group_rows) + revenue = sum(float(r["total"]) for r in group_rows) + avg = revenue / count if count else 0 + lines.append( + f"{key:<20} {count:>6} ${revenue:>12,.2f} ${avg:>8,.2f}", + ) + + return ToolChunk(content=[TextBlock(text="\n".join(lines))]) + + +# ========================================================================= +# Example 1: Built-in tools +# ========================================================================= +async def example_builtin_tools(agent: Agent) -> None: + """Use built-in tools to explore the sales data file.""" + print("\n" + "=" * 60) + print("Example 1: Built-in Tools (Read, Glob, Bash)") + print("=" * 60) + + msg = UserMsg( + name="user", + content=f"Read the first 10 lines of {SALES_CSV} and tell me " + f"what columns are available and what the data looks like.", + ) + + print("\n[User]: " + msg.get_text_content()[:80] + "...") + print("\n[DataMuse]: ", end="", flush=True) + + async for event in agent.reply_stream(msg): + match event.type: + case EventType.TEXT_BLOCK_DELTA: + print(event.delta, end="", flush=True) + case EventType.TOOL_CALL_START: + print(f"\n >> Calling: {event.tool_call_name}") + case EventType.TOOL_RESULT_END: + print(f" >> Result: {event.state}") + case EventType.REPLY_END: + print() + + +# ========================================================================= +# Example 2: FunctionTool +# ========================================================================= +async def example_function_tool(agent: Agent) -> None: + """Use a FunctionTool-wrapped query function.""" + print("\n" + "=" * 60) + print("Example 2: FunctionTool (query_sales)") + print("=" * 60) + + msg = UserMsg( + name="user", + content="Use the query_sales tool to find Electronics orders " + "from the North region with total > 500. Show me the results.", + ) + + print("\n[User]: " + msg.get_text_content()) + print("\n[DataMuse]: ", end="", flush=True) + + async for event in agent.reply_stream(msg): + match event.type: + case EventType.TEXT_BLOCK_DELTA: + print(event.delta, end="", flush=True) + case EventType.TOOL_CALL_START: + print(f"\n >> Calling: {event.tool_call_name}") + case EventType.TOOL_RESULT_TEXT_DELTA: + pass # suppress raw tool output for clarity + case EventType.TOOL_RESULT_END: + print(f" >> Result: {event.state}") + case EventType.REPLY_END: + print() + + +# ========================================================================= +# Example 3: Custom ToolBase +# ========================================================================= +async def example_custom_tool(agent: Agent) -> None: + """Use the custom SalesSummary tool.""" + print("\n" + "=" * 60) + print("Example 3: Custom ToolBase (SalesSummary)") + print("=" * 60) + + msg = UserMsg( + name="user", + content="Use the SalesSummary tool to show me a summary grouped by " + "category, then by region. Compare the results and tell me " + "which category and region have the highest revenue.", + ) + + print("\n[User]: " + msg.get_text_content()[:80] + "...") + print("\n[DataMuse]: ", end="", flush=True) + + async for event in agent.reply_stream(msg): + match event.type: + case EventType.TEXT_BLOCK_DELTA: + print(event.delta, end="", flush=True) + case EventType.TOOL_CALL_START: + print(f"\n >> Calling: {event.tool_call_name}") + case EventType.TOOL_RESULT_END: + print(f" >> Result: {event.state}") + case EventType.REPLY_END: + print() + + +# ========================================================================= +# Main +# ========================================================================= +async def main() -> None: + print("Tutorial 03: Tool System") + print("=" * 60) + + if not SALES_CSV.exists(): + print(f"ERROR: {SALES_CSV} not found.") + print("Run: cd tutorials/data && python generate_sales_data.py") + return + + model = DashScopeChatModel( + credential=DashScopeCredential( + api_key=os.environ["DASHSCOPE_API_KEY"], + ), + model="qwen-plus", + ) + + agent = Agent( + name="DataMuse", + system_prompt=( + "You are DataMuse, a data analysis assistant equipped with tools " + "to read files, run commands, and analyze data. Use the available " + "tools to answer the user's questions. Always show your findings " + "clearly." + ), + model=model, + toolkit=Toolkit( + tools=[ + # Built-in tools + Bash(), + Read(), + Glob(), + Grep(), + # FunctionTool adapter + FunctionTool(query_sales, is_read_only=True), + # Custom ToolBase + SalesSummary(), + ], + ), + state=AgentState( + permission_context=PermissionContext( + mode=PermissionMode.BYPASS, + ), + ), + ) + + print(f"Agent: {agent.name}") + print(f"Data: {SALES_CSV}") + + await example_builtin_tools(agent) + await example_function_tool(agent) + await example_custom_tool(agent) + + print("\n" + "=" * 60) + print("Tutorial 03 complete! Next: Tutorial 04 — Tool Groups") + print("=" * 60) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tutorials/04_tool_groups/README.md b/tutorials/04_tool_groups/README.md new file mode 100644 index 0000000..e4dbcfb --- /dev/null +++ b/tutorials/04_tool_groups/README.md @@ -0,0 +1,144 @@ +# Tutorial 04: Tool Group — 动态工具管理 + +> **什么时候需要这个?** Agent 的工具一多,所有工具 Schema 都塞进上下文既浪费 token、又让 LLM 选错工具。把工具按"功能域"分组(数据 IO / 分析 / 可视化),让 Agent 按需切换,是工具数量上量后的标配做法。 + +## 本章基于前序章节 + +- **T03 — `Toolkit` / `ToolBase` / `FunctionTool`**:本章把 T03 的 `query_sales` 等工具按功能拆进不同 `ToolGroup`。 + +## 你将学到 + +- `basic` 保留组的特殊地位 +- 如何定义和组织 ToolGroup +- `reset_tools` 元工具的工作原理 +- Agent 自动切换工具组的最佳实践 + +## 前置要求 + +- 完成 Tutorial 03 +- 理解 Toolkit 和 ToolBase 的基本概念 + +## 核心概念 + +### 为什么需要 Tool Group? + +随着 Agent 配备的工具越来越多,所有工具的 JSON Schema 都会被发送给 LLM。这带来两个问题: + +1. **上下文浪费**:大量不相关的工具描述消耗宝贵的 context window +2. **选择困难**:工具太多时 LLM 可能选错工具 + +ToolGroup 解决了这个问题:将工具按功能域分组,Agent 可以**按需激活/停用**工具组。 + +### basic 保留组 + +`basic` 是一个特殊的工具组: +- **始终激活**,不受 `reset_tools` 影响 +- 当你直接传入 `tools=` 参数时,这些工具自动归入 `basic` 组 +- 适合放入通用工具(Read, Write, Bash 等) + +### ToolGroup 定义 + +```python +from agentscope.tool import ToolGroup + +group = ToolGroup( + name="analysis", + description="Statistical analysis tools for computing summaries and trends.", + instructions="Always validate input data before running analysis.", + tools=[my_analysis_tool], +) +``` + +| 参数 | 说明 | +|------|------| +| `name` | 组名(`"basic"` 为保留名) | +| `description` | **必填**(basic 除外),Agent 用此决定是否激活 | +| `instructions` | 激活时返回给 Agent 的使用指南 | +| `tools` | 本组包含的工具列表 | +| `mcps` | 本组包含的 MCP 客户端 | +| `skills_or_loaders` | 本组包含的技能 | + +### reset_tools 元工具 + +`reset_tools` 是 Toolkit 在你注册了非 basic 工具组时**自动注入**的一个元工具。LLM 通过调用它来切换自己当前激活的工具组——这是"agent 自管理工具集"的实现机制。 + +**它和 basic 工具的区别** + +| | basic 工具 | reset_tools | +|---|---|---| +| 出现在 schema 里 | 总是 | 仅当存在至少一个非 basic 组时 | +| 归属哪个组 | `"basic"` 组 | **不属于任何组**(在 Toolkit 里单独占一个 `builtin_meta_tool` 槽) | +| 受组的激活状态影响 | basic 永远激活 | 不受影响——一旦被注入就一直可见 | +| 权限检查 | 跟普通工具一样走 PermissionEngine | 内置硬编码 `ALLOW`,用户规则改不掉 | + +> 所以严格说"`reset_tools` 是不是 basic 工具" 的答案是**不是**——它和 basic 是并列的"始终可用"层,但走的是不同的注册路径。 + +**动态生成的 input schema** + +`reset_tools` 没有静态 schema——它在每次调 `get_tool_schemas()` 时,根据当前 Toolkit 里的非 basic 组**动态生成**:每个组变成一个 `bool` 字段,字段 description 用的就是 `ToolGroup(description=...)`。所以 LLM 看到的是这样: + +```json +{ + "name": "reset_tools", + "parameters": { + "data_io": {"type": "boolean", "default": false, "description": "数据读写..."}, + "analysis": {"type": "boolean", "default": false, "description": "统计分析..."}, + "visualization": {"type": "boolean", "default": false, "description": "图表生成..."} + } +} +``` + +**调用语义** + +- 每次调用 = **最终期望状态**(覆盖式,不是增量)。源码里第一步就是 `activated_groups.clear()`,然后把传 `True` 的组加进去 +- 没显式传 `True` 的组都会被关掉——LLM 想保留某组必须每次都列上 +- `basic` 组不出现在 schema 里,永远激活,关不掉 +- 工具返回值是激活组的 `instructions` 文本(来自 `ToolGroup(instructions=...)`),LLM 收到后才知道这组该怎么用 + +**调用一次的完整流程** + +``` +LLM 决定要做可视化: + reset_tools({"visualization": True}) + ↓ + ResetTools.call: + 1. activated_groups.clear() + 2. activated_groups = ["visualization"] + 3. 返回 visualization 组的 instructions 给 LLM + ↓ + 下一轮 LLM 看到的 toolkit schema: + basic + reset_tools + visualization 组的工具 + (data_io / analysis 已经从 schema 里消失) +``` + +### 设计原则 + +- **按功能域分组**:数据读写、统计分析、可视化各一组 +- **最小激活**:只激活当前任务需要的组 +- **instructions 指导**:在组被激活时提供上下文相关的使用指南 + +## 示例:DataMuse 的三个工具组 + +本期将 DataMuse 的工具分为三个功能组: +1. **data_io** — 数据读写工具(Read, Glob, query_sales) +2. **analysis** — 统计分析工具(SalesSummary, Bash for Python scripts) +3. **visualization** — 图表生成工具(Bash for matplotlib) + +Agent 会根据用户的请求自动切换到合适的工具组。 + +## 运行示例 + +```bash +cd tutorials/04_tool_groups +python main.py +``` + +## 进一步探索 + +- 将 MCP 客户端放入工具组,观察激活/停用行为 +- 创建一个包含 Skill 的工具组 +- 增加组数量,观察 Agent 在复杂场景下的组切换决策 + +## 下一期预告 + +**Tutorial 05: MCP 集成** — 通过 MCP 协议连接外部工具服务器,让 DataMuse 访问数据库和网页。 diff --git a/tutorials/04_tool_groups/main.py b/tutorials/04_tool_groups/main.py new file mode 100644 index 0000000..51deb6b --- /dev/null +++ b/tutorials/04_tool_groups/main.py @@ -0,0 +1,363 @@ +# -*- coding: utf-8 -*- +"""Tutorial 04: Tool Groups — Dynamic tool management. + +This tutorial demonstrates: +- Organizing tools into functional ToolGroups +- The "basic" reserved group that stays always active +- The reset_tools meta tool for agent-driven group switching +- How group activation reduces context usage +""" +# pylint: disable=missing-function-docstring,unused-argument +import asyncio +import csv +import os +from pathlib import Path +from typing import Any + +from agentscope.agent import Agent +from agentscope.credential import DashScopeCredential +from agentscope.event import EventType +from agentscope.message import UserMsg, TextBlock +from agentscope.model import DashScopeChatModel +from agentscope.permission import ( + PermissionBehavior, + PermissionContext, + PermissionDecision, + PermissionMode, +) +from agentscope.state import AgentState +from agentscope.tool import ( + Toolkit, + ToolBase, + ToolChunk, + ToolGroup, + FunctionTool, + Bash, + Read, + Glob, + Grep, +) + +DATA_DIR = Path(__file__).resolve().parent.parent / "data" +SALES_CSV = DATA_DIR / "sales_data.csv" + + +# ========================================================================= +# Tools for each group +# ========================================================================= + + +def query_sales( + category: str = "", + region: str = "", + limit: int = 10, +) -> ToolChunk: + """Query and filter the sales dataset. + + Args: + category: Product category to filter. Empty means no filter. + region: Region to filter. Empty means no filter. + limit: Maximum number of rows to return. + """ + rows = [] + with open(SALES_CSV, "r", encoding="utf-8") as f: + reader = csv.DictReader(f) + for row in reader: + if category and row["category"] != category: + continue + if region and row["region"] != region: + continue + rows.append(row) + if len(rows) >= limit: + break + + if not rows: + return ToolChunk( + content=[TextBlock(text="No matching records found.")], + ) + + header = " | ".join(rows[0].keys()) + lines = [header, "-" * len(header)] + for row in rows: + lines.append(" | ".join(row.values())) + return ToolChunk( + content=[ + TextBlock(text=f"Found {len(rows)} records:\n" + "\n".join(lines)), + ], + ) + + +class SalesSummary(ToolBase): + """Compute aggregate statistics on the sales dataset.""" + + name = "SalesSummary" + description = ( + "Compute summary statistics (count, total revenue, avg order value) " + "for sales data, optionally grouped by a column." + ) + input_schema = { + "type": "object", + "properties": { + "group_by": { + "type": "string", + "description": "Column to group by: 'category', 'region', " + "'payment_method', or 'customer_tier'. " + "Leave empty for overall summary.", + "default": "", + }, + }, + "required": [], + } + is_concurrency_safe = True + is_read_only = True + + async def check_permissions( + self, + tool_input: dict[str, Any], + context: PermissionContext, + ) -> PermissionDecision: + return PermissionDecision( + behavior=PermissionBehavior.ALLOW, + message="Read-only analytics, always allowed.", + ) + + async def call(self, group_by: str = "") -> ToolChunk: + rows = [] + with open(SALES_CSV, "r", encoding="utf-8") as f: + for row in csv.DictReader(f): + rows.append(row) + + if not group_by: + total = sum(float(r["total"]) for r in rows) + avg = total / len(rows) if rows else 0 + text = ( + f"Overall: {len(rows)} orders, " + f"${total:,.2f} revenue, ${avg:,.2f} avg" + ) + return ToolChunk(content=[TextBlock(text=text)]) + + groups: dict[str, list] = {} + for row in rows: + groups.setdefault(row.get(group_by, "?"), []).append(row) + + lines = [f"Summary by '{group_by}':"] + for key in sorted(groups): + g = groups[key] + rev = sum(float(r["total"]) for r in g) + lines.append(f" {key}: {len(g)} orders, ${rev:,.2f}") + return ToolChunk(content=[TextBlock(text="\n".join(lines))]) + + +class GenerateChart(ToolBase): + """Generate a chart from sales data using matplotlib.""" + + name = "GenerateChart" + description = ( + "Generate a bar/line/pie chart from sales data and save as PNG. " + "Specify chart type, grouping column, and metric." + ) + input_schema = { + "type": "object", + "properties": { + "chart_type": { + "type": "string", + "enum": ["bar", "line", "pie"], + "description": "Type of chart to generate.", + }, + "group_by": { + "type": "string", + "description": "Column to group data by.", + }, + "metric": { + "type": "string", + "enum": ["revenue", "count"], + "description": "Metric to visualize.", + }, + "output_path": { + "type": "string", + "description": "File path to save the chart PNG.", + }, + }, + "required": ["chart_type", "group_by", "metric", "output_path"], + } + is_concurrency_safe = True + is_read_only = False + + async def check_permissions( + self, + tool_input: dict[str, Any], + context: PermissionContext, + ) -> PermissionDecision: + return PermissionDecision( + behavior=PermissionBehavior.ALLOW, + message="Chart generation allowed.", + ) + + async def call( + self, + chart_type: str, + group_by: str, + metric: str, + output_path: str, + ) -> ToolChunk: + rows = [] + with open(SALES_CSV, "r", encoding="utf-8") as f: + for row in csv.DictReader(f): + rows.append(row) + + groups: dict[str, list] = {} + for row in rows: + groups.setdefault(row.get(group_by, "?"), []).append(row) + + data = {} + for key, g in sorted(groups.items()): + if metric == "revenue": + data[key] = sum(float(r["total"]) for r in g) + else: + data[key] = len(g) + + text = ( + f"[Simulated] Would generate {chart_type} chart:\n" + f" Group by: {group_by}\n" + f" Metric: {metric}\n" + f" Data points: {len(data)}\n" + f" Values: {data}\n" + f" Output: {output_path}\n" + f"(matplotlib not required for this tutorial demo)" + ) + return ToolChunk(content=[TextBlock(text=text)]) + + +# ========================================================================= +# Stream helper +# ========================================================================= +async def stream_reply(agent: Agent, content: str) -> None: + """Send a message and stream the reply with tool call indicators.""" + msg = UserMsg(name="user", content=content) + print(f"\n[User]: {content[:100]}{'...' if len(content) > 100 else ''}") + print("\n[DataMuse]: ", end="", flush=True) + + async for event in agent.reply_stream(msg): + match event.type: + case EventType.TEXT_BLOCK_DELTA: + print(event.delta, end="", flush=True) + case EventType.TOOL_CALL_START: + print(f"\n >> Calling: {event.tool_call_name}") + case EventType.TOOL_RESULT_END: + print(f" >> Result: {event.state}") + case EventType.REPLY_END: + print() + + +# ========================================================================= +# Main +# ========================================================================= +async def main() -> None: + print("Tutorial 04: Tool Groups") + print("=" * 60) + + if not SALES_CSV.exists(): + print(f"ERROR: {SALES_CSV} not found.") + print("Run: cd tutorials/data && python generate_sales_data.py") + return + + model = DashScopeChatModel( + credential=DashScopeCredential( + api_key=os.environ["DASHSCOPE_API_KEY"], + ), + model="qwen-plus", + ) + + # Define tool groups + toolkit = Toolkit( + # Basic group: always-active general tools + tools=[Read(), Glob(), Grep()], + # Named groups: activated on demand by the agent + tool_groups=[ + ToolGroup( + name="data_io", + description=( + "Data reading and querying tools. Activate when the user " + "wants to explore, filter, or browse raw data." + ), + instructions=( + "Use query_sales for filtered searches. Use Read for " + "viewing raw file content." + ), + tools=[FunctionTool(query_sales, is_read_only=True)], + ), + ToolGroup( + name="analysis", + description=( + "Statistical analysis tools. Activate when the user wants " + "summaries, aggregations, trends, or computed metrics." + ), + instructions=( + "Use SalesSummary for quick aggregations. Use Bash to " + "run Python scripts for complex analysis." + ), + tools=[SalesSummary(), Bash()], + ), + ToolGroup( + name="visualization", + description=( + "Chart and visualization tools. Activate when the user " + "wants to create charts, plots, or visual reports." + ), + instructions=( + "Use GenerateChart to create standard chart types. " + "Specify chart_type, group_by, metric, and output_path." + ), + tools=[GenerateChart()], + ), + ], + ) + + agent = Agent( + name="DataMuse", + system_prompt=( + "You are DataMuse, a data analysis assistant. You have tool " + "groups organized by function: data_io (reading/querying), " + "analysis (statistics), and visualization (charts). Use the " + "reset_tools meta tool to activate the right group for each task. " + "Keep responses concise." + ), + model=model, + toolkit=toolkit, + state=AgentState( + permission_context=PermissionContext( + mode=PermissionMode.BYPASS, + ), + ), + ) + + print(f"Agent: {agent.name}") + print("Tool groups: basic (always on), data_io, analysis, visualization") + + # Task 1: Data exploration — should activate data_io group + await stream_reply( + agent, + "First, I need to explore the sales data. Query the first 5 " + "Electronics orders from the North region.", + ) + + # Task 2: Analysis — should activate analysis group + await stream_reply( + agent, + "Now analyze the data: show me a summary grouped by category.", + ) + + # Task 3: Visualization — should activate visualization group + await stream_reply( + agent, + "Great! Now create a bar chart showing revenue by category and " + "save it to /tmp/revenue_by_category.png.", + ) + + print("\n" + "=" * 60) + print("Tutorial 04 complete! Next: Tutorial 05 — MCP Integration") + print("=" * 60) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tutorials/05_mcp_integration/README.md b/tutorials/05_mcp_integration/README.md new file mode 100644 index 0000000..057998e --- /dev/null +++ b/tutorials/05_mcp_integration/README.md @@ -0,0 +1,176 @@ +# Tutorial 05: MCP 集成 — 连接外部工具服务器 + +> **什么时候需要这个?** 你需要的能力别人已经做成了 MCP server——数据库、浏览器、文件系统、各种 API——与其自己重新写一遍 `ToolBase`,不如直接接进来。MCP 让 Agent 一次性接入整个外部工具生态。 + +## 本章基于前序章节 + +- **T03 — `Toolkit`**:MCP 客户端通过 `Toolkit(mcps=[...])` 注册,和本地工具混在一起。 +- **T04 — `ToolGroup`**:MCP 同样可以放进工具组,随组激活/停用。 + +## 你将学到 + +- MCP 协议的基本概念和价值 +- `StdioMCPConfig` 与 `HttpMCPConfig` 两种连接方式 +- Stateful(有状态)与 Stateless(无状态)连接的区别 +- MCP 工具的命名空间规则:`mcp__{server}__{tool}` +- `enable_tools` / `disable_tools` 过滤机制 +- MCP 工具与本地工具的混合使用 + +## 前置要求 + +- 完成 Tutorial 04 +- MCP 客户端依赖已包含在基础安装中;如需运行示例里的 Stdio MCP 服务器,请安装 Node.js / `npx` +- (可选)安装 Node.js 以使用 Stdio MCP 服务器 + +## 核心概念 + +### 什么是 MCP? + +MCP(Model Context Protocol)是一个标准化的工具服务接口协议。它允许 Agent 通过统一的协议连接各种外部工具服务器——数据库、浏览器、文件系统、API 等。 + +AgentScope 通过 `MCPClient` 提供对 MCP 的完整支持,让你可以轻松地将 MCP 服务器注册为 Agent 的工具。 + +### MCPClient + +`MCPClient` 是 AgentScope 中的 MCP 客户端,它支持两种连接方式: + +```python +from agentscope.mcp import MCPClient, StdioMCPConfig, HttpMCPConfig + +# 方式 1: Stdio — 本地进程通信 +client = MCPClient( + name="filesystem", + is_stateful=True, # Stdio 必须是 stateful + mcp_config=StdioMCPConfig( + command="npx", + args=["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], + ), +) + +# 方式 2: HTTP — 远程服务通信 +client = MCPClient( + name="weather", + is_stateful=False, # HTTP 可以是 stateless + mcp_config=HttpMCPConfig( + url="https://api.example.com/mcp", + headers={"Authorization": "Bearer xxx"}, + timeout=30.0, + ), +) +``` + +### Stateful vs Stateless + +| 特性 | Stateful | Stateless | +|------|----------|-----------| +| 连接管理 | 需要 `connect()` / `close()` | 无需手动管理 | +| 会话持久 | 保持长连接 | 每次调用创建临时会话 | +| 适用传输 | Stdio 和 HTTP | 仅 HTTP | +| 性能 | 更高(复用连接) | 每次有连接开销 | +| 典型场景 | 本地 MCP 服务器 | 远程 API 服务 | + +**关键规则**:Stdio MCP **必须**是 stateful(因为需要管理子进程)。HTTP MCP 可以是 stateful 或 stateless。 + +### 工具命名空间 + +MCP 工具在注册后会自动加上命名空间前缀: + +``` +mcp__{server_name}__{tool_name} +``` + +例如,名为 `filesystem` 的 MCP 服务器提供的 `read_file` 工具,注册后名称变为 `mcp__filesystem__read_file`。这样可以避免不同 MCP 服务器之间的工具名冲突。 + +### 工具过滤 + +通过 `enable_tools` 和 `disable_tools` 参数,你可以精确控制暴露给 Agent 的工具: + +```python +# 只启用特定工具 +client = MCPClient( + name="filesystem", + is_stateful=True, + mcp_config=StdioMCPConfig(...), + enable_tools=["read_file", "list_directory"], # 仅这两个工具可用 +) + +# 禁用特定工具 +client = MCPClient( + name="filesystem", + is_stateful=True, + mcp_config=StdioMCPConfig(...), + disable_tools=["write_file", "delete_file"], # 排除危险操作 +) +``` + +**注意**:`enable_tools` 和 `disable_tools` 不可同时指定有交集的工具。 + +### MCP 与本地工具混合 + +MCP 工具和本地工具可以自由组合在 `Toolkit` 中: + +```python +toolkit = Toolkit( + tools=[Read(), Glob()], # 本地工具(basic 组) + mcps=[filesystem_client], # MCP 工具(basic 组) + tool_groups=[ + ToolGroup( + name="analysis", + tools=[SalesSummary()], # 本地工具 + mcps=[database_client], # MCP 工具 + ), + ], +) +``` + +MCP 工具同样支持 ToolGroup 的动态激活/停用机制。 + +### 生命周期管理 + +对于 stateful MCP 客户端,需要在使用前后正确管理连接: + +```python +# 连接 +await client.connect() + +# ... 使用 agent ... + +# 关闭(建议放在 try/finally 中) +await client.close() +``` + +**重要**:将 stateful MCP 客户端传入 `Toolkit` 之前,必须先调用 `connect()`,否则会抛出 `ValueError`。 + +## 示例:给 DataMuse 接入 MCP 服务 + +本期展示两种 MCP 接入方式: + +1. **Stdio MCP**:连接本地文件系统 MCP 服务器,让 DataMuse 通过 MCP 浏览文件 +2. **模拟 MCP**:展示如何配置 HTTP MCP 以及工具过滤的使用方式 + +由于 MCP 服务器需要外部依赖(Node.js),示例中提供了优雅的降级处理——当 MCP 不可用时,自动切换到本地工具演示。 + +## 运行示例 + +```bash +cd tutorials/05_mcp_integration +python main.py +``` + +如需体验 Stdio MCP(需要 Node.js): + +```bash +npm install -g @modelcontextprotocol/server-filesystem +python main.py +``` + +## 进一步探索 + +- 连接一个数据库 MCP 服务器(如 `@modelcontextprotocol/server-sqlite`) +- 将 MCP 客户端放入 ToolGroup,观察激活/停用行为 +- 使用 `enable_tools` 和 `disable_tools` 实现最小权限暴露 +- 比较 stateful 和 stateless HTTP MCP 的性能差异 + +## 下一期预告 + +**Tutorial 06: Skill** — 用 Markdown 指令集扩展 Agent 的能力,让 DataMuse 学会生成图表和分析报告。 diff --git a/tutorials/05_mcp_integration/main.py b/tutorials/05_mcp_integration/main.py new file mode 100644 index 0000000..778e499 --- /dev/null +++ b/tutorials/05_mcp_integration/main.py @@ -0,0 +1,373 @@ +# -*- coding: utf-8 -*- +"""Tutorial 05: MCP Integration — Connect external tool servers. + +This tutorial demonstrates: +- Connecting to MCP servers using StdioMCPConfig and HttpMCPConfig +- Stateful vs Stateless MCP connections +- MCP tool namespacing (mcp__{server}__{tool}) +- Tool filtering with enable_tools / disable_tools +- Mixing MCP tools with local tools +""" +# pylint: disable=missing-function-docstring +import asyncio +import os +import shutil +from pathlib import Path + +from agentscope.agent import Agent +from agentscope.credential import DashScopeCredential +from agentscope.event import EventType +from agentscope.mcp import MCPClient, StdioMCPConfig, HttpMCPConfig +from agentscope.message import UserMsg +from agentscope.model import DashScopeChatModel +from agentscope.permission import PermissionContext, PermissionMode +from agentscope.state import AgentState +from agentscope.tool import Toolkit, Read, Glob, Grep + +DATA_DIR = Path(__file__).resolve().parent.parent / "data" +SALES_CSV = DATA_DIR / "sales_data.csv" + + +# ========================================================================= +# Stream helper +# ========================================================================= +async def stream_reply(agent: Agent, content: str) -> None: + """Send a message and stream the reply with tool call indicators.""" + msg = UserMsg(name="user", content=content) + print(f"\n[User]: {content[:100]}{'...' if len(content) > 100 else ''}") + print("\n[DataMuse]: ", end="", flush=True) + + async for event in agent.reply_stream(msg): + match event.type: + case EventType.TEXT_BLOCK_DELTA: + print(event.delta, end="", flush=True) + case EventType.TOOL_CALL_START: + print(f"\n >> Calling: {event.tool_call_name}") + case EventType.TOOL_RESULT_END: + print(f" >> Result: {event.state}") + case EventType.REPLY_END: + print() + + +# ========================================================================= +# Example 1: Stdio MCP — local filesystem server +# ========================================================================= +async def example_stdio_mcp(model) -> None: + """Connect to a local filesystem MCP server via stdio.""" + print("\n" + "=" * 60) + print("Example 1: Stdio MCP (Filesystem Server)") + print("=" * 60) + + # Check if npx is available + if not shutil.which("npx"): + print(" [SKIP] npx not found. Install Node.js to try Stdio MCP.") + print(" Showing configuration example instead:\n") + print(" client = MCPClient(") + print(' name="filesystem",') + print(" is_stateful=True,") + print(" mcp_config=StdioMCPConfig(") + print(' command="npx",') + print( + ' args=["-y", "@modelcontextprotocol/server-filesystem",', + ) + print(f' "{DATA_DIR}"],') + print(" ),") + print(' enable_tools=["read_file", "list_directory"],') + print(" )") + return + + # Create a Stdio MCP client for the filesystem server + fs_client = MCPClient( + name="filesystem", + is_stateful=True, + mcp_config=StdioMCPConfig( + command="npx", + args=[ + "-y", + "@modelcontextprotocol/server-filesystem", + str(DATA_DIR), + ], + ), + enable_tools=["read_file", "list_directory"], + ) + + # Stateful client: must connect before use + print(" Connecting to filesystem MCP server...") + await fs_client.connect() + print(" Connected!") + + # List available tools + tools = await fs_client.list_tools() + print(f" Available tools ({len(tools)}):") + for tool in tools: + print(f" - {tool.name}: {tool.description[:60]}...") + + # Create agent with MCP tools + local tools + agent = Agent( + name="DataMuse", + system_prompt=( + "You are DataMuse, a data analysis assistant. You have access to " + "filesystem tools via MCP and local tools for text search. " + "Keep responses concise." + ), + model=model, + toolkit=Toolkit( + tools=[Grep()], + mcps=[fs_client], + ), + state=AgentState( + permission_context=PermissionContext( + mode=PermissionMode.BYPASS, + ), + ), + ) + + # Use the agent with MCP tools + await stream_reply( + agent, + f"List the files in {DATA_DIR} using the filesystem MCP tools, " + "then read the first 5 lines of sales_data.csv.", + ) + + # Clean up + await fs_client.close() + print(" MCP connection closed.") + + +# ========================================================================= +# Example 2: MCP configuration patterns +# ========================================================================= +async def example_mcp_config() -> None: + """Demonstrate different MCP configuration patterns.""" + print("\n" + "=" * 60) + print("Example 2: MCP Configuration Patterns") + print("=" * 60) + + # --- Pattern 1: Stdio MCP (stateful, local process) --- + print("\n Pattern 1: Stdio MCP (stateful)") + print(" ─────────────────────────────────") + stdio_client = MCPClient( + name="sqlite", + is_stateful=True, + mcp_config=StdioMCPConfig( + command="npx", + args=[ + "-y", + "@modelcontextprotocol/server-sqlite", + "/tmp/demo.db", + ], + env={"NODE_ENV": "production"}, + ), + ) + print(f" Name: {stdio_client.name}") + print(f" Stateful: {stdio_client.is_stateful}") + print(f" Config type: {stdio_client.mcp_config.type}") + print(f" Command: {stdio_client.mcp_config.command}") + + # --- Pattern 2: HTTP MCP (stateless, remote service) --- + print("\n Pattern 2: HTTP MCP (stateless)") + print(" ────────────────────────────────") + http_client = MCPClient( + name="weather", + is_stateful=False, + mcp_config=HttpMCPConfig( + url="https://api.example.com/mcp", + headers={"Authorization": "Bearer demo-token"}, + timeout=30.0, + ), + ) + print(f" Name: {http_client.name}") + print(f" Stateful: {http_client.is_stateful}") + print(f" Config type: {http_client.mcp_config.type}") + print(f" URL: {http_client.mcp_config.url}") + + # --- Pattern 3: HTTP MCP (stateful, persistent session) --- + print("\n Pattern 3: HTTP MCP (stateful)") + print(" ───────────────────────────────") + stateful_http = MCPClient( + name="database", + is_stateful=True, + mcp_config=HttpMCPConfig( + url="http://localhost:8080/mcp", + timeout=60.0, + ), + ) + print(f" Name: {stateful_http.name}") + print(f" Stateful: {stateful_http.is_stateful}") + print(f" Config type: {stateful_http.mcp_config.type}") + + # --- Pattern 4: Tool filtering --- + print("\n Pattern 4: Tool Filtering") + print(" ──────────────────────────") + filtered_client = MCPClient( + name="fs_readonly", + is_stateful=True, + mcp_config=StdioMCPConfig( + command="npx", + args=["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], + ), + disable_tools=["write_file", "create_directory", "move_file"], + ) + print(f" Name: {filtered_client.name}") + print(f" Disabled tools: {filtered_client.disable_tools}") + print(" → Only read-only operations will be exposed to the Agent") + + +# ========================================================================= +# Example 3: MCP + local tools in ToolGroups +# ========================================================================= +async def example_mcp_with_tool_groups() -> None: + """Show how MCP tools integrate with ToolGroups.""" + print("\n" + "=" * 60) + print("Example 3: MCP + ToolGroups Architecture") + print("=" * 60) + + print( + """ + MCP tools seamlessly integrate with ToolGroups: + + toolkit = Toolkit( + # Basic group (always active): local tools + MCP + tools=[Read(), Glob(), Grep()], + mcps=[filesystem_mcp], + + tool_groups=[ + # Named group: MCP tools activated on demand + ToolGroup( + name="database", + description="Database query tools", + mcps=[database_mcp], + tools=[SalesSummary()], + ), + ToolGroup( + name="web", + description="Web browsing tools", + mcps=[browser_mcp], + ), + ], + ) + + Key behaviors: + ───────────── + • MCP tools in 'basic' group → always available + • MCP tools in named groups → activated via reset_tools + • Tool names follow mcp__{server}__{tool} pattern + • enable_tools/disable_tools filter at MCPClient level + • ToolGroup activation/deactivation affects MCP tools too +""", + ) + + +# ========================================================================= +# Example 4: Naming convention demo +# ========================================================================= +async def example_naming_convention() -> None: + """Demonstrate MCP tool naming conventions.""" + print("\n" + "=" * 60) + print("Example 4: MCP Tool Naming Convention") + print("=" * 60) + + print( + """ + MCP tools are namespaced to prevent conflicts: + + Pattern: mcp__{server_name}__{tool_name} + + Examples: + ──────── + Server: "filesystem" + • read_file → mcp__filesystem__read_file + • write_file → mcp__filesystem__write_file + • list_dir → mcp__filesystem__list_dir + + Server: "sqlite" + • query → mcp__sqlite__query + • read_file → mcp__sqlite__read_file (no conflict!) + + Server: "browser" + • navigate → mcp__browser__navigate + • screenshot → mcp__browser__screenshot + + This namespacing ensures that even if two MCP servers + expose tools with the same name, they remain distinct + in the Agent's tool set. +""", + ) + + +# ========================================================================= +# Example 5: Working demo with local tools +# ========================================================================= +async def example_local_tools_demo(model) -> None: + """Demo with local tools showing the same pattern MCP would follow.""" + print("\n" + "=" * 60) + print("Example 5: Mixed Tools Demo (Local + MCP-ready)") + print("=" * 60) + + agent = Agent( + name="DataMuse", + system_prompt=( + "You are DataMuse, a data analysis assistant. Use the available " + "tools to help the user explore and understand data files. " + "Keep responses concise." + ), + model=model, + toolkit=Toolkit( + tools=[Read(), Glob(), Grep()], + ), + state=AgentState( + permission_context=PermissionContext( + mode=PermissionMode.BYPASS, + ), + ), + ) + + await stream_reply( + agent, + f"Find all CSV files under {DATA_DIR.parent} using Glob, then " + "read the first 3 lines of the sales data CSV to preview its " + "structure.", + ) + + +# ========================================================================= +# Main +# ========================================================================= +async def main() -> None: + print("Tutorial 05: MCP Integration") + print("=" * 60) + + if not SALES_CSV.exists(): + print(f"ERROR: {SALES_CSV} not found.") + print("Run: cd tutorials/data && python generate_sales_data.py") + return + + model = DashScopeChatModel( + credential=DashScopeCredential( + api_key=os.environ["DASHSCOPE_API_KEY"], + ), + model="qwen-plus", + ) + + # Example 1: Stdio MCP (requires Node.js) + await example_stdio_mcp(model) + + # Example 2: Configuration patterns (no server needed) + await example_mcp_config() + + # Example 3: MCP + ToolGroups architecture + await example_mcp_with_tool_groups() + + # Example 4: Naming conventions + await example_naming_convention() + + # Example 5: Working demo with local tools + await example_local_tools_demo(model) + + print("\n" + "=" * 60) + print("Tutorial 05 complete! Next: Tutorial 06 — Skills") + print("=" * 60) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tutorials/06_skills/README.md b/tutorials/06_skills/README.md new file mode 100644 index 0000000..a1c3117 --- /dev/null +++ b/tutorials/06_skills/README.md @@ -0,0 +1,152 @@ +# Tutorial 06: Skill — 用 Markdown 扩展 Agent 能力 + +> **什么时候需要这个?** 某个任务需要"按一套套路组合多个工具"(比如:先采样数据 → 决定图表类型 → matplotlib 画图 → 保存)。你想把这套套路用 Markdown 沉淀下来,让 Agent 按需加载,而不是把它塞进 system prompt 让模型每次重新摸索。 + +## 本章基于前序章节 + +- **T03 — `Toolkit` / 内置工具**:Skill 不替代工具,它指导 Agent 如何**组合**已有工具(Bash / Read / Write 等)完成复杂任务。 +- **T04 — `ToolGroup`**:Skill 可以挂进任一 ToolGroup,跟随该组激活/停用。 + +## 你将学到 + +- Skill 是什么、不是什么 +- 如何编写 `SKILL.md` +- 如何把 Skill 注册到 Toolkit +- Agent 运行时如何发现和使用 Skill + +## 前置要求 + +- 完成 Tutorial 05 +- 理解 Toolkit 和 ToolGroup 的基本概念 + +## 核心概念 + +### Skill — 是什么 + +**一组 Markdown 格式的操作指南**,告诉 Agent 如何组合现有工具完成特定任务。 + +Skill **不是**工具——工具有 Schema、可以被 LLM 直接调用;Skill 是写给 Agent 看的"操作手册",Agent 读完后用已有的工具(Bash、Read、Write 等)去执行。 + +``` +工具 = 原子操作(读文件、执行命令、查询数据) +Skill = 操作指南(如何组合工具来生成图表、写报告) +``` + +**什么时候用:** 你发现某类任务需要固定的多步骤套路(采样 → 选图表类型 → matplotlib 画图 → 保存),想把这套流程沉淀下来复用,而不是每次都靠模型自己摸索。 + +### SKILL.md — 怎么写 + +这一设计参考了 [Claude Code 的 Skill 规范](https://docs.anthropic.com/en/docs/claude-code/skills)。每个 Skill 是一个**目录**,包含必需的 `SKILL.md` 和可选的资源文件(脚本、参考文档、模板等): + +``` +skills/ +├── chart_generator/ +│ ├── SKILL.md # 必需:frontmatter 元数据 + Markdown 指令 +│ └── scripts/ # 可选:可复用脚本 +│ └── plot.py +└── report_writer/ + ├── SKILL.md + └── assets/ # 可选:模板、参考文档等资源 + └── report_template.md +``` + +`SKILL.md` = YAML frontmatter(元数据) + Markdown 正文(操作指令): + +```markdown +--- +name: chart_generator +description: Generate charts using matplotlib. Supports bar, line, pie. +--- + +# Chart Generator + +## 使用流程 + +1. 读取用户提供的数据文件 +2. 根据数据特征选择图表类型 +3. 使用 `scripts/plot.py` 生成图表 +4. 保存到用户指定的输出路径 +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `name` | 是 | 技能名称,Agent 通过此名称引用 | +| `description` | 是 | 技能描述,帮助 Agent 判断何时使用该技能 | + +### 注册与使用 + +写好 SKILL.md 后,通过 Toolkit 的 `skills_or_loaders` 参数注册(Toolkit 的基本用法见 T03)。 + +`skills_or_loaders` 接受三种类型的值,适用于不同场景: + +```python +import time + +from agentscope.skill import LocalSkillLoader, Skill + +toolkit = Toolkit( + tools=[Read(), Bash()], + skills_or_loaders=[ + # 方式 1:字符串路径 — 直接指向某个 Skill 目录 + # 适用于:加载单个已知的 Skill + "skills/chart_generator", + + # 方式 2:LocalSkillLoader — 扫描目录下的所有子目录 + # 适用于:批量加载一个目录下的多个 Skill + LocalSkillLoader("skills", scan_subdir=True), + + # 方式 3:Skill 对象 — 直接传入已构造的 Skill 实例 + # 适用于:程序化构建 Skill(如从数据库或远程加载) + Skill( + name="...", + description="...", + dir="...", + markdown="...", + updated_at=time.time(), + ), + ], +) +``` + +当当前可用工具组中存在 Skill 时,系统会暴露名为 `Skill` 的只读工具(Python 实现类叫 `SkillViewer`)。Agent 运行时的流程: + +``` +系统提示中列出所有技能的 name + description(占用极少上下文) + ↓ +Agent 根据用户请求,判断需要使用某个技能 + ↓ +调用 Skill(skill="chart_generator") 读取完整的 Markdown 指令 + ↓ +按照指令使用 Bash/Read/Write 等工具执行 +``` + +这就是**渐进式加载**——20 个 Skill 的元数据只占很少的上下文空间,完整指令只在需要时才加载。 + +> Skill 也可以放进 ToolGroup 随组激活/停用,详见 T04。 + +## 示例:给 DataMuse 添加技能 + +本期创建两个 Skill: + +1. **chart_generator** — 指导 Agent 用 matplotlib 生成图表 +2. **report_writer** — 指导 Agent 生成结构化的 Markdown 分析报告 + +Agent 在收到相关任务时,先通过 `Skill` 工具读取技能指令,再用 Bash、Read 等工具执行。 + +## 运行示例 + +```bash +cd tutorials/06_skills +python main.py +``` + +## 进一步探索 + +- 创建自己的 Skill(如 `data_cleaner`),处理数据清洗任务 +- 将 Skill 放入不同的 ToolGroup,测试激活/停用行为 +- 在 Skill 中引用资源文件(模板、配置),观察 `dir` 字段的作用 +- 尝试实时修改 SKILL.md 内容,观察 LocalSkillLoader 的缓存刷新 + +## 下一期预告 + +**Tutorial 07: Permission 系统** — 控制 Agent 的行为边界,配置五种权限模式和精细的规则。 diff --git a/tutorials/06_skills/main.py b/tutorials/06_skills/main.py new file mode 100644 index 0000000..1dcf023 --- /dev/null +++ b/tutorials/06_skills/main.py @@ -0,0 +1,264 @@ +# -*- coding: utf-8 -*- +"""Tutorial 06: Skills — Extend Agent abilities with Markdown instructions. + +This tutorial demonstrates: +- Creating SKILL.md files with frontmatter metadata +- Loading skills with LocalSkillLoader +- How the Skill tool is exposed when skills are available +- Combining skills with ToolGroups +- Agent reading and following skill instructions +""" +# pylint: disable=missing-function-docstring +import asyncio +import os +from pathlib import Path + +from agentscope.agent import Agent +from agentscope.credential import DashScopeCredential +from agentscope.event import EventType +from agentscope.message import UserMsg +from agentscope.model import DashScopeChatModel +from agentscope.permission import PermissionContext, PermissionMode +from agentscope.skill import LocalSkillLoader +from agentscope.state import AgentState +from agentscope.tool import ( + Toolkit, + ToolGroup, + Bash, + Read, + Glob, + Grep, +) + +DATA_DIR = Path(__file__).resolve().parent.parent / "data" +SALES_CSV = DATA_DIR / "sales_data.csv" +SKILLS_DIR = Path(__file__).resolve().parent / "skills" + + +# ========================================================================= +# Stream helper +# ========================================================================= +async def stream_reply(agent: Agent, content: str) -> None: + """Send a message and stream the reply with tool call indicators.""" + msg = UserMsg(name="user", content=content) + print(f"\n[User]: {content[:100]}{'...' if len(content) > 100 else ''}") + print("\n[DataMuse]: ", end="", flush=True) + + async for event in agent.reply_stream(msg): + match event.type: + case EventType.TEXT_BLOCK_DELTA: + print(event.delta, end="", flush=True) + case EventType.TOOL_CALL_START: + print(f"\n >> Calling: {event.tool_call_name}") + case EventType.TOOL_RESULT_END: + print(f" >> Result: {event.state}") + case EventType.REPLY_END: + print() + + +# ========================================================================= +# Example 1: Skills in the basic group +# ========================================================================= +async def example_basic_skills(model) -> None: + """Load skills into the basic group (always available).""" + print("\n" + "=" * 60) + print("Example 1: Skills in the Basic Group") + print("=" * 60) + + # Load all skills from the skills directory + skill_loader = LocalSkillLoader( + directory=str(SKILLS_DIR), + scan_subdir=True, + ) + + # List what skills are available + skills = await skill_loader.list_skills() + print(f" Found {len(skills)} skills:") + for skill in skills: + print(f" - {skill.name}: {skill.description[:60]}...") + + # Create agent with skills in basic group + agent = Agent( + name="DataMuse", + system_prompt=( + "You are DataMuse, a data analysis assistant with skills for " + "generating charts and writing reports. When the user asks you " + "to create a chart or write a report, use the Skill tool to " + "read the skill instructions first, then follow them. " + "Keep responses concise." + ), + model=model, + toolkit=Toolkit( + tools=[Read(), Bash(), Glob(), Grep()], + skills_or_loaders=[skill_loader], + ), + state=AgentState( + permission_context=PermissionContext( + mode=PermissionMode.BYPASS, + ), + ), + ) + + # The agent should read the skill, then use Bash to execute Python + await stream_reply( + agent, + f"I want to create a bar chart showing the number of orders per " + f"region from {SALES_CSV}. Use the chart_generator skill to guide " + f"your approach. Save the chart to /tmp/orders_by_region.png.", + ) + + +# ========================================================================= +# Example 2: Skills in ToolGroups +# ========================================================================= +async def example_skills_in_groups(model) -> None: + """Organize skills into ToolGroups for on-demand activation.""" + print("\n" + "=" * 60) + print("Example 2: Skills in ToolGroups") + print("=" * 60) + + agent = Agent( + name="DataMuse", + system_prompt=( + "You are DataMuse, a data analysis assistant. You have tool " + "groups for different tasks. Use reset_tools to activate the " + "right group, then use skills within that group. " + "Keep responses concise." + ), + model=model, + toolkit=Toolkit( + tools=[Read(), Bash(), Glob(), Grep()], + tool_groups=[ + ToolGroup( + name="visualization", + description=( + "Chart and visualization tools. Activate when " + "the user wants to create charts or plots." + ), + instructions=( + "Use the chart_generator skill to guide chart " + "creation. Always read the skill first." + ), + skills_or_loaders=[ + LocalSkillLoader( + str(SKILLS_DIR / "chart_generator"), + ), + ], + ), + ToolGroup( + name="reporting", + description=( + "Report generation tools. Activate when the user " + "wants to create analysis reports." + ), + instructions=( + "Use the report_writer skill to guide report " + "creation. Follow the template structure." + ), + skills_or_loaders=[ + LocalSkillLoader( + str(SKILLS_DIR / "report_writer"), + ), + ], + ), + ], + ), + state=AgentState( + permission_context=PermissionContext( + mode=PermissionMode.BYPASS, + ), + ), + ) + + print(" Tool groups: basic (always on), visualization, reporting") + + # Task 1: Should activate visualization group + await stream_reply( + agent, + f"Create a pie chart showing the revenue distribution by category " + f"from {SALES_CSV}. Save to /tmp/revenue_pie.png.", + ) + + # Task 2: Should activate reporting group + await stream_reply( + agent, + f"Now write a brief analysis report about the sales data in " + f"{SALES_CSV}. Save the report to /tmp/sales_report.md.", + ) + + +# ========================================================================= +# Example 3: Skill anatomy walkthrough +# ========================================================================= +async def example_skill_anatomy() -> None: + """Walk through the structure of a skill.""" + print("\n" + "=" * 60) + print("Example 3: Skill Anatomy") + print("=" * 60) + + loader = LocalSkillLoader( + directory=str(SKILLS_DIR), + scan_subdir=True, + ) + skills = await loader.list_skills() + + for skill in skills: + print(f"\n Skill: {skill.name}") + print(f" ├─ Description: {skill.description[:70]}...") + print(f" ├─ Directory: {skill.dir}") + print(f" ├─ Updated at: {skill.updated_at}") + preview = skill.markdown[:200].replace("\n", "\n │ ") + print(f" └─ Content preview:\n │ {preview}...") + + print( + """ + How it works: + ───────────── + 1. SKILL.md frontmatter → name + description (shown in system prompt) + 2. SKILL.md body → full instructions (loaded on demand via Skill) + 3. Agent sees skill list → decides which skill to use + 4. Agent calls Skill(skill="chart_generator") → gets full instructions + 5. Agent follows instructions using Bash, Read, Write tools +""", + ) + + +# ========================================================================= +# Main +# ========================================================================= +async def main() -> None: + print("Tutorial 06: Skills") + print("=" * 60) + + if not SALES_CSV.exists(): + print(f"ERROR: {SALES_CSV} not found.") + print("Run: cd tutorials/data && python generate_sales_data.py") + return + + if not SKILLS_DIR.exists(): + print(f"ERROR: {SKILLS_DIR} not found.") + return + + model = DashScopeChatModel( + credential=DashScopeCredential( + api_key=os.environ["DASHSCOPE_API_KEY"], + ), + model="qwen-plus", + ) + + # Example 1: Skills in basic group + await example_basic_skills(model) + + # Example 2: Skills in ToolGroups + await example_skills_in_groups(model) + + # Example 3: Skill anatomy (no model needed) + await example_skill_anatomy() + + print("\n" + "=" * 60) + print("Tutorial 06 complete! Next: Tutorial 07 — Permission System") + print("=" * 60) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tutorials/06_skills/skills/chart_generator/SKILL.md b/tutorials/06_skills/skills/chart_generator/SKILL.md new file mode 100644 index 0000000..3cc6595 --- /dev/null +++ b/tutorials/06_skills/skills/chart_generator/SKILL.md @@ -0,0 +1,74 @@ +--- +name: chart_generator +description: Generate charts and visualizations from data using matplotlib. Supports bar, line, and pie charts with customizable styling. +--- + +# Chart Generator Skill + +You can generate charts from data using Python's matplotlib library. + +## Workflow + +1. Read or query the data source to get the values you need +2. Write a Python script that uses matplotlib to create the chart +3. Execute the script using the Bash tool + +## Chart Types + +### Bar Chart +```python +import matplotlib.pyplot as plt + +categories = ["A", "B", "C"] +values = [10, 25, 15] + +plt.figure(figsize=(8, 5)) +plt.bar(categories, values, color="#4CAF50") +plt.title("Revenue by Category") +plt.xlabel("Category") +plt.ylabel("Revenue ($)") +plt.tight_layout() +plt.savefig("output.png", dpi=150) +plt.close() +``` + +### Line Chart +```python +import matplotlib.pyplot as plt + +months = ["Jan", "Feb", "Mar", "Apr"] +values = [100, 150, 130, 180] + +plt.figure(figsize=(8, 5)) +plt.plot(months, values, marker="o", color="#2196F3", linewidth=2) +plt.title("Monthly Trend") +plt.xlabel("Month") +plt.ylabel("Value") +plt.grid(True, alpha=0.3) +plt.tight_layout() +plt.savefig("output.png", dpi=150) +plt.close() +``` + +### Pie Chart +```python +import matplotlib.pyplot as plt + +labels = ["A", "B", "C"] +sizes = [40, 35, 25] + +plt.figure(figsize=(7, 7)) +plt.pie(sizes, labels=labels, autopct="%1.1f%%", startangle=90) +plt.title("Distribution") +plt.tight_layout() +plt.savefig("output.png", dpi=150) +plt.close() +``` + +## Style Guidelines + +- Always use `plt.tight_layout()` before saving +- Save with `dpi=150` for good quality +- Always call `plt.close()` after saving to free memory +- Use descriptive titles and axis labels +- Default figure size: `(8, 5)` for bar/line, `(7, 7)` for pie diff --git a/tutorials/06_skills/skills/report_writer/SKILL.md b/tutorials/06_skills/skills/report_writer/SKILL.md new file mode 100644 index 0000000..7d0f507 --- /dev/null +++ b/tutorials/06_skills/skills/report_writer/SKILL.md @@ -0,0 +1,68 @@ +--- +name: report_writer +description: Generate structured Markdown analysis reports with sections for summary, key findings, and recommendations. +--- + +# Report Writer Skill + +You can generate structured analysis reports in Markdown format. + +## Report Structure + +Every report should follow this template: + +```markdown +# [Report Title] + +> Generated on: [date] + +## Executive Summary + +[2-3 sentence overview of the analysis] + +## Key Findings + +1. **Finding 1**: [description with supporting data] +2. **Finding 2**: [description with supporting data] +3. **Finding 3**: [description with supporting data] + +## Data Overview + +| Metric | Value | +|--------|-------| +| Total Records | [n] | +| Time Period | [start] - [end] | +| Key Metric | [value] | + +## Detailed Analysis + +### [Section 1 Title] + +[Analysis content with specific numbers and percentages] + +### [Section 2 Title] + +[Analysis content] + +## Recommendations + +- **Action 1**: [specific recommendation based on findings] +- **Action 2**: [specific recommendation] + +--- +*Report generated by DataMuse* +``` + +## Guidelines + +- Always include specific numbers and percentages, not vague descriptions +- Use tables for structured data comparisons +- Bold key metrics and important findings +- Keep the Executive Summary under 3 sentences +- Include at least 3 Key Findings +- Recommendations should be actionable and tied to specific findings +- Save the report as a `.md` file using the Write or Bash tool + +## Report Template File + +A ready-to-use template is available at: `templates/report_template.md` diff --git a/tutorials/06_skills/skills/report_writer/templates/report_template.md b/tutorials/06_skills/skills/report_writer/templates/report_template.md new file mode 100644 index 0000000..5cae504 --- /dev/null +++ b/tutorials/06_skills/skills/report_writer/templates/report_template.md @@ -0,0 +1,39 @@ +# [Report Title] + +> Generated on: [date] + +## Executive Summary + +[2-3 sentence overview of the analysis] + +## Key Findings + +1. **Finding 1**: [description with supporting data] +2. **Finding 2**: [description with supporting data] +3. **Finding 3**: [description with supporting data] + +## Data Overview + +| Metric | Value | +|--------|-------| +| Total Records | [n] | +| Time Period | [start] - [end] | +| Key Metric | [value] | + +## Detailed Analysis + +### [Section 1 Title] + +[Analysis content with specific numbers and percentages] + +### [Section 2 Title] + +[Analysis content] + +## Recommendations + +- **Action 1**: [specific recommendation based on findings] +- **Action 2**: [specific recommendation] + +--- +*Report generated by DataMuse* diff --git a/tutorials/07_permissions/README.md b/tutorials/07_permissions/README.md new file mode 100644 index 0000000..40d589c --- /dev/null +++ b/tutorials/07_permissions/README.md @@ -0,0 +1,180 @@ +# Tutorial 07: Permission 系统 — 控制 Agent 的行为边界 + +> **什么时候需要这个?** Agent 能跑 Bash、写文件之后,你必须给它划一条"什么能做、什么得问一下、什么绝对不能做"的红线——尤其是要把它跑在线上环境、共享环境或无人值守的定时任务里。 + +## 本章基于前序章节 + +- **T03 — `ToolBase.is_read_only` / `check_permissions`**:权限系统在 T03 的工具基类之上构建判定逻辑。 +- **T04–T06 — Toolkit / MCP / Skill**:权限规则同样作用于工具组、MCP 工具和 Skill 触发的操作。 + +## 你将学到 + +- 权限系统的三类决策来源:规则、模式策略、工具自身判定 +- 五种 `PermissionMode` 的行为差异 +- `PermissionRule` 的工具特定匹配语法 +- 如何配置 Allow / Deny / Ask 规则 +- 不同模式的决策顺序,以及 BYPASS 的真实边界 + +## 前置要求 + +- 完成 Tutorial 06 +- 理解 ToolBase 的 `is_read_only` 和 `check_permissions` 概念 + +## 核心概念 + +### 为什么需要权限系统? + +Agent 具有工具调用能力后,就可以执行文件操作、Shell 命令等有风险的操作。权限系统在 Agent 和工具之间建立了一道安全屏障,确保: + +- 只读场景下不会误修改文件 +- 危险命令需要用户确认 +- 无人值守时不会卡在等待确认 + +### 五种 PermissionMode + +```python +from agentscope.permission import PermissionMode + +# 每种模式适用不同场景 +PermissionMode.DEFAULT # 默认 ASK;工具明确 ALLOW 时可直接执行 +PermissionMode.ACCEPT_EDITS # 工作目录内的文件编辑自动允许 +PermissionMode.EXPLORE # 只读模式:只允许读取,禁止写入 +PermissionMode.BYPASS # 跳过 ASK;仅保留显式规则和工具 DENY +PermissionMode.DONT_ASK # 不询问,直接拒绝 ASK 类决策 +``` + +| 模式 | 读取 | 写入 | Bash | 适用场景 | +|------|------|------|------|----------| +| DEFAULT | 通常 ASK | 通常 ASK | 已识别只读命令可 ALLOW | 默认交互 | +| ACCEPT_EDITS | ALLOW | 工作目录内 ALLOW* | 依命令和路径判定 | 开发迭代 | +| EXPLORE | ALLOW | DENY | 只读命令 ALLOW,其余 DENY | 浏览代码 | +| BYPASS | 通常 ALLOW | 通常 ALLOW | 通常 ALLOW | 可信沙箱 | +| DONT_ASK | ALLOW 保留,ASK→DENY | ALLOW 保留,ASK→DENY | ALLOW 保留,ASK→DENY | 定时任务 | + +\* ACCEPT_EDITS 的写入自动允许仅限于工作目录内 + +> BYPASS 不是“无条件 ALLOW”。用户配置的 DENY / ASK 规则,以及工具自身明确返回的 DENY,仍然生效;但工具返回的安全 ASK 会被跳过,所以只应在隔离且可信的环境中使用。 + +### PermissionRule + +规则由四个字段组成: + +```python +from agentscope.permission import PermissionRule, PermissionBehavior + +rule = PermissionRule( + tool_name="Bash", # 规则作用的工具名 + rule_content="python", # Bash 使用命令子串匹配 + behavior=PermissionBehavior.ALLOW, # ALLOW / DENY / ASK + source="tutorial", # 规则来源标识 +) +``` + +不同工具的 `rule_content` 匹配语法不同: + +| 工具 | 匹配方式 | 示例 | +|------|----------|------| +| Bash | 命令子串匹配 | `"python"` 匹配 `python script.py` | +| Read / Write / Edit | glob 路径匹配 | `"src/**"` 匹配 `src/main.py` | +| 其他工具 | 通用模式匹配 | 取决于工具实现 | + +空 `rule_content` 匹配所有调用。 + +### 决策优先级 + +权限引擎不是所有模式共用一条完全相同的流水线。共同起点是先检查用户配置的 DENY、ASK 规则,然后由当前模式决定后续行为: + +``` +DENY 规则 → ASK 规则 → 当前模式策略 / 工具 check_permissions + ↓ + Allow 规则(适用时) + ↓ + 当前模式的默认结果 +``` + +- `DEFAULT`:工具明确 ALLOW / DENY 就采用;安全 ASK 不会被 Allow 规则覆盖;否则匹配 Allow 规则,最后默认 ASK。 +- `EXPLORE`:直接以本次调用是否只读为准,只读 ALLOW、修改 DENY;Allow 规则不能突破只读边界。 +- `ACCEPT_EDITS`:只读调用和工作目录内编辑可自动 ALLOW,其他调用继续走工具判定、Allow 规则和默认 ASK。 +- `BYPASS`:显式 DENY / ASK 规则和工具 DENY 仍生效;工具 ASK 被跳过,最后默认 ALLOW。 +- `DONT_ASK`:任何原本要 ASK 的分支都转成 DENY,保证无人值守时不会挂起等待。 + +### PermissionContext + +```python +from agentscope.permission import PermissionContext, PermissionMode, PermissionRule + +context = PermissionContext( + mode=PermissionMode.DEFAULT, + allow_rules={ + "Bash": [ + PermissionRule( + tool_name="Bash", + rule_content="python", + behavior=PermissionBehavior.ALLOW, + source="tutorial", + ), + ], + }, + deny_rules={ + "Bash": [ + PermissionRule( + tool_name="Bash", + rule_content="rm -rf", + behavior=PermissionBehavior.DENY, + source="tutorial", + ), + ], + }, +) +``` + +### 将权限配置装入 Agent + +`PermissionContext` 本身只是一份配置——它需要通过 `AgentState` 传入 `Agent` 构造函数才能生效: + +```python +from agentscope.agent import Agent +from agentscope.state import AgentState + +agent = Agent( + name="DataMuse", + system_prompt="...", + model=model, + toolkit=toolkit, + # ← 权限在这里注入:通过 state 参数 + state=AgentState(permission_context=context), +) +``` + +关键点: + +- **`state=AgentState(permission_context=...)`** 是唯一的注入点。不传 `state` 参数时,Agent 使用默认 `AgentState()`,其中 `permission_context` 的模式为 `DEFAULT`(所有操作 ASK)。 +- `PermissionContext` 对象在 Agent 生命周期内可以**运行时修改**——例如 `agent.state.permission_context.mode = PermissionMode.BYPASS` 可以在调试时临时放开限制。 +- 同一个 `PermissionContext` 实例可以**跨多个 Agent 共享**(如果你想让一组 Agent 共用同一套规则),也可以每个 Agent 独立配置。 + +## 示例:为 DataMuse 配置不同权限级别 + +本期通过四个示例展示权限系统的工作方式: + +1. **EXPLORE 模式**:只读浏览,禁止一切修改 +2. **ACCEPT_EDITS 模式**:允许写入工作目录 +3. **规则配置**:Allow + Deny 规则的精细控制 +4. **DONT_ASK 模式**:无人值守的安全降级 + +## 运行示例 + +```bash +cd tutorials/07_permissions +python main.py +``` + +## 进一步探索 + +- 运行时修改 `PermissionContext.mode`,观察行为切换 +- 对 MCP 工具配置权限规则,测试命名空间匹配 +- 自定义 ToolBase 的 `check_permissions` 返回 PASSTHROUGH,观察引擎接管 +- 给 Bash 工具配置 `"git"` 的 Allow 规则,观察命令子串匹配 + +## 下一期预告 + +**Tutorial 08: Human-in-the-Loop** — 当权限检查返回 ASK 时,如何实现用户确认和外部执行流程。 diff --git a/tutorials/07_permissions/main.py b/tutorials/07_permissions/main.py new file mode 100644 index 0000000..46095b4 --- /dev/null +++ b/tutorials/07_permissions/main.py @@ -0,0 +1,434 @@ +# -*- coding: utf-8 -*- +"""Tutorial 07: Permission System — Control Agent behavior boundaries. + +This tutorial demonstrates: +- Five PermissionMode options and their effects +- Configuring Allow, Deny, and Ask rules +- Permission evaluation priority order +- Switching modes at runtime +- DONT_ASK mode for unattended execution +""" +# pylint: disable=missing-function-docstring,unused-argument +import asyncio +import csv +import os +from pathlib import Path +from typing import Any + +from agentscope.agent import Agent +from agentscope.credential import DashScopeCredential +from agentscope.event import EventType +from agentscope.message import UserMsg, TextBlock +from agentscope.model import ChatModelBase, DashScopeChatModel +from agentscope.permission import ( + PermissionBehavior, + PermissionContext, + PermissionDecision, + PermissionMode, + PermissionRule, +) +from agentscope.state import AgentState +from agentscope.tool import ( + Toolkit, + ToolBase, + ToolChunk, + FunctionTool, + Bash, + Read, + Glob, + Grep, +) + +DATA_DIR = Path(__file__).resolve().parent.parent / "data" +SALES_CSV = DATA_DIR / "sales_data.csv" +OUTPUT_DIR = Path(__file__).resolve().parent / "output" + + +# ========================================================================= +# Custom tools +# ========================================================================= +def query_sales(category: str = "", limit: int = 5) -> ToolChunk: + """Query the sales dataset. + + Args: + category: Product category to filter. Empty means no filter. + limit: Maximum number of rows to return. + """ + rows = [] + with open(SALES_CSV, "r", encoding="utf-8") as f: + for row in csv.DictReader(f): + if category and row["category"] != category: + continue + rows.append(row) + if len(rows) >= limit: + break + if not rows: + return ToolChunk( + content=[TextBlock(text="No matching records found.")], + ) + header = " | ".join(rows[0].keys()) + lines = [header, "-" * len(header)] + for row in rows: + lines.append(" | ".join(row.values())) + return ToolChunk( + content=[ + TextBlock(text=f"Found {len(rows)} records:\n" + "\n".join(lines)), + ], + ) + + +class SalesSummary(ToolBase): + """Read-only analytics tool with explicit permission declaration.""" + + name = "SalesSummary" + description = "Compute summary statistics for the sales dataset." + input_schema = { + "type": "object", + "properties": { + "group_by": { + "type": "string", + "description": "Column to group by (e.g. 'category', " + "'region'). Empty for overall summary.", + "default": "", + }, + }, + "required": [], + } + is_concurrency_safe = True + is_read_only = True + + async def check_permissions( + self, + tool_input: dict[str, Any], + context: PermissionContext, + ) -> PermissionDecision: + return PermissionDecision( + behavior=PermissionBehavior.ALLOW, + message="Read-only analytics, always allowed.", + ) + + async def call(self, group_by: str = "") -> ToolChunk: + rows = [] + with open(SALES_CSV, "r", encoding="utf-8") as f: + for row in csv.DictReader(f): + rows.append(row) + + if not group_by: + total = sum(float(r["total"]) for r in rows) + avg = total / len(rows) if rows else 0 + text = ( + f"Overall: {len(rows)} orders, " + f"${total:,.2f} revenue, ${avg:,.2f} avg" + ) + return ToolChunk(content=[TextBlock(text=text)]) + + groups: dict[str, list] = {} + for row in rows: + groups.setdefault(row.get(group_by, "?"), []).append(row) + + lines = [f"Summary by '{group_by}':"] + for key in sorted(groups): + g = groups[key] + rev = sum(float(r["total"]) for r in g) + lines.append(f" {key}: {len(g)} orders, ${rev:,.2f}") + return ToolChunk(content=[TextBlock(text="\n".join(lines))]) + + +# ========================================================================= +# Stream helper +# ========================================================================= +async def stream_reply(agent: Agent, content: str) -> None: + """Send a message and stream the reply.""" + msg = UserMsg(name="user", content=content) + print(f"\n[User]: {content[:100]}{'...' if len(content) > 100 else ''}") + print("\n[DataMuse]: ", end="", flush=True) + + async for event in agent.reply_stream(msg): + match event.type: + case EventType.TEXT_BLOCK_DELTA: + print(event.delta, end="", flush=True) + case EventType.TOOL_CALL_START: + print(f"\n >> Calling: {event.tool_call_name}") + case EventType.TOOL_RESULT_END: + print(f" >> Result: {event.state}") + case EventType.REPLY_END: + print() + + +# ========================================================================= +# Example 1: EXPLORE mode (read-only) +# ========================================================================= +async def example_explore_mode(model: ChatModelBase) -> None: + """EXPLORE mode: read-only access, all modifications denied.""" + print("\n" + "=" * 60) + print("Example 1: EXPLORE Mode (Read-Only)") + print("=" * 60) + print(" Mode: EXPLORE — only read-only tools are allowed") + + agent = Agent( + name="DataMuse", + system_prompt=( + "You are DataMuse, a data analysis assistant. Use tools to " + "answer the user's questions. Keep responses concise." + ), + model=model, + toolkit=Toolkit( + tools=[ + Read(), + Glob(), + Grep(), + Bash(), + SalesSummary(), + FunctionTool(query_sales, is_read_only=True), + ], + ), + state=AgentState( + permission_context=PermissionContext( + mode=PermissionMode.EXPLORE, + ), + ), + ) + + # Read-only tools should work + await stream_reply( + agent, + f"Read the first 5 lines of {SALES_CSV} and tell me what " + "columns are available.", + ) + + # Write operations should be denied + await stream_reply( + agent, + "Now create a file /tmp/test.txt with the text 'hello'.", + ) + + +# ========================================================================= +# Example 2: Permission rules +# ========================================================================= +async def example_permission_rules(model: ChatModelBase) -> None: + """Configure Allow and Deny rules for fine-grained control.""" + print("\n" + "=" * 60) + print("Example 2: Permission Rules (Allow + Deny)") + print("=" * 60) + + context = PermissionContext( + mode=PermissionMode.DEFAULT, + allow_rules={ + "Bash": [ + PermissionRule( + tool_name="Bash", + rule_content="python", + behavior=PermissionBehavior.ALLOW, + source="tutorial", + ), + PermissionRule( + tool_name="Bash", + rule_content="cat", + behavior=PermissionBehavior.ALLOW, + source="tutorial", + ), + ], + }, + deny_rules={ + "Bash": [ + PermissionRule( + tool_name="Bash", + rule_content="rm", + behavior=PermissionBehavior.DENY, + source="tutorial", + ), + ], + }, + ) + + print(" Rules configured:") + print(" ALLOW: Bash commands containing 'python' or 'cat'") + print(" DENY: Bash commands containing 'rm'") + print(" Other: Default ASK behavior") + + agent = Agent( + name="DataMuse", + system_prompt=( + "You are DataMuse, a data analysis assistant. Use Bash to run " + "commands. Keep responses concise. " + "If a tool call is denied, explain what happened." + ), + model=model, + toolkit=Toolkit( + tools=[Bash(), Read(), Glob(), SalesSummary()], + ), + state=AgentState(permission_context=context), + ) + + # Allowed: python commands + await stream_reply( + agent, + f"Run a Python one-liner to count the lines in {SALES_CSV}: " + f"python3 -c \"print(sum(1 for _ in open('{SALES_CSV}')))\"", + ) + + # Denied: rm commands + await stream_reply( + agent, + "Run: rm /tmp/test.txt", + ) + + +# ========================================================================= +# Example 3: BYPASS mode (testing/sandbox) +# ========================================================================= +async def example_bypass_mode(model: ChatModelBase) -> None: + """BYPASS mode: skip ASK, while keeping explicit DENY decisions.""" + print("\n" + "=" * 60) + print("Example 3: BYPASS Mode (Testing/Sandbox)") + print("=" * 60) + print(" Mode: BYPASS — skips ASK; explicit rules/tool DENY still apply") + print(" WARNING: Only use in trusted environments!") + + agent = Agent( + name="DataMuse", + system_prompt=( + "You are DataMuse. Use the SalesSummary tool to analyze data. " + "Keep responses concise." + ), + model=model, + toolkit=Toolkit( + tools=[SalesSummary(), Read(), Glob()], + ), + state=AgentState( + permission_context=PermissionContext( + mode=PermissionMode.BYPASS, + ), + ), + ) + + await stream_reply( + agent, + "Show me a summary of the sales data grouped by region.", + ) + + +# ========================================================================= +# Example 4: DONT_ASK mode (unattended) +# ========================================================================= +async def example_dont_ask_mode(model: ChatModelBase) -> None: + """DONT_ASK mode: converts ASK decisions to DENY.""" + print("\n" + "=" * 60) + print("Example 4: DONT_ASK Mode (Unattended Execution)") + print("=" * 60) + print(" Mode: DONT_ASK — ASK decisions become DENY") + print(" Use case: scheduled tasks, background jobs") + + context = PermissionContext( + mode=PermissionMode.DONT_ASK, + allow_rules={ + "Bash": [ + PermissionRule( + tool_name="Bash", + rule_content="python", + behavior=PermissionBehavior.ALLOW, + source="tutorial", + ), + ], + }, + ) + + agent = Agent( + name="DataMuse", + system_prompt=( + "You are DataMuse running in unattended mode. Use tools to " + "answer questions. If a tool is denied, explain why and try " + "an alternative approach. Keep responses concise." + ), + model=model, + toolkit=Toolkit( + tools=[ + Bash(), + Read(), + Glob(), + SalesSummary(), + ], + ), + state=AgentState(permission_context=context), + ) + + # Explicitly allowed command works + await stream_reply( + agent, + f'Run python3 -c "import csv; ' + f"r=csv.reader(open('{SALES_CSV}')); " + f'print(next(r))" to show the CSV header.', + ) + + # Non-allowed commands are auto-denied (no prompt) + await stream_reply( + agent, + "List the files in /tmp using ls -la.", + ) + + +# ========================================================================= +# Example 5: Mode comparison summary +# ========================================================================= +async def example_mode_comparison() -> None: + """Summary of all permission modes.""" + print("\n" + "=" * 60) + print("Example 5: Permission Mode Comparison") + print("=" * 60) + + print( + """ + ┌──────────────┬──────────┬──────────┬──────────┬────────────────┐ + │ Mode │ Read │ Write │ Bash │ Best For │ + ├──────────────┼──────────┼──────────┼──────────┼────────────────┤ + │ DEFAULT │ ASK* │ ASK │ varies │ Interactive │ + │ ACCEPT_EDITS │ ALLOW │ ALLOW* │ varies │ Dev iteration │ + │ EXPLORE │ ALLOW │ DENY │ read-only│ Code browsing │ + │ BYPASS │ ALLOW* │ ALLOW* │ ALLOW* │ Trusted sandbox│ + │ DONT_ASK │ ASK→DENY │ ASK→DENY │ ASK→DENY │ Scheduled jobs │ + └──────────────┴──────────┴──────────┴──────────┴────────────────┘ + * ACCEPT_EDITS allows writes only within working directories + * DEFAULT may accept a tool's explicit ALLOW decision + * BYPASS still honors deny/ask rules and a tool's explicit DENY + + Common start: Deny rules → Ask rules → mode-specific policy + EXPLORE: read-only ALLOW, modification DENY + BYPASS: tool ASK is skipped, fallback ALLOW + DONT_ASK: every ASK path becomes DENY +""", + ) + + +# ========================================================================= +# Main +# ========================================================================= +async def main() -> None: + print("Tutorial 07: Permission System") + print("=" * 60) + + if not SALES_CSV.exists(): + print(f"ERROR: {SALES_CSV} not found.") + print("Run: cd tutorials/data && python generate_sales_data.py") + return + + model = DashScopeChatModel( + credential=DashScopeCredential( + api_key=os.environ["DASHSCOPE_API_KEY"], + ), + model="qwen-plus", + ) + + await example_explore_mode(model) + await example_permission_rules(model) + await example_bypass_mode(model) + await example_dont_ask_mode(model) + await example_mode_comparison() + + print("\n" + "=" * 60) + print("Tutorial 07 complete! Next: Tutorial 08 — Human-in-the-Loop") + print("=" * 60) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tutorials/08_human_in_the_loop/README.md b/tutorials/08_human_in_the_loop/README.md new file mode 100644 index 0000000..d424d28 --- /dev/null +++ b/tutorials/08_human_in_the_loop/README.md @@ -0,0 +1,179 @@ +# Tutorial 08: Human-in-the-Loop — 人机协作 + +> **什么时候需要这个?** T07 的权限引擎已经会返回 ASK 了,本章告诉你 ASK 之后怎么办——如何实现"暂停 → 把待确认的工具调用展示给用户 → 收到回复 → 恢复执行"的完整循环。同样的机制也适用于"外部工具"场景(发邮件、上线服务等需要外部系统执行的操作)。 + +## 本章基于前序章节 + +- **T02 — Event 系统**:本章的 `RequireUserConfirmEvent` / `UserConfirmResultEvent` 都是 T02 介绍过的事件类型。 +- **T07 — 权限 ASK 行为 / `PermissionRule`**:ASK 是触发 HITL 的来源;`ConfirmResult.rules` 用来实现渐进式信任。 + +## 你将学到 + +- 两种暂停场景:用户确认(ASK)和外部执行(External Tool) +- `RequireUserConfirmEvent` → `UserConfirmResultEvent` 确认流程 +- `RequireExternalExecutionEvent` → `ExternalExecutionResultEvent` 外部执行流程 +- `ConfirmResult` 的构造和 `suggested_rules` 的使用 +- 如何实现一个终端交互确认界面 + +## 前置要求 + +- 完成 Tutorial 07 +- 理解权限系统的 ASK 行为 + +## 核心概念 + +### Agent 的暂停与恢复 + +在 Tutorial 07 中,我们看到权限检查可能返回 ASK——需要用户确认。当这发生时,Agent 的 `reply_stream()` 会 yield 一个特殊事件,然后**暂停执行**,等待外部输入后恢复。 + +AgentScope 支持两种暂停场景: + +``` +场景 1: 用户确认(ASK) +───────────────────── +Agent 想执行工具 → 权限 ASK → yield RequireUserConfirmEvent + → 暂停,等待用户输入 + → 用户确认/拒绝 → 构造 UserConfirmResultEvent + → 调用 reply_stream(event) 恢复 Agent + +场景 2: 外部执行(External Tool) +────────────────────────────── +Agent 调用外部工具 → yield RequireExternalExecutionEvent + → 暂停,等待外部执行结果 + → 外部系统执行完毕 → 构造 ExternalExecutionResultEvent + → 调用 reply_stream(event) 恢复 Agent +``` + +### RequireUserConfirmEvent + +当权限引擎返回 ASK 时,Agent 会 yield 此事件: + +```python +class RequireUserConfirmEvent: + type: EventType.REQUIRE_USER_CONFIRM + reply_id: str # 关联的回复 ID + tool_calls: list[ToolCallBlock] # 待确认的工具调用 +``` + +`tool_calls` 列表中的每个 `ToolCallBlock` 包含工具名称和输入参数。 + +### ConfirmResult + +用户确认后,需要构造 `ConfirmResult` 对象: + +```python +from agentscope.event import ConfirmResult + +# 确认执行 +result = ConfirmResult( + confirmed=True, + tool_call=tool_call_block, + rules=None, # 可选:接受建议的权限规则 +) + +# 拒绝执行 +result = ConfirmResult( + confirmed=False, + tool_call=tool_call_block, +) +``` + +`rules` 字段允许用户在确认时附带权限规则,实现**渐进式信任**: + +```python +from agentscope.permission import PermissionRule, PermissionBehavior + +result = ConfirmResult( + confirmed=True, + tool_call=tool_call_block, + rules=[ + PermissionRule( + tool_name="Bash", + rule_content="python", + behavior=PermissionBehavior.ALLOW, + source="user_confirm", + ), + ], +) +``` + +### UserConfirmResultEvent + +将 `ConfirmResult` 包装为事件,传回 Agent: + +```python +from agentscope.event import UserConfirmResultEvent + +event = UserConfirmResultEvent( + reply_id=require_event.reply_id, + confirm_results=[result1, result2, ...], +) + +# 恢复 Agent 执行 +async for event in agent.reply_stream(event): + ... +``` + +### External Tool(外部工具) + +当 `ToolBase.is_external_tool = True` 时,Agent 不会执行工具的 `call()`,而是 yield `RequireExternalExecutionEvent`,等待外部系统提供执行结果: + +```python +class ExternalExecutionResultEvent: + type: EventType.EXTERNAL_EXECUTION_RESULT + reply_id: str + execution_results: list[ToolResultBlock] +``` + +典型场景:发送邮件、部署服务、调用需要人工操作的 API。 + +### 完整交互循环 + +```python +async for event in agent.reply_stream(user_msg): + match event.type: + case EventType.REQUIRE_USER_CONFIRM: + # 展示待确认的工具调用给用户 + # 获取用户的确认/拒绝 + confirm_event = build_confirm_event(event) + # 恢复 Agent + async for evt in agent.reply_stream(confirm_event): + handle_event(evt) + + case EventType.REQUIRE_EXTERNAL_EXECUTION: + # 外部系统执行工具 + results = await execute_externally(event.tool_calls) + exec_event = build_execution_event(event, results) + # 恢复 Agent + async for evt in agent.reply_stream(exec_event): + handle_event(evt) + + case EventType.TEXT_BLOCK_DELTA: + print(event.delta, end="") +``` + +## 示例:交互式确认界面 + +本期实现一个终端交互式确认 UI,展示: + +1. **用户确认场景**:Agent 想执行 Bash 命令,权限要求确认,用户选择同意或拒绝 +2. **外部执行场景**:Agent 调用一个外部工具,外部系统提供执行结果 +3. **渐进式信任**:确认时接受建议规则,后续同类操作自动允许 + +## 运行示例 + +```bash +cd tutorials/08_human_in_the_loop +python main.py +``` + +## 进一步探索 + +- 实现一个 Web UI 的确认界面(使用 SSE 推送确认请求) +- 创建一个自定义外部工具(如"发送邮件"),模拟外部执行流程 +- 将 HITL 与 Permission 规则结合:第一次确认后自动添加 Allow 规则 +- 尝试部分确认:同一批次的多个工具调用中,只确认部分 + +## 下一期预告 + +**Tutorial 09: 流式 UI** — 利用完整的 Event 系统构建一个功能丰富的终端 UI,包括进度指示、token 统计和折叠展示。 diff --git a/tutorials/08_human_in_the_loop/main.py b/tutorials/08_human_in_the_loop/main.py new file mode 100644 index 0000000..d33f7fe --- /dev/null +++ b/tutorials/08_human_in_the_loop/main.py @@ -0,0 +1,473 @@ +# -*- coding: utf-8 -*- +"""Tutorial 08: Human-in-the-Loop — Confirmation & external execution. + +This tutorial demonstrates: +- Handling RequireUserConfirmEvent for tool call confirmation +- Building ConfirmResult with optional permission rules +- RequireExternalExecutionEvent for external tool execution +- Progressive trust via suggested_rules +- A terminal-based interactive confirmation UI +""" +# pylint: disable=missing-function-docstring,unused-argument +import asyncio +import csv +import os +from pathlib import Path +from typing import Any + +from agentscope.agent import Agent +from agentscope.credential import DashScopeCredential +from agentscope.event import ( + EventType, + ConfirmResult, + UserConfirmResultEvent, + RequireUserConfirmEvent, + RequireExternalExecutionEvent, + ExternalExecutionResultEvent, +) +from agentscope.model import DashScopeChatModel +from agentscope.message import ( + UserMsg, + TextBlock, + ToolResultBlock, + ToolResultState, +) +from agentscope.permission import ( + PermissionBehavior, + PermissionContext, + PermissionDecision, + PermissionMode, +) +from agentscope.state import AgentState +from agentscope.tool import ( + Toolkit, + ToolBase, + ToolChunk, + FunctionTool, + Bash, + Read, + Glob, + Grep, +) + +DATA_DIR = Path(__file__).resolve().parent.parent / "data" +SALES_CSV = DATA_DIR / "sales_data.csv" + + +# ========================================================================= +# External tool: simulates an action that requires external execution +# ========================================================================= +class SendReport(ToolBase): + """External tool that 'sends' a report via email.""" + + name = "SendReport" + description = ( + "Send an analysis report to a specified email address. " + "This is an external tool — execution happens outside the Agent." + ) + input_schema = { + "type": "object", + "properties": { + "recipient": { + "type": "string", + "description": "Email address of the recipient.", + }, + "subject": { + "type": "string", + "description": "Email subject line.", + }, + "body": { + "type": "string", + "description": "Email body content.", + }, + }, + "required": ["recipient", "subject", "body"], + } + is_concurrency_safe = True + is_read_only = False + is_external_tool = True + + async def check_permissions( + self, + tool_input: dict[str, Any], + context: PermissionContext, + ) -> PermissionDecision: + return PermissionDecision( + behavior=PermissionBehavior.ALLOW, + message="External tool, permission handled externally.", + ) + + async def call(self, **kwargs: Any) -> ToolChunk: + raise RuntimeError("External tools should not be called directly.") + + +# ========================================================================= +# Query tool (read-only, auto-allowed) +# ========================================================================= +def query_sales(category: str = "", limit: int = 5) -> ToolChunk: + """Query the sales dataset. + + Args: + category: Product category to filter. Empty means no filter. + limit: Maximum number of rows to return. + """ + rows = [] + with open(SALES_CSV, "r", encoding="utf-8") as f: + for row in csv.DictReader(f): + if category and row["category"] != category: + continue + rows.append(row) + if len(rows) >= limit: + break + if not rows: + return ToolChunk( + content=[TextBlock(text="No matching records found.")], + ) + header = " | ".join(rows[0].keys()) + lines = [header, "-" * len(header)] + for row in rows: + lines.append(" | ".join(row.values())) + return ToolChunk( + content=[ + TextBlock(text=f"Found {len(rows)} records:\n" + "\n".join(lines)), + ], + ) + + +# ========================================================================= +# Interactive event handler +# ========================================================================= +async def handle_events_with_hitl(agent: Agent, content: str) -> None: + """Process agent events with human-in-the-loop confirmation. + + This function demonstrates the complete HITL flow: + 1. Stream events from agent.reply_stream() + 2. When REQUIRE_USER_CONFIRM: prompt user and resume with confirmation + 3. When REQUIRE_EXTERNAL_EXECUTION: simulate external execution + """ + msg = UserMsg(name="user", content=content) + print(f"\n[User]: {content[:100]}{'...' if len(content) > 100 else ''}") + print("\n[DataMuse]: ", end="", flush=True) + + async def process_stream(stream): + """Process an event stream, handling HITL events recursively.""" + async for event in stream: + match event.type: + case EventType.TEXT_BLOCK_DELTA: + print(event.delta, end="", flush=True) + + case EventType.TOOL_CALL_START: + print(f"\n >> Calling: {event.tool_call_name}") + + case EventType.TOOL_RESULT_END: + print(f" >> Result: {event.state}") + + case EventType.REQUIRE_USER_CONFIRM: + await handle_user_confirm(agent, event) + + case EventType.REQUIRE_EXTERNAL_EXECUTION: + await handle_external_execution(agent, event) + + case EventType.REPLY_END: + print() + + await process_stream(agent.reply_stream(msg)) + + +async def handle_user_confirm( + agent: Agent, + event: RequireUserConfirmEvent, +) -> None: + """Handle a user confirmation request. + + Shows the pending tool calls and asks the user to confirm or deny each one. + """ + print("\n" + "─" * 40) + print(" CONFIRMATION REQUIRED") + print("─" * 40) + + confirm_results = [] + for tool_call in event.tool_calls: + print(f" Tool: {tool_call.name}") + print(f" Input: {tool_call.input[:100]}...") + + if tool_call.suggested_rules: + print(" Suggested rules:") + for rule in tool_call.suggested_rules: + print( + f" → {rule.tool_name}: {rule.rule_content} " + f"({rule.behavior.value})", + ) + + # Auto-confirm for this tutorial (in a real app, ask the user) + print(" → [Auto-confirming for tutorial demo]") + confirmed = True + + if confirmed: + confirm_results.append( + ConfirmResult( + confirmed=True, + tool_call=tool_call, + rules=tool_call.suggested_rules or None, + ), + ) + else: + confirm_results.append( + ConfirmResult( + confirmed=False, + tool_call=tool_call, + ), + ) + + print("─" * 40) + + # Resume agent with confirmation results + confirm_event = UserConfirmResultEvent( + reply_id=event.reply_id, + confirm_results=confirm_results, + ) + + async for evt in agent.reply_stream(confirm_event): + match evt.type: + case EventType.TEXT_BLOCK_DELTA: + print(evt.delta, end="", flush=True) + case EventType.TOOL_CALL_START: + print(f"\n >> Calling: {evt.tool_call_name}") + case EventType.TOOL_RESULT_END: + print(f" >> Result: {evt.state}") + case EventType.REQUIRE_USER_CONFIRM: + await handle_user_confirm(agent, evt) + case EventType.REQUIRE_EXTERNAL_EXECUTION: + await handle_external_execution(agent, evt) + case EventType.REPLY_END: + print() + + +async def handle_external_execution( + agent: Agent, + event: RequireExternalExecutionEvent, +) -> None: + """Handle an external execution request. + + Simulates executing the tool externally and returning results. + """ + print("\n" + "─" * 40) + print(" EXTERNAL EXECUTION") + print("─" * 40) + + execution_results = [] + for tool_call in event.tool_calls: + print(f" Tool: {tool_call.name}") + print(f" Input: {tool_call.input[:100]}...") + print(" → [Simulating external execution...]") + + # Simulate external execution result + result = ToolResultBlock( + id=tool_call.id, + name=tool_call.name, + output=f"[External] Successfully executed {tool_call.name}. " + f"Report sent to the specified recipient.", + state=ToolResultState.SUCCESS, + ) + execution_results.append(result) + + print("─" * 40) + + # Resume agent with execution results + exec_event = ExternalExecutionResultEvent( + reply_id=event.reply_id, + execution_results=execution_results, + ) + + async for evt in agent.reply_stream(exec_event): + match evt.type: + case EventType.TEXT_BLOCK_DELTA: + print(evt.delta, end="", flush=True) + case EventType.TOOL_CALL_START: + print(f"\n >> Calling: {evt.tool_call_name}") + case EventType.TOOL_RESULT_END: + print(f" >> Result: {evt.state}") + case EventType.REQUIRE_USER_CONFIRM: + await handle_user_confirm(agent, evt) + case EventType.REQUIRE_EXTERNAL_EXECUTION: + await handle_external_execution(agent, evt) + case EventType.REPLY_END: + print() + + +# ========================================================================= +# Example 1: User confirmation flow +# ========================================================================= +async def example_user_confirmation(model) -> None: + """Demonstrate the user confirmation flow.""" + print("\n" + "=" * 60) + print("Example 1: User Confirmation (ASK → Confirm)") + print("=" * 60) + print(" Mode: DEFAULT — Bash commands require confirmation") + + agent = Agent( + name="DataMuse", + system_prompt=( + "You are DataMuse, a data analysis assistant. Use tools to " + "answer questions. Keep responses concise." + ), + model=model, + toolkit=Toolkit( + tools=[ + Bash(), + Read(), + Glob(), + Grep(), + FunctionTool(query_sales, is_read_only=True), + ], + ), + state=AgentState( + permission_context=PermissionContext( + mode=PermissionMode.DEFAULT, + ), + ), + ) + + # This should trigger a user confirmation for the Bash tool + await handle_events_with_hitl( + agent, + f"Count the number of lines in {SALES_CSV} using the wc command.", + ) + + +# ========================================================================= +# Example 2: External tool execution +# ========================================================================= +async def example_external_execution(model) -> None: + """Demonstrate the external tool execution flow.""" + print("\n" + "=" * 60) + print("Example 2: External Tool Execution") + print("=" * 60) + print(" SendReport is an external tool — Agent yields, we execute") + + agent = Agent( + name="DataMuse", + system_prompt=( + "You are DataMuse, a data analysis assistant. You can send " + "analysis reports via the SendReport tool. " + "Keep responses concise." + ), + model=model, + toolkit=Toolkit( + tools=[ + Read(), + FunctionTool(query_sales, is_read_only=True), + SendReport(), + ], + ), + state=AgentState( + permission_context=PermissionContext( + mode=PermissionMode.BYPASS, + ), + ), + ) + + await handle_events_with_hitl( + agent, + "Send a brief sales summary report to analyst@example.com " + "with subject 'Weekly Sales Report'.", + ) + + +# ========================================================================= +# Example 3: HITL flow diagram +# ========================================================================= +async def example_flow_diagram() -> None: + """Display the HITL interaction flow.""" + print("\n" + "=" * 60) + print("Example 3: HITL Interaction Flows") + print("=" * 60) + + print( + """ + Flow 1: User Confirmation + ───────────────────────── + reply_stream(UserMsg) + │ + ├─ TEXT_BLOCK_DELTA ──── stream text to UI + ├─ TOOL_CALL_START ───── show tool being called + │ + ├─ REQUIRE_USER_CONFIRM ← Agent pauses here + │ │ + │ ├─ Show tool_calls to user + │ ├─ User confirms/denies + │ └─ Build UserConfirmResultEvent + │ │ + │ └─ reply_stream(confirm_event) + │ ├─ TOOL_RESULT_END ─── tool executed (if confirmed) + │ ├─ TEXT_BLOCK_DELTA ── continue streaming + │ └─ REPLY_END ──────── done + │ + └─ REPLY_END (if no confirmation needed) + + Flow 2: External Execution + ────────────────────────── + reply_stream(UserMsg) + │ + ├─ REQUIRE_EXTERNAL_EXECUTION ← Agent pauses here + │ │ + │ ├─ Extract tool_calls + │ ├─ Execute externally (API call, human action, etc.) + │ ├─ Build ToolResultBlock for each + │ └─ Build ExternalExecutionResultEvent + │ │ + │ └─ reply_stream(exec_event) + │ ├─ TEXT_BLOCK_DELTA ── Agent processes results + │ └─ REPLY_END ──────── done + + Progressive Trust (Suggested Rules) + ──────────────────────────────────── + When confirming, you can accept suggested_rules: + + ConfirmResult( + confirmed=True, + tool_call=tc, + rules=tc.suggested_rules, ← Accept rules + ) + + This adds Allow rules to PermissionContext, so similar + operations are auto-allowed in future calls. +""", + ) + + +# ========================================================================= +# Main +# ========================================================================= +async def main() -> None: + print("Tutorial 08: Human-in-the-Loop") + print("=" * 60) + + if not SALES_CSV.exists(): + print(f"ERROR: {SALES_CSV} not found.") + print("Run: cd tutorials/data && python generate_sales_data.py") + return + + model = DashScopeChatModel( + credential=DashScopeCredential( + api_key=os.environ["DASHSCOPE_API_KEY"], + ), + model="qwen-plus", + ) + + # Example 1: User confirmation + await example_user_confirmation(model) + + # Example 2: External tool execution + await example_external_execution(model) + + # Example 3: Flow diagram + await example_flow_diagram() + + print("\n" + "=" * 60) + print("Tutorial 08 complete! Next: Tutorial 09 — Streaming UI") + print("=" * 60) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tutorials/09_streaming_ui/README.md b/tutorials/09_streaming_ui/README.md new file mode 100644 index 0000000..21ca10d --- /dev/null +++ b/tutorials/09_streaming_ui/README.md @@ -0,0 +1,148 @@ +# Tutorial 09: 流式 UI — 构建实时交互界面 + +> **什么时候需要这个?** 你要做真正的实时交互界面(终端 TUI、Web 聊天等),需要把文本、多模态数据、思考过程、工具调用、token 统计和 HITL 确认组织成一套连贯的视觉体验。 + +## 本章基于前序章节 + +- **T02 — Event 系统全景与 `start → delta → end` 模式**:本章把 T02 介绍的事件类型扩展成完整的 UI 渲染方案。 +- **T03 — `TOOL_CALL_*` / `TOOL_RESULT_*` 事件**:UI 里"工具卡片"和"结果摘要"的数据来源。 +- **T08 — `REQUIRE_USER_CONFIRM` / `REQUIRE_EXTERNAL_EXECUTION`**:在 UI 中如何整合 HITL 事件。 + +## 你将学到 + +- Event 类型全景及其 start → delta → end 生命周期 +- `reply_id` 和 `block_id` 的关联关系 +- Token 用量追踪(`ModelCallEndEvent`) +- 如何用事件分发构建一个功能丰富的终端 UI +- HITL 事件在 UI 中的整合处理 + +## 前置要求 + +- 完成 Tutorial 08 +- 理解 Event 系统(Tutorial 02 回顾) + +## 核心概念 + +### Event 类型全景 + +AgentScope 的事件系统涵盖 Agent 执行的每个阶段: + +``` +Reply 级别 +├─ REPLY_START ── 回复开始(包含 session_id, reply_id, name) +└─ REPLY_END ── 回复结束 + +Model 调用 +├─ MODEL_CALL_START ── 模型调用开始(model_name) +└─ MODEL_CALL_END ── 模型调用结束(input_tokens, output_tokens) + +文本块 +├─ TEXT_BLOCK_START ── 文本开始(block_id) +├─ TEXT_BLOCK_DELTA ── 文本增量(delta) +└─ TEXT_BLOCK_END ── 文本结束 + +数据块 +├─ DATA_BLOCK_START ── 图片、音频等数据开始(media_type) +├─ DATA_BLOCK_DELTA ── base64 数据增量 +└─ DATA_BLOCK_END ── 数据结束 + +思考块 +├─ THINKING_BLOCK_START ── 思考开始 +├─ THINKING_BLOCK_DELTA ── 思考增量 +└─ THINKING_BLOCK_END ── 思考结束 + +一次性提示 +└─ HINT_BLOCK ── Team 消息、后台结果等完整提示 + +工具调用 +├─ TOOL_CALL_START ── 开始调用(tool_call_name) +├─ TOOL_CALL_DELTA ── 参数增量(JSON 片段) +└─ TOOL_CALL_END ── 调用结束 + +工具结果 +├─ TOOL_RESULT_START ── 结果开始 +├─ TOOL_RESULT_TEXT_DELTA ── 文本结果增量 +├─ TOOL_RESULT_DATA_DELTA ── 二进制数据增量 +└─ TOOL_RESULT_END ── 结果结束(state: success/error/denied) + +HITL 事件 +├─ REQUIRE_USER_CONFIRM ── 需要用户确认 +├─ REQUIRE_EXTERNAL_EXECUTION ── 需要外部执行 +├─ USER_CONFIRM_RESULT ── 用户确认结果 +├─ EXTERNAL_EXECUTION_RESULT ── 外部执行结果 +└─ USER_INTERRUPT ── 用户中止一个等待恢复的回复 + +其他 +├─ EXCEED_MAX_ITERS ── 超过最大迭代次数 +└─ CUSTOM ── 服务或应用自定义的扩展事件 +``` + +`USER_CONFIRM_RESULT`、`EXTERNAL_EXECUTION_RESULT` 和 `USER_INTERRUPT` 通常是 UI 传回 `reply_stream()`、用于恢复或中止 parked reply 的输入事件,不一定会出现在一次普通回复的输出流里。UI 仍应认识它们,并对未知 `CUSTOM.name` 或未来新增事件做安全降级。 + +### Token 用量追踪 + +`ModelCallEndEvent` 包含 token 使用信息: + +```python +case EventType.MODEL_CALL_END: + print(f"Input: {event.input_tokens}, Output: {event.output_tokens}") +``` + +累计多次模型调用的 token,可以用于成本估算。 + +### UI 设计模式 + +流式 UI 的核心是**事件分发 + 状态管理**: + +```python +total_input_tokens = 0 +total_output_tokens = 0 + +async for event in agent.reply_stream(msg): + match event.type: + case EventType.REPLY_START: + # 初始化 UI 状态 + case EventType.TEXT_BLOCK_DELTA: + # 实时渲染文本 + case EventType.THINKING_BLOCK_DELTA: + # 折叠显示思考过程 + case EventType.TOOL_CALL_START: + # 显示工具调用指示器 + case EventType.TOOL_RESULT_END: + # 显示执行结果摘要 + case EventType.MODEL_CALL_END: + # 累计 token 用量 + total_input_tokens += event.input_tokens + total_output_tokens += event.output_tokens + case EventType.REPLY_END: + # 显示最终统计 +``` + +## 示例:终端流式 UI + +本期实现一个完整的终端 UI,展示: + +1. 实时流式文本输出 +2. 思考过程(带前缀标识) +3. 工具调用进度指示 +4. 工具结果摘要 +5. Token 用量统计 +6. 多轮 ReAct 循环的可视化 + +## 运行示例 + +```bash +cd tutorials/09_streaming_ui +python main.py +``` + +## 进一步探索 + +- 用 `rich` 库替换 print,实现彩色输出和进度条 +- 添加工具结果的折叠/展开功能 +- 统计每次模型调用的耗时(利用 `MODEL_CALL_START` 和 `MODEL_CALL_END` 的时间差) +- 将 HITL 事件集成到 UI 中,实现交互式确认 + +## 下一期预告 + +**Tutorial 10: Context 管理** — 处理长对话和大工具结果,配置上下文压缩策略。 diff --git a/tutorials/09_streaming_ui/main.py b/tutorials/09_streaming_ui/main.py new file mode 100644 index 0000000..a3bc559 --- /dev/null +++ b/tutorials/09_streaming_ui/main.py @@ -0,0 +1,473 @@ +# -*- coding: utf-8 -*- +"""Tutorial 09: Streaming UI — Build a real-time interactive terminal UI. + +This tutorial demonstrates: +- Handling all event types from reply_stream +- Token usage tracking via ModelCallEndEvent +- Thinking block visualization +- Tool call progress indicators +- Building a complete event-driven terminal UI +""" +# pylint: disable=missing-function-docstring,unused-argument +import asyncio +import csv +import os +import time +from pathlib import Path +from typing import Any + +from agentscope.agent import Agent +from agentscope.credential import DashScopeCredential +from agentscope.event import EventType +from agentscope.message import UserMsg, TextBlock +from agentscope.model import DashScopeChatModel +from agentscope.permission import ( + PermissionBehavior, + PermissionContext, + PermissionDecision, + PermissionMode, +) +from agentscope.state import AgentState +from agentscope.tool import ( + Toolkit, + ToolBase, + ToolChunk, + FunctionTool, + Read, + Glob, + Grep, +) + +DATA_DIR = Path(__file__).resolve().parent.parent / "data" +SALES_CSV = DATA_DIR / "sales_data.csv" + + +# ========================================================================= +# Custom tools +# ========================================================================= +def query_sales(category: str = "", limit: int = 5) -> ToolChunk: + """Query the sales dataset. + + Args: + category: Product category to filter. Empty means no filter. + limit: Maximum number of rows to return. + """ + rows = [] + with open(SALES_CSV, "r", encoding="utf-8") as f: + for row in csv.DictReader(f): + if category and row["category"] != category: + continue + rows.append(row) + if len(rows) >= limit: + break + if not rows: + return ToolChunk( + content=[TextBlock(text="No matching records found.")], + ) + header = " | ".join(rows[0].keys()) + lines = [header, "-" * len(header)] + for row in rows: + lines.append(" | ".join(row.values())) + return ToolChunk( + content=[ + TextBlock(text=f"Found {len(rows)} records:\n" + "\n".join(lines)), + ], + ) + + +class SalesSummary(ToolBase): + """Compute aggregate statistics on the sales dataset.""" + + name = "SalesSummary" + description = "Compute summary statistics for the sales dataset." + input_schema = { + "type": "object", + "properties": { + "group_by": { + "type": "string", + "description": "Column to group by. Empty for overall.", + "default": "", + }, + }, + "required": [], + } + is_concurrency_safe = True + is_read_only = True + + async def check_permissions( + self, + tool_input: dict[str, Any], + context: PermissionContext, + ) -> PermissionDecision: + return PermissionDecision( + behavior=PermissionBehavior.ALLOW, + message="Read-only analytics, always allowed.", + ) + + async def call(self, group_by: str = "") -> ToolChunk: + rows = [] + with open(SALES_CSV, "r", encoding="utf-8") as f: + for row in csv.DictReader(f): + rows.append(row) + + if not group_by: + total = sum(float(r["total"]) for r in rows) + avg = total / len(rows) if rows else 0 + text = ( + f"Overall: {len(rows)} orders, " + f"${total:,.2f} revenue, ${avg:,.2f} avg" + ) + return ToolChunk(content=[TextBlock(text=text)]) + + groups: dict[str, list] = {} + for row in rows: + groups.setdefault(row.get(group_by, "?"), []).append(row) + + lines = [f"Summary by '{group_by}':"] + for key in sorted(groups): + g = groups[key] + rev = sum(float(r["total"]) for r in g) + lines.append(f" {key}: {len(g)} orders, ${rev:,.2f}") + return ToolChunk(content=[TextBlock(text="\n".join(lines))]) + + +# ========================================================================= +# Streaming UI +# ========================================================================= +async def streaming_ui(agent: Agent, content: str) -> dict: + """Full-featured streaming UI with event tracking. + + Returns a stats dict with token counts and event counts. + """ + msg = UserMsg(name="user", content=content) + + stats = { + "input_tokens": 0, + "output_tokens": 0, + "model_calls": 0, + "tool_calls": 0, + "text_blocks": 0, + "data_blocks": 0, + "thinking_blocks": 0, + "events": 0, + } + model_call_start_time = None + + print(f"\n{'─' * 50}") + print(f" [User]: {content[:80]}{'...' if len(content) > 80 else ''}") + print(f"{'─' * 50}") + + async for event in agent.reply_stream(msg): + stats["events"] += 1 + + match event.type: + # --- Reply lifecycle --- + case EventType.REPLY_START: + print(f"\n [{event.name}]:", end="", flush=True) + + case EventType.REPLY_END: + print() + + # --- Model calls --- + case EventType.MODEL_CALL_START: + model_call_start_time = time.time() + stats["model_calls"] += 1 + + case EventType.MODEL_CALL_END: + elapsed = ( + time.time() - model_call_start_time + if model_call_start_time + else 0 + ) + stats["input_tokens"] += event.input_tokens + stats["output_tokens"] += event.output_tokens + print( + f"\n [model] {event.input_tokens}in " + f"+ {event.output_tokens}out " + f"({elapsed:.1f}s)", + end="", + flush=True, + ) + model_call_start_time = None + + # --- Text blocks --- + case EventType.TEXT_BLOCK_START: + stats["text_blocks"] += 1 + print("\n ", end="", flush=True) + + case EventType.TEXT_BLOCK_DELTA: + print(event.delta, end="", flush=True) + + case EventType.TEXT_BLOCK_END: + pass + + # --- Data blocks (image/audio/file payloads) --- + case EventType.DATA_BLOCK_START: + stats["data_blocks"] += 1 + print( + f"\n [data] receiving {event.media_type}", + end="", + flush=True, + ) + + case EventType.DATA_BLOCK_DELTA: + pass # do not print base64 payloads in a terminal UI + + case EventType.DATA_BLOCK_END: + print(" [received]", end="", flush=True) + + # --- Thinking blocks --- + case EventType.THINKING_BLOCK_START: + stats["thinking_blocks"] += 1 + print("\n [thinking] ", end="", flush=True) + + case EventType.THINKING_BLOCK_DELTA: + # Show abbreviated thinking + text = event.delta.replace("\n", " ") + if len(text) > 60: + text = text[:60] + "..." + print(text, end="", flush=True) + + case EventType.THINKING_BLOCK_END: + print() + + # --- One-shot context hint --- + case EventType.HINT_BLOCK: + hint = str(event.hint).replace("\n", " ") + if len(hint) > 80: + hint = hint[:80] + "..." + print( + f"\n [hint from {event.source or 'system'}] {hint}", + end="", + flush=True, + ) + + # --- Tool calls --- + case EventType.TOOL_CALL_START: + stats["tool_calls"] += 1 + print( + f"\n [tool] >> {event.tool_call_name}", + end="", + flush=True, + ) + + case EventType.TOOL_CALL_DELTA: + pass # suppress raw JSON fragments + + case EventType.TOOL_CALL_END: + pass + + # --- Tool results --- + case EventType.TOOL_RESULT_START: + print(" (executing...)", end="", flush=True) + + case EventType.TOOL_RESULT_TEXT_DELTA: + pass # suppress verbose tool output + + case EventType.TOOL_RESULT_DATA_DELTA: + pass # a graphical UI could render event.data or event.url + + case EventType.TOOL_RESULT_END: + state_icon = "ok" if event.state == "success" else event.state + print(f" [{state_icon}]", end="", flush=True) + + # --- HITL --- + case EventType.REQUIRE_USER_CONFIRM: + print( + f"\n [hitl] Confirmation required for " + f"{len(event.tool_calls)} tool(s)", + ) + + case EventType.REQUIRE_EXTERNAL_EXECUTION: + print( + f"\n [hitl] External execution for " + f"{len(event.tool_calls)} tool(s)", + ) + + # These events are normally passed back into reply_stream() to + # resume a parked reply, rather than emitted by a normal run. + case EventType.USER_CONFIRM_RESULT: + print("\n [resume] User confirmation received") + + case EventType.EXTERNAL_EXECUTION_RESULT: + print("\n [resume] External execution result received") + + case EventType.USER_INTERRUPT: + print("\n [interrupt] User stopped the parked reply") + + # --- Service/application extension event --- + case EventType.CUSTOM: + print( + f"\n [custom:{event.name}] {event.value}", + end="", + flush=True, + ) + + # --- Max iterations --- + case EventType.EXCEED_MAX_ITERS: + print("\n [warn] Max iterations exceeded!") + + case _: + print(f"\n [event] {event.type}", end="", flush=True) + + # Print summary + print(f"\n{'─' * 50}") + print(" Stats:") + print( + f" Tokens: {stats['input_tokens']} in " + f"+ {stats['output_tokens']} out " + f"= {stats['input_tokens'] + stats['output_tokens']} total", + ) + print( + f" Model calls: {stats['model_calls']} | " + f"Tool calls: {stats['tool_calls']}", + ) + print( + f" Text blocks: {stats['text_blocks']} | " + f"Data blocks: {stats['data_blocks']} | " + f"Thinking blocks: {stats['thinking_blocks']}", + ) + print(f" Total events: {stats['events']}") + print(f"{'─' * 50}") + + return stats + + +# ========================================================================= +# Examples +# ========================================================================= +async def example_basic_streaming(agent: Agent) -> None: + """Basic streaming with all event types visible.""" + print("\n" + "=" * 60) + print("Example 1: Basic Streaming UI") + print("=" * 60) + + await streaming_ui( + agent, + f"Read the first 3 lines of {SALES_CSV}, then use SalesSummary " + f"to show a summary grouped by category.", + ) + + +async def example_multi_turn(agent: Agent) -> None: + """Multi-turn conversation showing cumulative token tracking.""" + print("\n" + "=" * 60) + print("Example 2: Multi-Turn Token Tracking") + print("=" * 60) + + total_tokens = {"input": 0, "output": 0} + + for i, question in enumerate( + [ + "What categories are in the sales data? Use query_sales " + "with limit=3.", + "Now show me the summary grouped by region.", + ], + 1, + ): + print(f"\n Turn {i}:") + stats = await streaming_ui(agent, question) + total_tokens["input"] += stats["input_tokens"] + total_tokens["output"] += stats["output_tokens"] + + print(f"\n Cumulative tokens across {2} turns:") + print( + f" Input: {total_tokens['input']} | " + f"Output: {total_tokens['output']} | " + f"Total: {total_tokens['input'] + total_tokens['output']}", + ) + + +async def example_event_catalog() -> None: + """Display all event types organized by category.""" + print("\n" + "=" * 60) + print("Example 3: Event Type Catalog") + print("=" * 60) + + print( + """ + All AgentScope Event Types: + ─────────────────────────── + + Reply Lifecycle Model Calls Text Blocks + ├─ REPLY_START ├─ MODEL_CALL_START ├─ TEXT_BLOCK_START + └─ REPLY_END └─ MODEL_CALL_END ├─ TEXT_BLOCK_DELTA + (tokens tracking) └─ TEXT_BLOCK_END + + Thinking Blocks Tool Calls Tool Results + ├─ THINKING_..._START ├─ TOOL_CALL_START ├─ TOOL_RESULT_START + ├─ THINKING_..._DELTA ├─ TOOL_CALL_DELTA ├─ TOOL_RESULT_TEXT_DELTA + └─ THINKING_..._END └─ TOOL_CALL_END ├─ TOOL_RESULT_DATA_DELTA + └─ TOOL_RESULT_END + + Data Blocks HITL Events Other + ├─ DATA_BLOCK_START ├─ REQUIRE_USER_ └─ EXCEED_MAX_ITERS + ├─ DATA_BLOCK_DELTA │ CONFIRM + └─ DATA_BLOCK_END ├─ REQUIRE_EXTERNAL_ + │ EXECUTION + ├─ USER_CONFIRM_ + │ RESULT + └─ EXTERNAL_EXECUTION_ + RESULT + + Lifecycle pattern: START → DELTA(s) → END + Each event has: id, created_at, type, reply_id + ModelCallEnd adds: input_tokens, output_tokens + ToolResultEnd adds: state (success/error/denied/interrupted) +""", + ) + + +# ========================================================================= +# Main +# ========================================================================= +async def main() -> None: + print("Tutorial 09: Streaming UI") + print("=" * 60) + + if not SALES_CSV.exists(): + print(f"ERROR: {SALES_CSV} not found.") + print("Run: cd tutorials/data && python generate_sales_data.py") + return + + model = DashScopeChatModel( + credential=DashScopeCredential( + api_key=os.environ["DASHSCOPE_API_KEY"], + ), + model="qwen-plus", + ) + + agent = Agent( + name="DataMuse", + system_prompt=( + "You are DataMuse, a data analysis assistant. Use tools to " + "answer questions about the sales data. Keep responses concise." + ), + model=model, + toolkit=Toolkit( + tools=[ + Read(), + Glob(), + Grep(), + FunctionTool(query_sales, is_read_only=True), + SalesSummary(), + ], + ), + state=AgentState( + permission_context=PermissionContext( + mode=PermissionMode.BYPASS, + ), + ), + ) + + await example_basic_streaming(agent) + await example_multi_turn(agent) + await example_event_catalog() + + print("\n" + "=" * 60) + print("Tutorial 09 complete! Next: Tutorial 10 — Context Management") + print("=" * 60) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tutorials/10_context_management/README.md b/tutorials/10_context_management/README.md new file mode 100644 index 0000000..8d089e2 --- /dev/null +++ b/tutorials/10_context_management/README.md @@ -0,0 +1,151 @@ +# Tutorial 10: Context 管理 — 长对话与大结果处理 + +> **什么时候需要这个?** 对话变长、工具一次返回的结果很大(比如 `query_sales` 拉了几千行 CSV),token 数量逼近上下文上限——这时你需要自动压缩历史、截断大工具结果,或把它们 offload 到外部存储。 + +## 本章基于前序章节 + +- **T02 — `Msg` 结构**:理解被压缩的对象长什么样。 +- **T03 — `ToolResultBlock`**:`tool_result_limit` 截断的就是它。 +- **T07 — `PermissionMode.DONT_ASK`** 等长跑场景:长对话和定时任务最先撞上上下文上限。 + +## 你将学到 + +- 上下文窗口的挑战及其解决方案 +- `ContextConfig` 的三个关键参数 +- 自动压缩流程:分割 → 摘要 → 保留近期 +- 工具结果截断:超长结果自动裁剪 +- `compress_context()` 手动触发压缩,并用 `instructions` 给压缩器提示 + +## 前置要求 + +- 完成 Tutorial 09 +- 理解 Agent 的消息上下文结构 + +## 核心概念 + +### 上下文窗口的挑战 + +Agent 在多轮对话 + 大量工具调用后,上下文会迅速膨胀: + +- 每轮对话的输入/输出消息 +- 工具调用的参数和返回结果(可能很大) +- 思考过程的内容 + +当上下文接近模型的最大 token 限制时,就需要压缩或裁剪。 + +### ContextConfig + +```python +from agentscope.agent import Agent +from agentscope.agent import ContextConfig + +agent = Agent( + ..., + context_config=ContextConfig( + trigger_ratio=0.8, # 触发压缩的阈值(占最大上下文比例) + reserve_ratio=0.1, # 压缩后保留的最近消息比例 + tool_result_limit=50000, # 单个工具结果的最大 token 数 + ), +) +``` + +| 参数 | 默认值 | 说明 | +|------|--------|------| +| `trigger_ratio` | 0.8 | 当 token 数超过 `context_size × trigger_ratio` 时触发压缩 | +| `reserve_ratio` | 0.1 | 压缩后保留最近的消息(占总上下文的比例) | +| `tool_result_limit` | 50000 | 单个工具结果超过此 token 数时自动截断 | + +### 自动压缩流程 + +``` +Agent 准备新一轮推理 + ↓ +估算当前 token 数 + ↓ 未超阈值 → 跳过 + ↓ 超过 trigger_ratio +分割上下文:[旧消息 | 近期消息] + ↓ +对旧消息生成结构化摘要 + ↓ +摘要内容: + - task_overview: 用户请求和目标 + - current_state: 已完成的工作 + - important_discoveries: 关键发现 + - next_steps: 下一步计划 + - context_to_preserve: 需要保留的上下文 + ↓ +用摘要替换旧消息,保留近期消息 +``` + +### 工具结果截断 + +当工具返回的结果超过 `tool_result_limit` 个 token 时,AgentScope 会自动截断结果。这防止单个工具结果占用过多上下文。 + +### 手动压缩 + +```python +from agentscope.message import HintBlock + +# 使用默认配置压缩 +await agent.compress_context() + +# 使用自定义配置压缩 +custom_config = ContextConfig( + trigger_ratio=0.5, # 更低的阈值 + reserve_ratio=0.2, # 保留更多近期消息 +) +await agent.compress_context(context_config=custom_config) + +# 给压缩器额外提示:哪些业务细节必须保留 +await agent.compress_context( + instructions=HintBlock( + hint="Preserve the user's preferred report format and KPI formulas.", + ), +) +``` + +`instructions` 不会改变 Agent 的长期系统提示,只会影响这一次压缩摘要的取舍。比如 DataMuse 已经和用户约定了"日报必须包含 GMV、订单数、客单价",就可以在手动压缩时把这个约定钉住。 + +### Offloader 协议 + +在 Agent Service 场景下,`Offloader` 将被压缩的上下文和截断的工具结果持久化到工作空间: + +```python +class Offloader(Protocol): + async def offload_context( + self, session_id: str, msgs: list[Msg], + ) -> str: ... + + async def offload_tool_result( + self, session_id: str, tool_result: ToolResultBlock, + ) -> str: ... +``` + +这允许 Agent 在需要时重新加载完整的历史记录。 + +## 示例:长对话上下文管理 + +本期通过多轮数据分析对话演示: + +1. 不同 `ContextConfig` 参数的效果 +2. 工具结果截断的行为 +3. 手动触发压缩并观察摘要生成 + +## 运行示例 + +```bash +cd tutorials/10_context_management +python main.py +``` + +## 进一步探索 + +- 调整 `trigger_ratio` 和 `reserve_ratio`,观察压缩时机和保留量的变化 +- 降低 `tool_result_limit`,观察大工具结果的截断行为 +- 进行 20+ 轮对话,触发自动压缩 +- 自定义 `compression_prompt` 和 `summary_template` +- 写一个 Middleware 实现 `on_compress_context`,在压缩前自动补充业务保留规则 + +## 下一期预告 + +**Tutorial 11: Middleware** — 用中间件实现日志、计时、动态提示等横切关注点。 diff --git a/tutorials/10_context_management/main.py b/tutorials/10_context_management/main.py new file mode 100644 index 0000000..13986a1 --- /dev/null +++ b/tutorials/10_context_management/main.py @@ -0,0 +1,359 @@ +# -*- coding: utf-8 -*- +"""Tutorial 10: Context Management — Long conversations & large results. + +This tutorial demonstrates: +- ContextConfig parameters (trigger_ratio, reserve_ratio, tool_result_limit) +- Automatic context compression +- Tool result truncation +- Manual compress_context() usage +""" +# pylint: disable=missing-function-docstring,unused-argument +import asyncio +import csv +import os +from pathlib import Path +from typing import Any + +from agentscope.agent import Agent, ContextConfig +from agentscope.credential import DashScopeCredential +from agentscope.event import EventType +from agentscope.message import UserMsg, TextBlock +from agentscope.model import DashScopeChatModel +from agentscope.permission import ( + PermissionBehavior, + PermissionContext, + PermissionDecision, + PermissionMode, +) +from agentscope.state import AgentState +from agentscope.tool import ( + Toolkit, + ToolBase, + ToolChunk, + FunctionTool, + Read, + Glob, +) + +DATA_DIR = Path(__file__).resolve().parent.parent / "data" +SALES_CSV = DATA_DIR / "sales_data.csv" + + +# ========================================================================= +# Tools +# ========================================================================= +def query_sales(category: str = "", limit: int = 5) -> ToolChunk: + """Query the sales dataset. + + Args: + category: Product category to filter. Empty means no filter. + limit: Maximum number of rows to return. + """ + rows = [] + with open(SALES_CSV, "r", encoding="utf-8") as f: + for row in csv.DictReader(f): + if category and row["category"] != category: + continue + rows.append(row) + if len(rows) >= limit: + break + if not rows: + return ToolChunk( + content=[TextBlock(text="No matching records found.")], + ) + header = " | ".join(rows[0].keys()) + lines = [header, "-" * len(header)] + for row in rows: + lines.append(" | ".join(row.values())) + return ToolChunk( + content=[ + TextBlock(text=f"Found {len(rows)} records:\n" + "\n".join(lines)), + ], + ) + + +class LargeResultTool(ToolBase): + """A tool that returns a large result to demonstrate truncation.""" + + name = "LargeResultTool" + description = "Returns all sales data rows (large result for demo)." + input_schema = { + "type": "object", + "properties": { + "max_rows": { + "type": "integer", + "description": "Maximum rows to return.", + "default": 100, + }, + }, + "required": [], + } + is_concurrency_safe = True + is_read_only = True + + async def check_permissions( + self, + tool_input: dict[str, Any], + context: PermissionContext, + ) -> PermissionDecision: + return PermissionDecision( + behavior=PermissionBehavior.ALLOW, + message="Read-only, always allowed.", + ) + + async def call(self, max_rows: int = 100) -> ToolChunk: + rows = [] + with open(SALES_CSV, "r", encoding="utf-8") as f: + for row in csv.DictReader(f): + rows.append(row) + if len(rows) >= max_rows: + break + + lines = [] + for row in rows: + lines.append(" | ".join(f"{k}={v}" for k, v in row.items())) + text = f"Returning {len(rows)} rows:\n" + "\n".join(lines) + return ToolChunk(content=[TextBlock(text=text)]) + + +# ========================================================================= +# Stream helper +# ========================================================================= +async def stream_reply(agent: Agent, content: str) -> None: + """Send a message and stream the reply.""" + msg = UserMsg(name="user", content=content) + print(f"\n[User]: {content[:80]}{'...' if len(content) > 80 else ''}") + print("[DataMuse]: ", end="", flush=True) + + async for event in agent.reply_stream(msg): + match event.type: + case EventType.TEXT_BLOCK_DELTA: + print(event.delta, end="", flush=True) + case EventType.TOOL_CALL_START: + print(f"\n >> Calling: {event.tool_call_name}") + case EventType.TOOL_RESULT_END: + print(f" >> Result: {event.state}") + case EventType.MODEL_CALL_END: + print( + f"\n [tokens: {event.input_tokens}in " + f"+ {event.output_tokens}out]", + end="", + ) + case EventType.REPLY_END: + print() + + +# ========================================================================= +# Example 1: ContextConfig overview +# ========================================================================= +async def example_context_config() -> None: + """Explain ContextConfig parameters.""" + print("\n" + "=" * 60) + print("Example 1: ContextConfig Parameters") + print("=" * 60) + + configs = [ + ("Default", ContextConfig()), + ( + "Aggressive compression", + ContextConfig( + trigger_ratio=0.5, + reserve_ratio=0.05, + tool_result_limit=1000, + ), + ), + ( + "Conservative compression", + ContextConfig( + trigger_ratio=0.85, + reserve_ratio=0.2, + tool_result_limit=5000, + ), + ), + ] + + for name, cfg in configs: + print(f"\n {name}:") + print(f" trigger_ratio: {cfg.trigger_ratio}") + print(f" reserve_ratio: {cfg.reserve_ratio}") + print(f" tool_result_limit: {cfg.tool_result_limit} tokens") + + +# ========================================================================= +# Example 2: Tool result truncation +# ========================================================================= +async def example_tool_result_truncation(model) -> None: + """Demonstrate tool result truncation with different limits.""" + print("\n" + "=" * 60) + print("Example 2: Tool Result Truncation") + print("=" * 60) + + for limit in [500, 3000]: + print(f"\n --- tool_result_limit={limit} ---") + + agent = Agent( + name="DataMuse", + system_prompt=( + "You are DataMuse. Use LargeResultTool to fetch data, " + "then summarize the result briefly." + ), + model=model, + toolkit=Toolkit( + tools=[ + LargeResultTool(), + FunctionTool(query_sales, is_read_only=True), + ], + ), + context_config=ContextConfig(tool_result_limit=limit), + state=AgentState( + permission_context=PermissionContext( + mode=PermissionMode.BYPASS, + ), + ), + ) + + await stream_reply( + agent, + "Fetch 50 rows of sales data using LargeResultTool and " + "tell me how many rows you received.", + ) + + +# ========================================================================= +# Example 3: Multi-turn with context growth +# ========================================================================= +async def example_multi_turn_context(model) -> None: + """Show context growth across multiple turns.""" + print("\n" + "=" * 60) + print("Example 3: Multi-Turn Context Growth") + print("=" * 60) + + agent = Agent( + name="DataMuse", + system_prompt=( + "You are DataMuse, a data analysis assistant. Use tools to " + "answer questions. Keep responses concise (1-2 sentences)." + ), + model=model, + toolkit=Toolkit( + tools=[ + Read(), + Glob(), + FunctionTool(query_sales, is_read_only=True), + ], + ), + context_config=ContextConfig( + trigger_ratio=0.8, + reserve_ratio=0.1, + tool_result_limit=2000, + ), + state=AgentState( + permission_context=PermissionContext( + mode=PermissionMode.BYPASS, + ), + ), + ) + + questions = [ + "What categories exist in the sales data? Query 3 rows.", + "How many Electronics orders are there? Query 5.", + "Show me 3 orders from the North region.", + "What's the most common payment method? Query 5 rows.", + ] + + for i, q in enumerate(questions, 1): + print(f"\n --- Turn {i}/{len(questions)} ---") + n_msgs = len(agent.state.context) + print(f" Context messages before: {n_msgs}") + await stream_reply(agent, q) + n_msgs_after = len(agent.state.context) + print(f" Context messages after: {n_msgs_after}") + + +# ========================================================================= +# Example 4: Compression flow diagram +# ========================================================================= +async def example_compression_flow() -> None: + """Display the compression flow.""" + print("\n" + "=" * 60) + print("Example 4: Context Compression Flow") + print("=" * 60) + + print( + """ + Automatic Compression (triggered in each reasoning iteration): + ────────────────────────────────────────────────────────────── + + estimate current tokens + │ + ├─ tokens < context_size × trigger_ratio + │ └─ skip (no compression needed) + │ + └─ tokens >= context_size × trigger_ratio + │ + ├─ Split context: [old messages | recent messages] + │ (recent = reserve_ratio of context) + │ + ├─ Generate structured summary of old messages: + │ • task_overview: user's core request + │ • current_state: what's been done + │ • important_discoveries: key findings + │ • next_steps: what remains + │ • context_to_preserve: important details + │ + └─ Replace old messages with summary + → new context = [system_prompt, summary, recent messages] + + Tool Result Truncation (applied during tool execution): + ────────────────────────────────────────────────────── + + tool returns result + │ + ├─ result tokens <= tool_result_limit + │ └─ keep as-is + │ + └─ result tokens > tool_result_limit + └─ truncate to fit limit + (or offload to workspace if Offloader available) + + Manual Compression: + ────────────────── + await agent.compress_context() # default config + await agent.compress_context(context_config=custom_config) + await agent.compress_context(instructions=hint_block) + # hint_block tells the summarizer what must be preserved +""", + ) + + +# ========================================================================= +# Main +# ========================================================================= +async def main() -> None: + print("Tutorial 10: Context Management") + print("=" * 60) + + if not SALES_CSV.exists(): + print(f"ERROR: {SALES_CSV} not found.") + print("Run: cd tutorials/data && python generate_sales_data.py") + return + + model = DashScopeChatModel( + credential=DashScopeCredential( + api_key=os.environ["DASHSCOPE_API_KEY"], + ), + model="qwen-plus", + ) + + await example_context_config() + await example_tool_result_truncation(model) + await example_multi_turn_context(model) + await example_compression_flow() + + print("\n" + "=" * 60) + print("Tutorial 10 complete! Next: Tutorial 11 — Middleware") + print("=" * 60) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tutorials/11_middleware/README.md b/tutorials/11_middleware/README.md new file mode 100644 index 0000000..947685a --- /dev/null +++ b/tutorials/11_middleware/README.md @@ -0,0 +1,178 @@ +# Tutorial 11: Middleware — 可插拔的行为扩展 + +> **什么时候需要这个?** 你想加日志、计时、token 计费、tracing、动态注入 prompt 这些"横切关注点"——但不想把它们硬编码进 Agent 主代码。Middleware 让你在 ReAct 循环的关键节点钉钉子,而 Agent 本身保持干净。 + +## 本章基于前序章节 + +- **T01 — Agent ReAct 循环(reasoning + acting)**:Middleware 的执行 Hook 对应 ReAct 循环的不同阶段。 +- **T02 — Event 流**:洋葱模型的 hook 会拦截、转发或加工事件流。 +- **T09 — Token 统计 / `MODEL_CALL_END`**:本章的 `CostTrackerMiddleware` 在 T09 手写统计的基础上抽象成中间件。 + +## 你将学到 + +- Middleware Hook 位置及其作用 +- 洋葱模型(Onion)vs 变换器模型(Transformer) +- 自定义中间件开发 +- 内置 `TracingMiddleware` 的使用 +- `on_compress_context` 与 `list_tools()` 的作用边界 +- 多中间件的执行顺序 + +## 前置要求 + +- 完成 Tutorial 10 +- 理解 Agent 的 ReAct 循环(reasoning + acting) + +## 核心概念 + +### Middleware 系统概述 + +中间件允许你在 Agent 执行的关键节点插入自定义逻辑,而不修改 Agent 本身的代码。这是典型的横切关注点(Cross-cutting Concerns)处理方式。 + +### Hook 位置 + +``` +Agent.reply() +│ +├─ on_reply ── 拦截整个回复过程(最外层) +│ │ +│ ├─ on_reasoning ── 拦截推理阶段(模型调用 + 解析) +│ │ │ +│ │ └─ on_model_call ── 拦截原始模型 API 调用 +│ │ +│ └─ on_acting ── 拦截工具执行 +│ +└─ on_system_prompt ── 变换系统提示(独立管线) + +Agent.compress_context() +│ +└─ on_compress_context ── 拦截上下文压缩 + +Middleware.list_tools() +└─ 返回这个 middleware 额外提供的工具(不是 hook) +``` + +| Hook | 模式 | 说明 | +|------|------|------| +| `on_reply` | 洋葱 | 拦截整个回复,包含所有 ReAct 循环 | +| `on_reasoning` | 洋葱 | 拦截推理阶段(每个 ReAct 迭代) | +| `on_acting` | 洋葱 | 拦截工具执行 | +| `on_model_call` | 洋葱 | 拦截模型 API 调用 | +| `on_compress_context` | 洋葱 | 拦截 `compress_context()`,适合补压缩提示、记录压缩日志 | +| `on_system_prompt` | 变换器 | 顺序变换系统提示字符串 | +| `list_tools()` | 工具发现 | 让 middleware 暴露额外工具;库模式要手动放进 `Toolkit`,Agent Service 会在组装 toolkit 时收集 | + +### 洋葱模型 vs 变换器模型 + +**洋葱模型**(on_reply, on_reasoning, on_acting, on_model_call, on_compress_context): + +```python +class MyMiddleware(MiddlewareBase): + async def on_reasoning(self, agent, input_kwargs, next_handler): + # Before: 在推理前执行 + print("Before reasoning") + async for event in next_handler(**input_kwargs): + yield event # 中间:转发事件 + # After: 在推理后执行 + print("After reasoning") +``` + +多个中间件形成嵌套层: +``` +Middleware A (before) → Middleware B (before) → 核心逻辑 + ↓ +Middleware A (after) ← Middleware B (after) ← 返回 +``` + +**变换器模型**(on_system_prompt): + +```python +class TimeInjector(MiddlewareBase): + async def on_system_prompt(self, agent, current_prompt): + return current_prompt + f"\nCurrent time: {datetime.now()}" +``` + +顺序管线,每个中间件接收前一个的输出。 + +### 自定义中间件 + +继承 `MiddlewareBase`,只实现你需要的 hook: + +```python +from agentscope.middleware import MiddlewareBase + +class LoggingMiddleware(MiddlewareBase): + async def on_reply(self, agent, input_kwargs, next_handler): + print(f"[{agent.name}] Reply started") + async for event in next_handler(**input_kwargs): + yield event + print(f"[{agent.name}] Reply ended") +``` + +### 注册中间件 + +```python +agent = Agent( + ..., + middlewares=[ + LoggingMiddleware(), + TimingMiddleware(), + TracingMiddleware(), + ], +) +``` + +中间件按列表顺序形成洋葱层:第一个是最外层,最后一个最接近核心逻辑。 + +### 内置 TracingMiddleware + +`TracingMiddleware` 提供 OpenTelemetry 集成,自动为 reply、model call、tool execution 创建 span: + +```python +from agentscope.middleware import TracingMiddleware + +agent = Agent( + ..., + middlewares=[TracingMiddleware()], +) +``` + +当未配置 tracing 时,`TracingMiddleware` 零开销直通。 + +### 常见内置 Middleware + +| Middleware | 什么时候用 | +|------------|------------| +| `TracingMiddleware` | 需要 OpenTelemetry tracing、排查慢调用 | +| `ReplyBudgetControlMiddleware` | 需要给单次回复设 token 预算,超预算就收尾 | +| `RAGMiddleware` | 需要从 Knowledge Base 检索资料并注入上下文 | +| `TTSMiddleware` | 需要把文本回复转成音频事件 | +| `AgenticMemoryMiddleware` / `Mem0Middleware` / `ReMeMiddleware` | 需要长期记忆,跨会话保留用户偏好或事实 | + +## 示例:为 DataMuse 添加中间件 + +本期实现四个自定义中间件: + +1. **LoggingMiddleware** — 记录 reply 开始/结束 +2. **TimingMiddleware** — 测量模型调用耗时 +3. **CostTrackerMiddleware** — 累计 token 消费 +4. **DynamicPromptMiddleware** — 注入当前时间到系统提示 +5. **CompressionHintMiddleware** — 在上下文压缩前补充保留提示 + +## 运行示例 + +```bash +cd tutorials/11_middleware +python main.py +``` + +## 进一步探索 + +- 实现一个 `RateLimitMiddleware`,限制模型调用频率 +- 在 `on_acting` 中实现工具结果缓存 +- 组合多个中间件,观察洋葱嵌套的执行顺序 +- 配置 `TracingMiddleware` + Jaeger,可视化追踪链路 +- 用 `list_tools()` 给 RAG 或长期记忆 middleware 暴露搜索工具 + +## 下一期预告 + +**Tutorial 12: Workspace** — 理解 Agent 的统一工作空间:内置工具注入、MCP/Skill 管理、Offloader。 diff --git a/tutorials/11_middleware/main.py b/tutorials/11_middleware/main.py new file mode 100644 index 0000000..46befb8 --- /dev/null +++ b/tutorials/11_middleware/main.py @@ -0,0 +1,513 @@ +# -*- coding: utf-8 -*- +"""Tutorial 11: Middleware — Pluggable behavior extensions. + +This tutorial demonstrates: +- Creating custom middleware with MiddlewareBase +- Onion pattern hooks (on_reply, on_reasoning, on_acting, on_model_call) +- Compression hook (on_compress_context) +- Transformer pattern hook (on_system_prompt) +- Middleware execution order +- TracingMiddleware for OpenTelemetry integration +""" +# pylint: disable=missing-function-docstring,unused-argument +import asyncio +import csv +import os +import time +from datetime import datetime +from pathlib import Path +from typing import Any, AsyncGenerator, Awaitable, Callable + +from agentscope.agent import Agent +from agentscope.credential import DashScopeCredential +from agentscope.event import EventType +from agentscope.message import UserMsg, TextBlock, HintBlock +from agentscope.middleware import MiddlewareBase +from agentscope.model import DashScopeChatModel +from agentscope.permission import ( + PermissionBehavior, + PermissionContext, + PermissionDecision, + PermissionMode, +) +from agentscope.state import AgentState +from agentscope.tool import ( + Toolkit, + ToolBase, + ToolChunk, + FunctionTool, +) + +DATA_DIR = Path(__file__).resolve().parent.parent / "data" +SALES_CSV = DATA_DIR / "sales_data.csv" + + +# ========================================================================= +# Tools +# ========================================================================= +def query_sales(category: str = "", limit: int = 5) -> ToolChunk: + """Query the sales dataset. + + Args: + category: Product category to filter. Empty means no filter. + limit: Maximum number of rows to return. + """ + rows = [] + with open(SALES_CSV, "r", encoding="utf-8") as f: + for row in csv.DictReader(f): + if category and row["category"] != category: + continue + rows.append(row) + if len(rows) >= limit: + break + if not rows: + return ToolChunk( + content=[TextBlock(text="No matching records found.")], + ) + header = " | ".join(rows[0].keys()) + lines = [header, "-" * len(header)] + for row in rows: + lines.append(" | ".join(row.values())) + return ToolChunk( + content=[ + TextBlock(text=f"Found {len(rows)} records:\n" + "\n".join(lines)), + ], + ) + + +class SalesSummary(ToolBase): + """Compute aggregate statistics on the sales dataset.""" + + name = "SalesSummary" + description = "Compute summary statistics for the sales dataset." + input_schema = { + "type": "object", + "properties": { + "group_by": { + "type": "string", + "description": "Column to group by. Empty for overall.", + "default": "", + }, + }, + "required": [], + } + is_concurrency_safe = True + is_read_only = True + + async def check_permissions( + self, + tool_input: dict[str, Any], + context: PermissionContext, + ) -> PermissionDecision: + return PermissionDecision( + behavior=PermissionBehavior.ALLOW, + message="Read-only analytics, always allowed.", + ) + + async def call(self, group_by: str = "") -> ToolChunk: + rows = [] + with open(SALES_CSV, "r", encoding="utf-8") as f: + for row in csv.DictReader(f): + rows.append(row) + + if not group_by: + total = sum(float(r["total"]) for r in rows) + avg = total / len(rows) if rows else 0 + return ToolChunk( + content=[ + TextBlock( + text=f"Overall: {len(rows)} orders, " + f"${total:,.2f} revenue, ${avg:,.2f} avg", + ), + ], + ) + + groups: dict[str, list] = {} + for row in rows: + groups.setdefault(row.get(group_by, "?"), []).append(row) + + lines = [f"Summary by '{group_by}':"] + for key in sorted(groups): + g = groups[key] + rev = sum(float(r["total"]) for r in g) + lines.append(f" {key}: {len(g)} orders, ${rev:,.2f}") + return ToolChunk(content=[TextBlock(text="\n".join(lines))]) + + +# ========================================================================= +# Custom Middlewares +# ========================================================================= +class LoggingMiddleware(MiddlewareBase): + """Logs reply start/end events.""" + + async def on_reply( + self, + agent: Agent, + input_kwargs: dict, + next_handler: Callable[..., AsyncGenerator], + ) -> AsyncGenerator: + print(f" [LOG] Reply started for '{agent.name}'") + start = time.time() + async for event in next_handler(**input_kwargs): + yield event + elapsed = time.time() - start + print(f" [LOG] Reply ended for '{agent.name}' ({elapsed:.1f}s)") + + +class TimingMiddleware(MiddlewareBase): + """Measures model call duration.""" + + async def on_model_call( + self, + agent: Agent, + input_kwargs: dict, + next_handler: Callable[..., Awaitable], + ): + start = time.time() + result = await next_handler(**input_kwargs) + elapsed = time.time() - start + print(f" [TIME] Model call: {elapsed:.2f}s") + return result + + +class CostTrackerMiddleware(MiddlewareBase): + """Tracks cumulative token usage across replies.""" + + def __init__(self): + self.total_input_tokens = 0 + self.total_output_tokens = 0 + self.call_count = 0 + + async def on_reasoning( + self, + agent: Agent, + input_kwargs: dict, + next_handler: Callable[..., AsyncGenerator], + ) -> AsyncGenerator: + self.call_count += 1 + async for event in next_handler(**input_kwargs): + if ( + hasattr(event, "type") + and event.type == EventType.MODEL_CALL_END + ): + self.total_input_tokens += event.input_tokens + self.total_output_tokens += event.output_tokens + yield event + + def summary(self) -> str: + total = self.total_input_tokens + self.total_output_tokens + return ( + f"Reasoning calls: {self.call_count} | " + f"Tokens: {self.total_input_tokens}in " + f"+ {self.total_output_tokens}out " + f"= {total} total" + ) + + +class DynamicPromptMiddleware(MiddlewareBase): + """Injects dynamic information into the system prompt.""" + + async def on_system_prompt( + self, + agent: Agent, + current_prompt: str, + ) -> str: + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + return ( + current_prompt + + f"\n\nCurrent time: {now}" + + f"\nData source: {SALES_CSV}" + ) + + +class CompressionHintMiddleware(MiddlewareBase): + """Adds a preservation hint whenever context compression is requested.""" + + async def on_compress_context( + self, + agent: Agent, + input_kwargs: dict, + next_handler: Callable[..., Awaitable[None]], + ) -> None: + hint = input_kwargs.get("instructions") or HintBlock( + hint=( + "Preserve DataMuse's KPI definitions, report format, " + "and any user-specific reporting preferences." + ), + ) + print(f" [COMPRESS] Adding preservation hint for '{agent.name}'") + await next_handler(**{**input_kwargs, "instructions": hint}) + + +# ========================================================================= +# Stream helper +# ========================================================================= +async def stream_reply(agent: Agent, content: str) -> None: + """Send a message and stream the reply.""" + msg = UserMsg(name="user", content=content) + print(f"\n[User]: {content[:80]}{'...' if len(content) > 80 else ''}") + print("[DataMuse]: ", end="", flush=True) + + async for event in agent.reply_stream(msg): + match event.type: + case EventType.TEXT_BLOCK_DELTA: + print(event.delta, end="", flush=True) + case EventType.TOOL_CALL_START: + print(f"\n >> Calling: {event.tool_call_name}") + case EventType.TOOL_RESULT_END: + print(f" >> Result: {event.state}") + case EventType.REPLY_END: + print() + + +# ========================================================================= +# Example 1: Onion pattern middlewares +# ========================================================================= +async def example_onion_middlewares(model) -> None: + """Demonstrate the onion pattern with logging and timing.""" + print("\n" + "=" * 60) + print("Example 1: Onion Pattern (Logging + Timing)") + print("=" * 60) + + agent = Agent( + name="DataMuse", + system_prompt=( + "You are DataMuse. Use tools to answer questions. " + "Keep responses concise." + ), + model=model, + toolkit=Toolkit( + tools=[ + FunctionTool(query_sales, is_read_only=True), + SalesSummary(), + ], + ), + middlewares=[ + LoggingMiddleware(), + TimingMiddleware(), + ], + state=AgentState( + permission_context=PermissionContext( + mode=PermissionMode.BYPASS, + ), + ), + ) + + await stream_reply( + agent, + "Show me a summary of sales grouped by category.", + ) + + +# ========================================================================= +# Example 2: Cost tracking middleware +# ========================================================================= +async def example_cost_tracking(model) -> None: + """Track token costs across multiple replies.""" + print("\n" + "=" * 60) + print("Example 2: Cost Tracking Middleware") + print("=" * 60) + + cost_tracker = CostTrackerMiddleware() + + agent = Agent( + name="DataMuse", + system_prompt=( + "You are DataMuse. Use tools to answer questions. " + "Keep responses concise (1-2 sentences)." + ), + model=model, + toolkit=Toolkit( + tools=[ + FunctionTool(query_sales, is_read_only=True), + SalesSummary(), + ], + ), + middlewares=[cost_tracker], + state=AgentState( + permission_context=PermissionContext( + mode=PermissionMode.BYPASS, + ), + ), + ) + + await stream_reply(agent, "Query 3 Electronics orders.") + print(f" [COST] After turn 1: {cost_tracker.summary()}") + + await stream_reply(agent, "Now show summary grouped by region.") + print(f" [COST] After turn 2: {cost_tracker.summary()}") + + +# ========================================================================= +# Example 3: Dynamic prompt middleware +# ========================================================================= +async def example_dynamic_prompt(model) -> None: + """Inject dynamic information into the system prompt, then act on it.""" + print("\n" + "=" * 60) + print("Example 3: Dynamic Prompt Middleware (on_system_prompt)") + print("=" * 60) + + agent = Agent( + name="DataMuse", + system_prompt=( + "You are DataMuse, a sales-data analyst. The current snapshot " + "time and active data source are appended to this prompt by the " + "framework — quote them verbatim in your reply, then use the " + "available tools to compute the headline numbers from that data " + "source." + ), + model=model, + toolkit=Toolkit( + tools=[ + FunctionTool(query_sales, is_read_only=True), + SalesSummary(), + ], + ), + middlewares=[DynamicPromptMiddleware()], + state=AgentState( + permission_context=PermissionContext( + mode=PermissionMode.BYPASS, + ), + ), + ) + + await stream_reply( + agent, + "Use the snapshot time injected into your system prompt as the " + "report timestamp, then call SalesSummary grouped by category and " + "tell me the top category by revenue.", + ) + + +# ========================================================================= +# Example 4: Compression hook +# ========================================================================= +async def example_compression_hook(model) -> None: + """Show how middleware can intercept manual context compression.""" + print("\n" + "=" * 60) + print("Example 4: Compression Hook (on_compress_context)") + print("=" * 60) + + agent = Agent( + name="DataMuse", + system_prompt="You are DataMuse, a sales-data analyst.", + model=model, + toolkit=Toolkit(tools=[]), + middlewares=[CompressionHintMiddleware()], + ) + await agent.observe( + UserMsg( + name="user", + content=( + "For future reports, preserve the KPI definitions and " + "keep summaries in bullet form." + ), + ), + ) + await agent.compress_context() + print(" [COMPRESS] compress_context() completed") + + +# ========================================================================= +# Example 5: Middleware architecture +# ========================================================================= +async def example_architecture() -> None: + """Display the middleware architecture.""" + print("\n" + "=" * 60) + print("Example 5: Middleware Architecture") + print("=" * 60) + + print( + """ + Onion Model: + ──────────── + on_reply / on_reasoning / on_acting / on_model_call + on_compress_context + + middlewares = [A, B, C] + + Request: A.before → B.before → C.before → core logic + Response: A.after ← B.after ← C.after ← core logic + + ┌─────────────────────────────────────────────┐ + │ A: on_reply │ + │ ┌─────────────────────────────────────┐ │ + │ │ B: on_reasoning │ │ + │ │ ┌─────────────────────────────┐ │ │ + │ │ │ C: on_model_call │ │ │ + │ │ │ ┌─────────────────────┐ │ │ │ + │ │ │ │ Core Logic │ │ │ │ + │ │ │ └─────────────────────┘ │ │ │ + │ │ └─────────────────────────────┘ │ │ + │ └─────────────────────────────────────┘ │ + └─────────────────────────────────────────────┘ + + Transformer Model (on_system_prompt): + ───────────────────────────────────── + + prompt → A.transform → B.transform → C.transform → final prompt + + Each middleware receives the output of the previous one. + Unlike onion, there's no "after" phase. + + Implementation pattern: + ────────────────────── + + # Onion hook (async generator): + async def on_reasoning(self, agent, input_kwargs, next_handler): + # Before logic + async for event in next_handler(**input_kwargs): + yield event # Forward events + # After logic + + # Transformer hook (returns string): + async def on_system_prompt(self, agent, current_prompt): + return current_prompt + "\\nExtra info" + + # Compression hook (returns None): + async def on_compress_context(self, agent, input_kwargs, next_handler): + await next_handler(**input_kwargs) + + Optional tool discovery: + ─────────────────────── + async def list_tools(self): + return [SomeTool()] + + In library mode, add those tools to Toolkit yourself. + In Agent Service, the toolkit assembly step collects middleware tools. +""", + ) + + +# ========================================================================= +# Main +# ========================================================================= +async def main() -> None: + print("Tutorial 11: Middleware") + print("=" * 60) + + if not SALES_CSV.exists(): + print(f"ERROR: {SALES_CSV} not found.") + print("Run: cd tutorials/data && python generate_sales_data.py") + return + + model = DashScopeChatModel( + credential=DashScopeCredential( + api_key=os.environ["DASHSCOPE_API_KEY"], + ), + model="qwen-plus", + ) + + await example_onion_middlewares(model) + await example_cost_tracking(model) + await example_dynamic_prompt(model) + await example_compression_hook(model) + await example_architecture() + + print("\n" + "=" * 60) + print("Tutorial 11 complete! Next: Tutorial 12 — Workspace") + print("=" * 60) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tutorials/12_workspace/README.md b/tutorials/12_workspace/README.md new file mode 100644 index 0000000..17db0f5 --- /dev/null +++ b/tutorials/12_workspace/README.md @@ -0,0 +1,152 @@ +# Tutorial 12: Workspace — Agent 的工作空间 + +> **什么时候需要这个?** 当你不想再手动一个一个拼装 `Tool` / `MCP` / `Skill` / `Offloader`,或者你要把 Agent 部署到多用户、多 Session 场景,每个 Session 需要一个隔离的执行环境(内置工具、持久化目录、独立 MCP)——Workspace 就是把这些东西收束成一个统一对象。 + +## 本章基于前序章节 + +- **T03 — 内置工具(Bash/Read/Write/Edit/Glob/Grep)**:Workspace 自动注入这些工具,无需手动列。 +- **T05 — MCP**:Workspace 通过 `add_mcp` / `remove_mcp` 统一管理 MCP 客户端。 +- **T06 — Skill / `LocalSkillLoader`**:Workspace 提供 `add_skill` / `list_skills` 等 Skill 管理接口。 +- **T10 — `Offloader` 协议**:Workspace 同时实现 Offloader,把压缩的上下文和截断的工具结果落到本地目录。 + +## 你将学到 + +- Workspace 的设计定位:工具、MCP、Skill 和 Offload 的统一载体 +- `LocalWorkspace`:目录布局、初始化、生命周期 +- Workspace 如何自动注入内置工具(Bash、Read、Write、Edit、Glob、Grep) +- Workspace 作为 Offloader:上下文和工具结果的持久化 +- MCP 和 Skill 的动态管理:`add_mcp` / `remove_mcp`、`add_skill` / `remove_skill` +- Docker / E2B / K8s Workspace 的对比(概念介绍) +- Workspace 在 Agent Service 中的角色 + +## 前置要求 + +- 完成 Tutorial 01-11 +- 理解 MCP(Tutorial 05)、Skill(Tutorial 06)、ContextConfig(Tutorial 10)的基本概念 + +## 核心概念 + +### 为什么需要 Workspace? + +在前面的教程中,我们分别学了工具、MCP、Skill、Context 压缩。它们在代码中是分散的: + +```python +# 以前的做法:手动拼装 +agent = Agent( + toolkit=Toolkit( + tools=[Bash(), Read(), Write(), ...], # 手动列工具 + skills_or_loaders=[LocalSkillLoader(...)], # 手动加 Skill + mcps=[MCPClient(...)], # 手动加 MCP + ), + offloader=some_offloader, # 手动配 Offloader +) +``` + +Workspace 把这些收束到一个统一的抽象里: + +```python +# Workspace 的做法:一个对象管所有 +workspace = LocalWorkspace(workdir="./workspace") +await workspace.initialize() + +agent = Agent( + toolkit=Toolkit( + tools=await workspace.list_tools(), # 自动提供内置工具 + skills_or_loaders=await workspace.list_skills(), + mcps=await workspace.list_mcps(), + ), + offloader=workspace, # 同时也是 Offloader +) +``` + +### WorkspaceBase 协议 + +```python +class WorkspaceBase: + # 生命周期 + async def initialize() -> None + async def close() -> None + async def reset() -> None + + # Agent 消费:资源发现 + async def list_tools() -> list[ToolBase] + async def list_mcps() -> list[MCPClient] + async def list_skills() -> list[Skill] + async def get_instructions() -> str + + # Agent 消费:Offload + async def offload_context(session_id, msgs) -> str + async def offload_tool_result(session_id, tool_result) -> str + + # 用户操作:动态管理 + async def add_mcp(mcp_client) -> None + async def remove_mcp(name) -> None + async def add_skill(skill_path) -> None + async def remove_skill(name) -> None +``` + +### LocalWorkspace 目录布局 + +``` +workspace/ +├── .mcp ← MCP 配置持久化(JSON) +├── data/ ← Offload 的多模态文件(图片等) +├── skills/ ← 技能目录(每个技能一个子目录) +│ ├── .skills ← 技能索引文件 +│ └── chart_gen/ +│ └── SKILL.md +└── sessions/ ← 按 session_id 分区 + └── {session_id}/ + ├── context.jsonl ← 压缩后的上下文 + └── tool_result-{id}.txt ← Offload 的工具结果 +``` + +### Workspace 的三个角色 + +| 角色 | 方法 | 说明 | +|------|------|------| +| 工具提供者 | `list_tools()` | 返回 Bash、Read、Write、Edit、Glob、Grep | +| 资源管理者 | `list_mcps()`, `list_skills()` | MCP 和 Skill 的注册/发现 | +| Offloader | `offload_context()`, `offload_tool_result()` | 上下文压缩和工具结果持久化 | + +### 三种 Workspace 实现 + +| 实现 | 隔离级别 | 适用场景 | +|------|----------|----------| +| `LocalWorkspace` | 目录级别 | 本地开发、教程、单用户 | +| `DockerWorkspace` | 容器级别 | 单机服务、多租户隔离 | +| `E2BWorkspace` | 云沙箱 | SaaS 场景、完全隔离 | +| `K8sWorkspace` | Pod / PVC 级别 | 已有 Kubernetes 集群,需要按 Session 管理 Pod 生命周期 | + +本教程聚焦 `LocalWorkspace`。Docker、E2B、K8s 的使用方式相同:在 Agent Service 里换成对应的 `WorkspaceManager`,由它负责为每个 Session 分配工作空间。 + +```python +from agentscope.app.workspace_manager import ( + LocalWorkspaceManager, + DockerWorkspaceManager, + E2BWorkspaceManager, + K8sWorkspaceManager, +) +``` + +## 示例 + +本期演示 LocalWorkspace 的完整功能:初始化、内置工具、Skill 管理、Offloader 集成,以及用 Agent 在 Workspace 中完成一次数据分析任务。 + +## 运行示例 + +```bash +cd tutorials/12_workspace +python main.py +``` + +## 进一步探索 + +- 用 `add_mcp()` 在运行时动态添加一个 MCP server +- 观察 `sessions/` 目录下的 offload 文件内容 +- 自定义 `instructions` 参数,改变 Agent 对 Workspace 的理解 +- 对比 `LocalWorkspace`、`DockerWorkspace` 和 `K8sWorkspace` 的隔离差异 + +## 下一期预告 + +**Tutorial 13: Agent Service** — 将 Workspace 作为 Agent Service 的执行环境,部署多用户多会话的 HTTP 服务。 diff --git a/tutorials/12_workspace/main.py b/tutorials/12_workspace/main.py new file mode 100644 index 0000000..3a9d749 --- /dev/null +++ b/tutorials/12_workspace/main.py @@ -0,0 +1,338 @@ +# -*- coding: utf-8 -*- +"""Tutorial 12: Workspace — Agent's unified working environment. + +This tutorial demonstrates: +- LocalWorkspace: initialization, directory layout, lifecycle +- Built-in tools provided by workspace (Bash, Read, Write, etc.) +- Workspace as Offloader: context and tool result persistence +- Dynamic skill management: add_skill / remove_skill +- Using workspace with an Agent for a complete analysis task +""" +# pylint: disable=missing-function-docstring +import asyncio +import os +from pathlib import Path + +from agentscope.agent import Agent, ContextConfig +from agentscope.credential import DashScopeCredential +from agentscope.event import EventType +from agentscope.message import UserMsg +from agentscope.model import DashScopeChatModel +from agentscope.permission import PermissionContext, PermissionMode +from agentscope.state import AgentState +from agentscope.tool import Toolkit +from agentscope.workspace import LocalWorkspace + +DATA_DIR = Path(__file__).resolve().parent.parent / "data" +SALES_CSV = DATA_DIR / "sales_data.csv" +WORKSPACE_DIR = Path(__file__).resolve().parent / "workspace" + + +# ========================================================================= +# Example 1: Workspace basics +# ========================================================================= +async def example_workspace_basics() -> None: + """Demonstrate workspace initialization and inspection.""" + print("\n" + "=" * 60) + print("Example 1: Workspace Basics") + print("=" * 60) + + workspace = LocalWorkspace(workdir=str(WORKSPACE_DIR)) + await workspace.initialize() + + try: + print(f"\n workspace_id: {workspace.workspace_id}") + print(f" workdir: {workspace.workdir}") + print(f" is_alive: {workspace.is_alive}") + + # List built-in tools + tools = await workspace.list_tools() + print(f"\n Built-in tools ({len(tools)}):") + for tool in tools: + print(f" - {tool.name}: {tool.description[:60]}...") + + # List MCPs and skills + mcps = await workspace.list_mcps() + skills = await workspace.list_skills() + print(f"\n MCPs: {len(mcps)}") + print(f" Skills: {len(skills)}") + + # Get workspace instructions + instructions = await workspace.get_instructions() + preview = instructions[:200].replace("\n", "\n ") + print(f"\n Instructions (preview):\n {preview}...") + + # Directory layout + print("\n Directory layout:") + for item in sorted(Path(workspace.workdir).rglob("*")): + rel = item.relative_to(workspace.workdir) + prefix = " " + " " * (len(rel.parts) - 1) + print(f"{prefix}{'/' if item.is_dir() else ''}{rel.name}") + + finally: + await workspace.close() + + print("\n Workspace closed.") + + +# ========================================================================= +# Example 2: Skill management +# ========================================================================= +async def example_skill_management() -> None: + """Demonstrate dynamic skill add/remove.""" + print("\n" + "=" * 60) + print("Example 2: Dynamic Skill Management") + print("=" * 60) + + # Check if we have skills from Tutorial 06 + skills_dir = ( + Path(__file__).resolve().parent.parent / "06_skills" / "skills" + ) + + workspace = LocalWorkspace(workdir=str(WORKSPACE_DIR)) + await workspace.initialize() + + try: + # Show initial state + skills = await workspace.list_skills() + print(f"\n Initial skills: {len(skills)}") + + if skills_dir.exists(): + # Add skills from Tutorial 06 + chart_skill = skills_dir / "chart_generator" + if chart_skill.exists(): + print(f"\n Adding skill from: {chart_skill}") + await workspace.add_skill(str(chart_skill)) + + skills = await workspace.list_skills() + print(f" Skills after add: {len(skills)}") + for skill in skills: + print(f" - {skill.name}: {skill.description[:60]}...") + + # Remove the skill + print(f"\n Removing skill: {skills[0].name}") + await workspace.remove_skill(skills[0].name) + + skills = await workspace.list_skills() + print(f" Skills after remove: {len(skills)}") + else: + print( + "\n (Tutorial 06 skills not found — showing API pattern " + "only)", + ) + print( + """ + # Dynamic skill management API: + await workspace.add_skill("/path/to/skill_dir") # add + skills = await workspace.list_skills() # list + await workspace.remove_skill("skill-name") # remove +""", + ) + + finally: + await workspace.close() + + +# ========================================================================= +# Example 3: Workspace as Offloader +# ========================================================================= +async def example_offloader() -> None: + """Demonstrate workspace offloading with an Agent.""" + print("\n" + "=" * 60) + print("Example 3: Workspace as Offloader") + print("=" * 60) + + model = DashScopeChatModel( + credential=DashScopeCredential( + api_key=os.environ["DASHSCOPE_API_KEY"], + ), + model="qwen-plus", + ) + workspace = LocalWorkspace(workdir=str(WORKSPACE_DIR)) + await workspace.initialize() + + try: + agent = Agent( + name="DataMuse", + system_prompt=( + "You are DataMuse, a data analyst. Use Read to inspect " + f"the sales data at {SALES_CSV}. Be concise." + ), + model=model, + toolkit=Toolkit( + tools=await workspace.list_tools(), + skills_or_loaders=await workspace.list_skills(), + mcps=await workspace.list_mcps(), + ), + context_config=ContextConfig( + tool_result_limit=800, + ), + state=AgentState( + permission_context=PermissionContext( + mode=PermissionMode.BYPASS, + ), + ), + offloader=workspace, + ) + + # Send a task that produces a large tool result + msg = UserMsg( + name="user", + content=( + f"Read the first 20 lines of {SALES_CSV} and tell me " + "the column names and data types." + ), + ) + + print("\n Sending task to Agent with workspace offloader...") + text_parts = [] + async for event in agent.reply_stream(msg): + match event.type: + case EventType.TEXT_BLOCK_DELTA: + text_parts.append(event.delta) + case EventType.TOOL_CALL_START: + print( + f" >> Tool: {event.tool_call_name}", + end="", + flush=True, + ) + case EventType.TOOL_RESULT_END: + print(f" [{event.state}]") + + text = "".join(text_parts) + print(f"\n Agent response: {text[:200]}...") + + # Check what was offloaded + sessions_dir = Path(workspace.workdir) / "sessions" + if sessions_dir.exists(): + print(f"\n Offloaded files in {sessions_dir}:") + for item in sorted(sessions_dir.rglob("*")): + if item.is_file(): + rel = item.relative_to(sessions_dir) + size = item.stat().st_size + print(f" {rel} ({size} bytes)") + else: + print("\n (No offloaded files yet — tool results were small)") + + finally: + await workspace.close() + + +# ========================================================================= +# Example 4: Architecture overview +# ========================================================================= +async def example_architecture() -> None: + """Display workspace architecture patterns.""" + print("\n" + "=" * 60) + print("Example 4: Workspace Architecture") + print("=" * 60) + + print( + """ + WorkspaceBase Protocol + ────────────────────── + + ┌─────────────────────────────────────────────────────────┐ + │ WorkspaceBase │ + │ │ + │ Lifecycle: │ + │ initialize() → close() → reset() │ + │ │ + │ Resource Discovery (consumed by Agent): │ + │ list_tools() → [Bash, Read, Write, Edit, ...] │ + │ list_mcps() → [MCPClient, ...] │ + │ list_skills() → [Skill, ...] │ + │ get_instructions() → system prompt fragment │ + │ │ + │ Offload (consumed by Agent): │ + │ offload_context(session_id, msgs) │ + │ offload_tool_result(session_id, tool_result) │ + │ │ + │ Dynamic Management (consumed by User/UI): │ + │ add_mcp(client) / remove_mcp(name) │ + │ add_skill(path) / remove_skill(name) │ + └─────────────────────────────────────────────────────────┘ + + Workspace Implementations + ───────────────────────── + + LocalWorkspace DockerWorkspace E2BWorkspace K8sWorkspace + ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌──────────┐ + │ ./workspace │ │ Docker │ │ E2B Cloud │ │ Pod/PVC │ + │ local files │ │ container │ │ sandbox │ │ cluster │ + └─────────────┘ └─────────────┘ └─────────────┘ └──────────┘ + 本地目录 容器隔离 云端隔离 K8s 隔离 + + Workspace in Agent Construction + ──────────────────────────────── + + workspace = LocalWorkspace(workdir="./ws") + await workspace.initialize() + + agent = Agent( + toolkit=Toolkit( + tools=await workspace.list_tools(), + skills_or_loaders=await workspace.list_skills(), + mcps=await workspace.list_mcps(), + ), + offloader=workspace, # ← same object serves as Offloader + ) + + # At shutdown + await workspace.close() + + Workspace in Agent Service (Tutorial 13) + ────────────────────────────────────────── + + # WorkspaceManager creates per-session workspaces + from agentscope.app import create_app + from agentscope.app.message_bus import InMemoryMessageBus + from agentscope.app.storage import RedisStorage + from agentscope.app.workspace_manager import LocalWorkspaceManager + + manager = LocalWorkspaceManager( + basedir="./workspaces", + default_mcps=[...], + skill_paths=["./skills/analyst"], + ) + + app = create_app( + storage=RedisStorage(...), + message_bus=InMemoryMessageBus(), + workspace_manager=manager, + ) +""", + ) + + +# ========================================================================= +# Main +# ========================================================================= +async def main() -> None: + print("Tutorial 12: Workspace") + print("=" * 60) + + if not SALES_CSV.exists(): + print(f"ERROR: {SALES_CSV} not found.") + print("Run: cd tutorials/data && python generate_sales_data.py") + return + + # Example 1: Basics + await example_workspace_basics() + + # Example 2: Skill management + await example_skill_management() + + # Example 3: Offloader + await example_offloader() + + # Example 4: Architecture + await example_architecture() + + print("\n" + "=" * 60) + print("Tutorial 12 complete! Next: Tutorial 13 — Agent Service") + print("=" * 60) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tutorials/13_agent_service/README.md b/tutorials/13_agent_service/README.md new file mode 100644 index 0000000..94f9f25 --- /dev/null +++ b/tutorials/13_agent_service/README.md @@ -0,0 +1,300 @@ +# Tutorial 13: Agent Service — 服务化部署 + +> **什么时候需要这个?** 单脚本跑通后,你要把 Agent 暴露成 HTTP 服务:支持多用户隔离、多 Session 并行、状态持久化、Web/移动端通过 REST + SSE 接入。`create_app()` 提供 FastAPI 服务骨架,并在运行时组装模型、工具、Middleware 和 Workspace。 + +从本章开始,DataMuse 从“本地单 Agent”切换为“Agent Service”形态。业务目标和销售数据不变,但 Agent 模板、Session 状态、服务端能力和客户端调用被拆到不同层,不再沿用前面脚本的文件相对路径。 + +## 本章基于前序章节 + +- **T09 — 流式事件**:HTTP `/sessions/{id}/stream` 推送的就是 T09 渲染的那套 AgentEvent。 +- **T10 — `ContextConfig`**:Session 创建时绑定的 context 配置。 +- **T11 — Middleware**:服务端 Agent 同样可以挂中间件做日志/计费/tracing。 +- **T12 — Workspace / `WorkspaceManager`**:每个 Session 通过 `WorkspaceManager` 拿到一个隔离的 Workspace。 + +## 你将学到 + +- `create_app()` 工厂函数的使用 +- `MessageBus` 在服务端事件流里的作用 +- 多租户架构:`user_id` 隔离 +- Session 模型:Agent 模板 vs 运行时状态 +- Credential 集中管理 +- Chat 触发与 Session SSE 流式通信 +- 模型 fallback、TTS、Knowledge Base 等 Session 级配置 +- 连接官方示例 Web UI +- REST API 的完整流程 + +## 前置要求 + +- 完成 Tutorial 12 +- 安装服务依赖:`pip install "agentscope[service]" fakeredis httpx` +- Redis 服务可选;本教程默认用 `fakeredis` 跑内存模式 +- 如需体验 Web UI:Node.js 20+ 与 `pnpm` + +## 核心概念 + +### 从脚本到服务 + +前面的教程都是单脚本运行的 Agent。在真实部署中,我们需要: + +- **多用户**:不同用户的 Agent 相互隔离 +- **多会话**:同一用户可以有多个对话 +- **持久化**:重启后恢复状态 +- **HTTP API**:Web/移动客户端可以接入 + +### create_app() 工厂函数 + +```python +from agentscope.app import create_app +from agentscope.app.message_bus import InMemoryMessageBus +from agentscope.app.storage import RedisStorage +from agentscope.app.workspace_manager import LocalWorkspaceManager + +app = create_app( + storage=RedisStorage(host="localhost", port=6379), + message_bus=InMemoryMessageBus(), + workspace_manager=LocalWorkspaceManager(basedir="./workspaces"), +) +``` + +`create_app()` 返回一个 FastAPI 应用,内置以下路由: + +| 路由前缀 | 功能 | +|----------|------| +| `/credential` | API Key 管理 | +| `/agent` | Agent 模板管理 | +| `/sessions` | 会话管理 | +| `/chat` | 触发一次对话运行 | +| `/sessions/{id}/stream` | 订阅会话事件流 | +| `/schedule` | 定时任务 | +| `/knowledge_bases` | Knowledge Base / RAG 管理(启用后可用) | +| `/tts_model` | TTS 模型 schema / 发现 | + +`MessageBus` 是服务里的实时事件通道:`POST /chat/` 只负责触发 run,Agent 产生的事件会写入 bus,再由 `/sessions/{id}/stream` 以 SSE 推给客户端。单进程教程用 `InMemoryMessageBus()`;多进程或多 worker 部署时换成 `RedisMessageBus()`。 + +### 多租户架构 + +每个请求通过 `user_id` Header 标识用户: + +``` +Client → HTTP Request (X-User-Id: user123) → AgentScope Service + ↓ + user_id 隔离 + ├─ Credentials + ├─ Agents + └─ Sessions +``` + +### Agent 与 Session 的关系 + +``` +Agent(模板) Session(运行时) +├─ name ├─ session_id +├─ system_prompt ├─ agent_id(关联模板) +├─ context_config ├─ chat_model_config +└─ react_config ├─ context(对话历史) + └─ state(权限、工具状态) +``` + +- **Agent** 是模板:定义了 Agent 的配置 +- **Session** 是实例:每次对话创建一个 Session,包含独立的上下文和状态 +- 同一个 Agent 模板可以创建多个 Session + +### 完整 API 流程 + +``` +1. POST /credential/ ── 创建 API Key +2. POST /agent/ ── 创建 Agent 模板 +3. POST /sessions/ ── 创建 Session 并绑定模型 +4. GET /sessions/{id}/stream ── 打开 SSE 事件订阅 +5. POST /chat/ ── 发送消息,触发一次 run +6. GET /sessions/{id}/status ── 查看 running / idle / awaiting 状态 +7. GET /sessions/{id}/messages ── 查看会话消息 +``` + +### Web UI + +AgentScope 2.0 仓库里包含一个配套 Web UI,位于 `examples/web_ui`。它不是 Python extra 的一部分,而是一个独立的前端示例,用来连接上面由 `create_app()` 启动的 Agent Service。 + +启动 Agent Service 后,在另一个终端运行: + +```bash +cd examples/web_ui +pnpm install +pnpm dev +``` + +打开 Web UI 后,在 setup 页面把服务器地址填为 `http://localhost:8000`,用户名可以填 `demo-user`。后续创建 Credential、Agent、Session 和发送消息,都可以通过界面完成;这和下面的 `curl` 流程调用的是同一组后端 API。 + +### SSE 流式通信 + +`POST /chat/` 现在是**触发器**:请求成功只说明 run 已开始,不直接返回 AgentEvent。真正的 Server-Sent Events 流来自 `GET /sessions/{session_id}/stream?agent_id=...`: + +``` +GET /sessions/{session_id}/stream?agent_id={agent_id} +data: {"type": "REPLY_START", "reply_id": "xxx", ...} +data: {"type": "TEXT_BLOCK_DELTA", "delta": "Hello", ...} +data: {"type": "MODEL_CALL_END", "input_tokens": 100, ...} +data: {"type": "REPLY_END", ...} +``` + +所以客户端的顺序是:先建立 stream 长连接,再 `POST /chat/` 触发一次回复,收到 `REPLY_END` 后按需关闭连接。这样同一个 stream 可以跨多次 run 复用,也能支持 HITL 恢复、后台唤醒和 team worker 的事件投影。 + +### Workspace 隔离 + +`WorkspaceManager` 为每个 Session 提供隔离的工作环境: + +| 类型 | 说明 | +|------|------| +| `LocalWorkspaceManager` | 本地目录隔离 | +| `DockerWorkspaceManager` | Docker 容器隔离 | +| `E2BWorkspaceManager` | E2B 沙箱隔离 | +| `K8sWorkspaceManager` | Kubernetes Pod / PVC 隔离 | + +### 模型 fallback 与自动重试 + +服务化之后,模型挂掉/限流就不再是"重跑一次"能解决的事——请求来自真实用户或定时任务,必须**自动**降级或重试。AgentScope 有两层配置: + +```python +from agentscope.agent import Agent +from agentscope.agent import ModelConfig +from agentscope.credential import DashScopeCredential +from agentscope.model import DashScopeChatModel + +primary = DashScopeChatModel( + credential=DashScopeCredential(api_key=os.environ["DASHSCOPE_API_KEY"]), + model="qwen-plus", +) +backup = DashScopeChatModel( + credential=DashScopeCredential(api_key=os.environ["DASHSCOPE_API_KEY"]), + model="qwen-turbo", # 便宜、稳定的兜底 +) + +agent = Agent( + name="DataMuse", + system_prompt="...", + model=primary, + model_config=ModelConfig( + max_retries=2, # 主模型先重试 2 次 + fallback_model=backup, # 还失败就切到 backup(backup 也享受 max_retries) + ), +) +``` + +在 Agent Service 里,fallback 是 Session 级配置: + +```json +{ + "agent_id": "agt_xxx", + "chat_model_config": { + "type": "dashscope_chat", + "credential_id": "cred_primary", + "model": "qwen-plus", + "parameters": {} + }, + "fallback_chat_model_config": { + "type": "dashscope_chat", + "credential_id": "cred_backup", + "model": "qwen-turbo", + "parameters": {} + } +} +``` + +语义: +- `fallback_chat_model_config=None`(默认)→ 主模型失败后直接抛错 +- 配了 fallback → 主模型失败后切到备用模型 +- 每个具体 `ChatModelBase` 仍有自己的 API retry 逻辑;库模式下还可以通过 `ModelConfig(max_retries=...)` 控制 fallback 前的重试次数 + +什么时候配? +- **面向用户的 API 服务**:用户在等响应,必须有兜底 +- **定时任务(T14)**:无人值守失败就只能等下次 cron,必须自动重试 + fallback +- **dev/exploration**:通常不需要——失败让它显式报错,反而能更快定位 + +### 给服务端 Agent 注入能力 + +`POST /agent/` 创建的是模板,HTTP payload 不能直接塞 Python 对象进去;但服务宿主可以在 `create_app()` 时注入运行期能力: + +```python +app = create_app( + ..., + extra_agent_tools=tool_factory, # 每次组装 Agent 时追加工具 + extra_agent_middlewares=middleware_factory, +) +``` + +这里的 factory 是异步函数,签名为 `(user_id, agent_id, session_id) -> list[ToolBase]`。它会在每次组装服务端 Agent 时执行,因此既可以返回固定工具,也可以按用户、Agent 或 Session 决定可用能力。 + +本教程同时展示两种注入方式: + +- `extra_agent_tools=datamuse_tools`:注入固定读取服务端数据的 `SalesProfile` 和 `SalesBreakdown`。数据路径由服务宿主管理,客户端不需要知道文件系统结构。 +- `LocalWorkspaceManager(skill_paths=[...])`:把 T06 的 `report_writer` Skill 放入每个隔离 workspace,让 Agent 按需读取写报告的操作指南。 + +二者职责不同:Tool 提供可直接调用的原子能力,Skill 提供如何组合能力的操作指南。 + +### Knowledge Base / TTS + +新版本的 Agent Service 还支持两类 Session 级能力: + +- `knowledge_config`:把 Knowledge Base 接到本 Session,底层通过 `RAGMiddleware` 检索并注入上下文。 +- `tts_model_config`:把 TTS 模型接到本 Session,底层通过 `TTSMiddleware` 把文本回复合成为 `DATA_BLOCK_*` 音频事件。 + +它们都不是 DataMuse 主线的必要步骤,所以本章先点出接入位置;真正需要"带资料库问答"或"语音回复"时,再扩展这一层。 + +## 示例:部署 DataMuse 服务 + +本期展示如何用 `create_app` 创建一个完整的 Agent 服务,并用三种方式驱动它:`curl`、`client.py`(Python httpx)、Web UI。 + +Agent 模板只保存名称、系统提示词和运行配置;具体模型在创建 Session 时通过 `chat_model_config` 绑定。Python 工具不要放进 HTTP Agent payload,而是在服务宿主侧通过 `extra_agent_tools` 注入;MCP 和 Skill 则可以通过 Workspace 的 `default_mcps` / `skill_paths` 注入。本期 `main.py` 两种方式都用了:`SalesProfile` / `SalesBreakdown` 来自服务宿主,`report_writer` Skill 来自 Workspace。 + +> 默认存储用 `fakeredis` 跑内存模式,**无需启动真实 Redis**。实际部署时把 `_make_inmemory_storage()` 换成 `RedisStorage(host=..., port=...)` 即可。 + +## 运行示例 + +```bash +# 安装零依赖运行所需的两个小包 +pip install fakeredis httpx + +# 终端 A:启动服务(默认 8000,无 Redis 依赖) +cd tutorials/13_agent_service +python main.py + +# 终端 B:用 Python httpx 走完 5 步 API 流程 +cd tutorials/13_agent_service +python client.py +``` + +`client.py` 会依次: + +1. `POST /credential/` — 用环境变量里的 `DASHSCOPE_API_KEY` / `OPENAI_API_KEY` 注册 Credential +2. `POST /agent/` — 创建 DataMuse Agent 模板 +3. `POST /sessions/` — 建一个 Session 并绑定模型 +4. `GET /sessions/{id}/stream` — 打开 SSE 事件流 +5. `POST /chat/` — 触发一次回复 +6. Agent 调用 `SalesProfile` / `SalesBreakdown`,客户端流式打印工具和文本事件 +7. `GET /sessions/{id}/messages` — 列出已持久化的对话 + +如果偏好命令行,仍可用 curl —— `main.py` 的 `print_overview()` 会列出每一步的 endpoint。 + +如果要用 Web UI 体验同一个服务: + +```bash +# 在仓库根目录的另一个终端 +cd examples/web_ui +pnpm install +pnpm dev +``` + +Web UI 首次打开时填入 `http://localhost:8000` 和一个用户名即可。 + +## 进一步探索 + +- 挂载到已有的 FastAPI 应用中 +- 配置 Docker Workspace 实现更强的隔离 +- 自定义认证中间件替换默认的 `X-User-Id` Header +- 使用 `extra_credentials` 注册自定义 Credential 类型 +- 使用 `extra_agent_tools` 做按用户/租户的工具注入 +- 为 Session 配置 `knowledge_config` 或 `tts_model_config` + +## 下一期预告 + +**Tutorial 14: Schedule** — 配置定时任务,让 DataMuse 自动生成日报。 diff --git a/tutorials/13_agent_service/client.py b/tutorials/13_agent_service/client.py new file mode 100644 index 0000000..775d981 --- /dev/null +++ b/tutorials/13_agent_service/client.py @@ -0,0 +1,252 @@ +# -*- coding: utf-8 -*- +"""Tutorial 13: Python client walkthrough of the Agent Service. + +Runs against the FastAPI service started by `python main.py` in another +terminal. Walks the canonical 5-step flow: + + POST /credential/ → POST /agent/ → POST /sessions/ + → GET /sessions/{id}/stream + POST /chat/ + → GET /sessions/{id}/messages + +Prerequisites: + pip install httpx + python main.py # in another terminal, on http://localhost:8000 + +Notes +----- +The service-side Agent template only stores ``name`` + ``system_prompt`` + +optional ``context_config`` / ``react_config``. **Custom Python tools cannot +be attached through the Agent-create HTTP payload**. They can be provided by +the service host through ``extra_agent_tools`` or by the workspace +(``default_mcps`` / ``skill_paths``). In this tutorial the DataMuse persona +survives the HTTP boundary, ``SalesProfile`` / ``SalesBreakdown`` come from +``extra_agent_tools``, and the report_writer skill comes from +``LocalWorkspaceManager(skill_paths=...)`` in ``main.py``. +""" +# pylint: disable=missing-function-docstring +import asyncio +import json +import os +import sys + +import httpx + + +BASE_URL = os.getenv("AGENTSCOPE_SERVICE_URL", "http://localhost:8000") +USER_ID = os.getenv("AGENTSCOPE_USER_ID", "demo-user") +HEADERS = {"X-User-Id": USER_ID, "Content-Type": "application/json"} + + +def _pick_credential_payload() -> tuple[dict, str, str]: + """Detect which provider key is available and return a matching payload. + + Returns: (credential_payload, model_type, model_name) + """ + dashscope_key = os.getenv("DASHSCOPE_API_KEY") + openai_key = os.getenv("OPENAI_API_KEY") + if dashscope_key: + return ( + { + "data": { + "type": "dashscope_credential", + "api_key": dashscope_key, + }, + }, + "dashscope_chat", + "qwen-plus", + ) + if openai_key: + return ( + { + "data": { + "type": "openai_credential", + "api_key": openai_key, + }, + }, + "openai_chat", + "gpt-4o", + ) + print( + "ERROR: set DASHSCOPE_API_KEY or OPENAI_API_KEY before running the " + "client.", + file=sys.stderr, + ) + raise SystemExit(1) + + +async def step_1_create_credential( + client: httpx.AsyncClient, +) -> tuple[str, str, str]: + cred_body, model_type, model_name = _pick_credential_payload() + resp = await client.post("/credential/", json=cred_body, headers=HEADERS) + resp.raise_for_status() + credential_id = resp.json()["credential_id"] + print(f"[1] credential_id = {credential_id}") + return credential_id, model_type, model_name + + +async def step_2_create_agent(client: httpx.AsyncClient) -> str: + body = { + "name": "DataMuse", + "system_prompt": ( + "You are DataMuse, a sales-data analyst.\n" + "Use SalesProfile to inspect the server-side sales dataset and " + "SalesBreakdown for grouped analysis. Do not guess figures. " + "If asked to write a report, use the report_writer skill that " + "has been installed in the workspace." + ), + } + resp = await client.post("/agent/", json=body, headers=HEADERS) + resp.raise_for_status() + agent_id = resp.json()["agent_id"] + print(f"[2] agent_id = {agent_id}") + return agent_id + + +async def step_3_create_session( + client: httpx.AsyncClient, + agent_id: str, + credential_id: str, + model_type: str, + model_name: str, +) -> str: + body = { + "agent_id": agent_id, + "name": "DataMuse demo session", + "chat_model_config": { + "type": model_type, + "credential_id": credential_id, + "model": model_name, + "parameters": {}, + }, + } + resp = await client.post("/sessions/", json=body, headers=HEADERS) + resp.raise_for_status() + session_id = resp.json()["session_id"] + print(f"[3] session_id = {session_id}") + return session_id + + +async def step_4_chat( + client: httpx.AsyncClient, + agent_id: str, + session_id: str, + prompt: str, +) -> None: + body = { + "agent_id": agent_id, + "session_id": session_id, + "input": { + "name": "user", + "role": "user", + "content": [{"type": "text", "text": prompt}], + }, + } + print(f"\n[4] streaming reply for: {prompt!r}") + print("-" * 60) + + stream_url = f"/sessions/{session_id}/stream" + async with client.stream( + "GET", + stream_url, + params={"agent_id": agent_id}, + headers=HEADERS, + timeout=httpx.Timeout(60.0, read=None), + ) as resp: + resp.raise_for_status() + + trigger_resp = await client.post( + "/chat/", + json=body, + headers=HEADERS, + ) + trigger_resp.raise_for_status() + print(f" >> chat run {trigger_resp.json()['status']}") + + async for line in resp.aiter_lines(): + if not line.startswith("data:"): + continue + payload = line[len("data:") :].strip() # noqa: E203 + if not payload or payload == "[DONE]": + continue + event = json.loads(payload) + etype = event.get("type") + if etype == "TEXT_BLOCK_DELTA": + print(event.get("delta", ""), end="", flush=True) + elif etype == "TOOL_CALL_START": + print( + f"\n >> calling tool: {event.get('tool_call_name')}", + flush=True, + ) + elif etype == "TOOL_RESULT_END": + print( + f" >> tool finished: {event.get('state')}", + flush=True, + ) + elif etype == "REPLY_END": + print() + break + print("-" * 60) + + +async def step_5_list_messages( + client: httpx.AsyncClient, + agent_id: str, + session_id: str, +) -> None: + resp = await client.get( + f"/sessions/{session_id}/messages", + params={"agent_id": agent_id}, + headers=HEADERS, + ) + resp.raise_for_status() + data = resp.json() + msgs = data.get("messages", []) + print(f"\n[5] persisted messages: {len(msgs)}") + for msg in msgs: + role = msg.get("role") + content = msg.get("content") + if isinstance(content, list): + content = " | ".join( + block.get("text", str(block)) for block in content + ) + snippet = str(content)[:160].replace("\n", " ") + print(f" [{role}] {snippet}") + + +async def main() -> None: + print(f"Talking to {BASE_URL} as user {USER_ID!r}") + + async with httpx.AsyncClient(base_url=BASE_URL, timeout=30.0) as client: + cred_id, model_type, model_name = await step_1_create_credential( + client, + ) + agent_id = await step_2_create_agent(client) + session_id = await step_3_create_session( + client, + agent_id, + cred_id, + model_type, + model_name, + ) + await step_4_chat( + client, + agent_id, + session_id, + "Use SalesProfile to list the dataset columns and row count, " + "then use SalesBreakdown to summarize revenue by category.", + ) + await step_5_list_messages(client, agent_id, session_id) + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except httpx.ConnectError as exc: + print( + f"\nERROR: cannot reach {BASE_URL}.\n" + "Did you start the service in another terminal? " + "Run: python main.py", + file=sys.stderr, + ) + raise SystemExit(1) from exc diff --git a/tutorials/13_agent_service/main.py b/tutorials/13_agent_service/main.py new file mode 100644 index 0000000..651918c --- /dev/null +++ b/tutorials/13_agent_service/main.py @@ -0,0 +1,286 @@ +# -*- coding: utf-8 -*- +"""Tutorial 13: Agent Service — Deploy DataMuse as a shared service. + +This tutorial demonstrates: +- Using create_app() to build a FastAPI service +- Zero-dep storage via fakeredis (swap to real Redis when deployed) +- InMemoryMessageBus for local event delivery +- extra_agent_tools for injecting server-side DataMuse tools +- LocalWorkspaceManager with skill_paths to inject T06 skills +- The complete API flow: Credential → Agent → Session → Stream + Chat +- SSE streaming from the Session stream endpoint + +Two ways to drive the service: + - terminal A: python main.py (this file — starts the service) + - terminal B: python client.py (httpx walkthrough of the 5 steps) + +Or use the companion Web UI in examples/web_ui. + +Prerequisites: +- pip install "agentscope[service]" httpx +- pip install fakeredis # zero-dep in-memory storage +- DASHSCOPE_API_KEY (or OPENAI_API_KEY) in env +""" +# pylint: disable=import-outside-toplevel +import csv +import os +from pathlib import Path +from typing import Any + +from agentscope.message import TextBlock +from agentscope.permission import ( + PermissionBehavior, + PermissionContext, + PermissionDecision, +) +from agentscope.tool import ToolBase, ToolChunk + +TUTORIAL_DIR = Path(__file__).resolve().parent +REPO_ROOT = TUTORIAL_DIR.parent.parent +SALES_CSV = REPO_ROOT / "tutorials" / "data" / "sales_data.csv" + + +def _load_sales_rows() -> list[dict[str, str]]: + """Load the shared tutorial dataset on the service host.""" + with SALES_CSV.open("r", encoding="utf-8") as csv_file: + return list(csv.DictReader(csv_file)) + + +class SalesProfile(ToolBase): + """Return a compact profile of the shared sales dataset.""" + + name = "SalesProfile" + description = ( + "Inspect the sales dataset and return its row count, columns, date " + "range, and three sample rows." + ) + input_schema = {"type": "object", "properties": {}, "required": []} + is_concurrency_safe = True + is_read_only = True + + async def check_permissions( + self, + _tool_input: dict[str, Any], + _context: PermissionContext, + ) -> PermissionDecision: + """Allow this fixed, read-only server-side query.""" + return PermissionDecision( + behavior=PermissionBehavior.ALLOW, + message="SalesProfile only reads the tutorial dataset.", + ) + + async def call(self) -> ToolChunk: + """Profile the sales CSV.""" + rows = _load_sales_rows() + columns = list(rows[0]) if rows else [] + dates = sorted(row["date"] for row in rows) + lines = [ + "Sales data profile", + f"- rows: {len(rows)}", + f"- columns: {', '.join(columns)}", + ( + f"- date range: {dates[0]} to {dates[-1]}" + if dates + else "- date range: n/a" + ), + "- sample rows:", + ] + lines.extend(f" - {row}" for row in rows[:3]) + return ToolChunk(content=[TextBlock(text="\n".join(lines))]) + + +class SalesBreakdown(ToolBase): + """Aggregate sales by a supported business dimension.""" + + name = "SalesBreakdown" + description = ( + "Compute order count, revenue, and average order value grouped by " + "category, region, payment_method, or customer_tier." + ) + input_schema = { + "type": "object", + "properties": { + "group_by": { + "type": "string", + "enum": [ + "category", + "region", + "payment_method", + "customer_tier", + ], + "description": "Business dimension used for grouping.", + }, + }, + "required": ["group_by"], + } + is_concurrency_safe = True + is_read_only = True + + async def check_permissions( + self, + _tool_input: dict[str, Any], + _context: PermissionContext, + ) -> PermissionDecision: + """Allow this fixed, read-only server-side aggregation.""" + return PermissionDecision( + behavior=PermissionBehavior.ALLOW, + message="SalesBreakdown only reads the tutorial dataset.", + ) + + async def call(self, group_by: str) -> ToolChunk: + """Return a Markdown breakdown for the requested dimension.""" + rows = _load_sales_rows() + groups: dict[str, list[dict[str, str]]] = {} + for row in rows: + groups.setdefault(row[group_by], []).append(row) + + lines = [ + f"Sales breakdown by {group_by}", + "group | orders | revenue | avg_order", + "--- | ---: | ---: | ---:", + ] + for key, group_rows in sorted( + groups.items(), + key=lambda item: sum(float(row["total"]) for row in item[1]), + reverse=True, + ): + revenue = sum(float(row["total"]) for row in group_rows) + average = revenue / len(group_rows) + lines.append( + f"{key} | {len(group_rows)} | ${revenue:,.2f} | " + f"${average:,.2f}", + ) + + return ToolChunk(content=[TextBlock(text="\n".join(lines))]) + + +async def datamuse_tools( + _user_id: str, + _agent_id: str, + _session_id: str, +) -> list[ToolBase]: + """Build fresh DataMuse tools for each service-side Agent assembly.""" + return [SalesProfile(), SalesBreakdown()] + + +def _make_inmemory_storage() -> Any: + """Build a RedisStorage backed by an in-process fakeredis client. + + Same pattern AgentScope's own RedisStorage unit tests use — no Redis + server required, no extra StorageBase implementation to maintain. + """ + try: + import fakeredis.aioredis + except ImportError as missing: + raise ImportError( + "Tutorial 13 defaults to an in-memory store backed by fakeredis. " + "Install it with: pip install fakeredis\n" + "Or edit main.py to use RedisStorage(host=..., port=...).", + ) from missing + + from agentscope.app.storage import RedisStorage + + # pylint: disable=protected-access + # Mirrors the pattern in tests/storage_redis_test.py — we deliberately + # construct a bare RedisStorage and swap its backing client for fakeredis. + storage = RedisStorage.__new__(RedisStorage) + storage._client = fakeredis.aioredis.FakeRedis(decode_responses=True) + storage._external_pool = None + storage._owned_pool = None + storage.key_ttl = None + storage.key_config = RedisStorage.KeyConfig() + return storage + + +def create_service() -> tuple[Any, Any]: + """Create the AgentScope service application.""" + import uvicorn + from fastapi.middleware import Middleware + from fastapi.middleware.cors import CORSMiddleware + + from agentscope.app import create_app + from agentscope.app.message_bus import InMemoryMessageBus + from agentscope.app.workspace_manager import LocalWorkspaceManager + + basedir = str(TUTORIAL_DIR / "workspaces") + + # Seed every new workspace with T06's report_writer skill so the Agent + # can produce Markdown reports through a real Skill, not just by hand. + skill_dirs = [ + str( + REPO_ROOT / "tutorials" / "06_skills" / "skills" / "report_writer", + ), + ] + + app = create_app( + storage=_make_inmemory_storage(), + message_bus=InMemoryMessageBus(), + workspace_manager=LocalWorkspaceManager( + basedir=basedir, + skill_paths=skill_dirs, + ), + extra_agent_tools=datamuse_tools, + extra_middlewares=[ + Middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], + ), + ], + title="DataMuse Service", + version="1.0.0", + ) + + return app, uvicorn + + +def print_overview() -> None: + """Print where to go next once the service is running.""" + print( + """ +Tutorial 13: Agent Service +============================================================ +Storage : fakeredis (in-memory) — swap RedisStorage for deployment +Bus : InMemoryMessageBus — swap RedisMessageBus for workers +Skills : tutorials/06_skills/skills/report_writer injected +Tools : SalesProfile + SalesBreakdown injected by extra_agent_tools +Docs : http://localhost:8000/docs + +Drive the service in another terminal: + python client.py — Python httpx walkthrough (5 API calls) + +Or with curl: + Step 1 POST /credential/ Register a Credential + Step 2 POST /agent/ Create an Agent template + Step 3 POST /sessions/ Create a Session + bind a model + Step 4 GET /sessions/{id}/stream?agent_id=... + Step 5 POST /chat/ Trigger a reply + Step 6 GET /sessions/{id}/messages?agent_id=... + +Or with the official Web UI: + cd examples/web_ui && pnpm install && pnpm dev + Setup page: server http://localhost:8000, username demo-user + +Press Ctrl+C to stop. +""", + ) + + +if __name__ == "__main__": + print_overview() + + if not os.getenv("DASHSCOPE_API_KEY") and not os.getenv("OPENAI_API_KEY"): + print( + "WARNING: neither DASHSCOPE_API_KEY nor OPENAI_API_KEY is set. " + "The service will start but /chat calls will fail until you " + "register a working credential.\n", + ) + + try: + service_app, runner = create_service() + except ImportError as exc: + print(f"\nCannot start service: {exc}") + raise SystemExit(1) from exc + + runner.run(service_app, host="0.0.0.0", port=8000) diff --git a/tutorials/14_scheduling/README.md b/tutorials/14_scheduling/README.md new file mode 100644 index 0000000..698ac2d --- /dev/null +++ b/tutorials/14_scheduling/README.md @@ -0,0 +1,190 @@ +# Tutorial 14: Schedule — 定时任务与自动化 + +> **什么时候需要这个?** Agent 要按时间表自动跑——每天早上生成销售日报、每小时巡检、每周出分析报告——而不是等用户输入。无人值守场景下你还要处理"权限确认怎么办"和"要不要保留历史上下文"两个关键问题。 + +## 本章基于前序章节 + +- **T07 — `PermissionMode.DONT_ASK`**:无人值守时把 ASK 自动转为 DENY,必须靠 Allow 规则提前授权。 +- **T10 — Context 压缩**:Stateful 定时任务会持续累积上下文,必须配套压缩策略。 +- **T13 — `create_app()` / Session / DataMuse 工具**:本章的 `/schedule` 路由跑在 T13 启动的 Agent Service 上,并复用服务端注入的 `SalesProfile` / `SalesBreakdown`。 + +## 你将学到 + +- Schedule API 的使用方式 +- Cron 表达式驱动的定时任务 +- Stateful vs Stateless 模式 +- 定时任务的权限模式 +- 定时任务触发后如何查看 Session 和运行状态 +- 通过 API 管理定时任务 + +## 前置要求 + +- 完成 Tutorial 13 +- Agent Service 正常运行 +- `pip install "agentscope[service]" fakeredis httpx` + +## 核心概念 + +### 定时任务的价值 + +Agent 不只是等待用户输入——它可以按时间表自动执行任务: + +- 每天早上生成销售日报 +- 每小时检查系统健康状况 +- 每周生成分析报告 +- 定时清理临时文件 + +### Schedule API + +定时任务通过 Agent Service 的 `/schedule` 路由管理,也可以由 Agent 自己通过 `ScheduleCreate` 工具创建。当前服务端会把 schedule 的 `description` 作为触发时发送给 Agent 的任务输入,所以这里建议写成可直接执行的任务描述。 + +``` +POST /schedule ── 创建定时任务 +GET /schedule ── 列出所有定时任务 +GET /schedule/{id}/sessions ── 查看任务触发的会话 +DELETE /schedule/{id} ── 删除定时任务 +``` + +Schedule 只负责"按时间触发 Agent";触发后真正执行的是一个 Session。要看运行状态或事件流,继续使用 T13 的 Session API: + +``` +GET /sessions/{session_id}/status?agent_id=... +GET /sessions/{session_id}/stream?agent_id=... +GET /sessions/{session_id}/messages?agent_id=... +``` + +### 创建定时任务 + +```bash +curl -X POST http://localhost:8000/schedule \ + -H "X-User-Id: demo-user" \ + -H "Content-Type: application/json" \ + -d '{ + "agent_id": "", + "name": "Daily Sales Report", + "description": "Use SalesProfile and SalesBreakdown by category, then generate a 5-bullet sales summary.", + "cron_expression": "0 9 * * *", + "timezone": "Asia/Shanghai", + "chat_model_config": { + "type": "dashscope_chat", + "credential_id": "", + "model": "qwen-plus", + "parameters": {} + }, + "stateful": true, + "permission_mode": "dont_ask" + }' +``` + +### Cron 表达式 + +标准 5 字段 cron 表达式: + +``` +┌───────────── 分 (0-59) +│ ┌─────────── 时 (0-23) +│ │ ┌───────── 日 (1-31) +│ │ │ ┌─────── 月 (1-12) +│ │ │ │ ┌───── 周几 (0-6, 0=周日) +│ │ │ │ │ +* * * * * +``` + +| 表达式 | 含义 | +|--------|------| +| `0 9 * * *` | 每天早上 9 点 | +| `0 9 * * 1-5` | 工作日早上 9 点 | +| `*/30 * * * *` | 每 30 分钟 | +| `0 0 1 * *` | 每月 1 号零点 | + +### Stateful vs Stateless + +| | Stateful | Stateless | +|---|----------|-----------| +| 会话 | 每次触发复用同一个 Session | 每次触发创建新 Session | +| 上下文 | 保留之前的对话历史 | 无历史,从零开始 | +| 适用场景 | 趋势对比、持续监控 | 独立报告、一次性任务 | +| 内存 | 随时间增长(需要压缩) | 固定开销 | + +**Stateful 模式**:Agent 记住之前的分析结果,可以做趋势对比 + +``` +第 1 天:"上周总收入 $50,000" +第 2 天:"今天总收入 $55,000,环比增长 10%"(因为记得昨天的数据) +``` + +**Stateless 模式**:每次都是全新开始 + +``` +第 1 天:"总收入 $50,000" +第 2 天:"总收入 $55,000"(没有之前的对比) +``` + +### 权限模式 + +定时任务默认使用 `DONT_ASK` 模式(Tutorial 07),因为: + +- 用户不在场,无法回答确认提示 +- ASK 决策自动转为 DENY,防止阻塞 +- 必要操作必须由工具自身明确返回 ALLOW,或通过 Allow 规则预先授权 + +```python +# 定时任务的推荐配置 +{ + "permission_mode": "dont_ask", # 或 "bypass"(测试环境) +} +``` + +### Agent 自主创建定时任务 + +Agent 可以通过 `ScheduleCreate` 工具自己创建定时任务: + +``` +用户:帮我设置一个每天早上 9 点生成销售日报的定时任务 +Agent:好的,我来创建定时任务... + >> Calling: ScheduleCreate + >> {"name": "Daily Report", "cron_expression": "0 9 * * *", ...} +Agent:已创建定时任务 "Daily Report",每天 9:00 自动执行。 +``` + +## 示例:定时销售报告 + +本期展示如何通过 API 创建和管理定时任务,包括: + +1. 创建 Stateless 定时任务,每次调用 T13 的只读销售工具独立生成摘要 +2. 创建 Stateful 定时任务,在保留历史上下文的同时重新查询销售数据 +3. 查看和管理定时任务 + +## 运行示例 + +本期的 `main.py` 是一段真实 httpx 客户端代码,不再只是打印说明。直接依赖 T13 的服务(默认走 `fakeredis` 内存模式,**无需 Redis**): + +```bash +# 终端 A:启动服务 +cd tutorials/13_agent_service && python main.py + +# 终端 B:跑 T14 的 schedule CRUD 演示 +cd tutorials/14_scheduling && python main.py +``` + +执行后会顺序: +1. 注册 Credential + DataMuse Agent 模板(如果已有则复用) +2. `POST /schedule/` 创建一条 Stateless 任务(默认 cron `*/5 * * * *`) +3. `POST /schedule/` 创建一条 Stateful 任务(默认 cron `*/10 * * * *`) +4. `GET /schedule/` 列出所有任务 +5. `GET /schedule/{id}/sessions` 查看每个任务触发的会话 + +默认会在结尾 `DELETE` 清掉两条任务方便重复实验;想保留并等真正触发,加 `CLEANUP=0 python main.py`,几分钟后再访问 `GET /schedule/{id}/sessions` 就能看到自动执行的会话。 + +## 进一步探索 + +- 创建一个 Stateful 定时任务,观察多次触发后的上下文压缩 +- 用 BYPASS 模式运行定时任务,与 DONT_ASK 模式对比行为 +- 实现一个监控类定时任务:定期检查文件变化 +- 通过 API 动态启用/停用定时任务 + +> 无人值守跑久了,模型限流或临时挂掉是必发生的事。Schedule 创建时至少要选一个稳定的 `chat_model_config`;如果是普通 Session,可以按 T13 的方式额外配置 `fallback_chat_model_config`。详见 **[T13 → 模型 fallback 与自动重试](../13_agent_service/README.md#模型-fallback-与自动重试)**。 + +## 下一期预告 + +**Tutorial 15: Multi-Agent** — 多 Agent 协作,实现数据采集 → 分析 → 报告的流水线。 diff --git a/tutorials/14_scheduling/main.py b/tutorials/14_scheduling/main.py new file mode 100644 index 0000000..125e6c9 --- /dev/null +++ b/tutorials/14_scheduling/main.py @@ -0,0 +1,279 @@ +# -*- coding: utf-8 -*- +"""Tutorial 14: Scheduling — drive the /schedule API from Python. + +Walks the full CRUD for the Schedule router that ships with +``agentscope.app.create_app``: + + POST /credential/ — register a Credential (idempotent) + POST /agent/ — register a DataMuse Agent (idempotent) + POST /schedule/ — create a Stateless schedule + POST /schedule/ — create a Stateful schedule + GET /schedule/ — list schedules + GET /schedule/{id}/sessions — list sessions triggered by a schedule + DELETE /schedule/{id} — clean up + +Prerequisites +------------- +* Terminal A: ``cd tutorials/13_agent_service && python main.py`` + (fakeredis-backed service on http://localhost:8000) +* Terminal B: ``python main.py`` (this file) +* DASHSCOPE_API_KEY or OPENAI_API_KEY in env + +Notes +----- +We use a 5-minute cron so a curious reader can wait a bit and watch a real +trigger. Change ``CRON_STATELESS`` to ``"*/1 * * * *"`` if you want it sooner. +""" +# pylint: disable=missing-function-docstring +import asyncio +import json +import os +import sys + +import httpx + + +BASE_URL = os.getenv("AGENTSCOPE_SERVICE_URL", "http://localhost:8000") +USER_ID = os.getenv("AGENTSCOPE_USER_ID", "demo-user") +HEADERS = {"X-User-Id": USER_ID, "Content-Type": "application/json"} + +CRON_STATELESS = "*/5 * * * *" # every 5 minutes +CRON_STATEFUL = "*/10 * * * *" # every 10 minutes + + +def _pick_credential_payload() -> tuple[dict, str, str]: + """Pick whichever provider has its API key in the environment.""" + if os.getenv("DASHSCOPE_API_KEY"): + return ( + { + "data": { + "type": "dashscope_credential", + "api_key": os.environ["DASHSCOPE_API_KEY"], + }, + }, + "dashscope_chat", + "qwen-plus", + ) + if os.getenv("OPENAI_API_KEY"): + return ( + { + "data": { + "type": "openai_credential", + "api_key": os.environ["OPENAI_API_KEY"], + }, + }, + "openai_chat", + "gpt-4o", + ) + print( + "ERROR: set DASHSCOPE_API_KEY or OPENAI_API_KEY first.", + file=sys.stderr, + ) + raise SystemExit(1) + + +async def _ensure_credential( + client: httpx.AsyncClient, +) -> tuple[str, str, str]: + cred_body, model_type, model_name = _pick_credential_payload() + resp = await client.post("/credential/", json=cred_body, headers=HEADERS) + resp.raise_for_status() + credential_id = resp.json()["credential_id"] + print(f" credential_id = {credential_id}") + return credential_id, model_type, model_name + + +async def _ensure_agent(client: httpx.AsyncClient) -> str: + body = { + "name": "DataMuse", + "system_prompt": ( + "You are DataMuse, a sales-data analyst running unattended on a " + "schedule. Use SalesProfile and SalesBreakdown to inspect the " + "server-side dataset, then summarize the headline numbers in " + "5 bullet points. Do not guess figures or request file paths." + ), + } + resp = await client.post("/agent/", json=body, headers=HEADERS) + resp.raise_for_status() + agent_id = resp.json()["agent_id"] + print(f" agent_id = {agent_id}") + return agent_id + + +async def create_stateless_schedule( + client: httpx.AsyncClient, + agent_id: str, + credential_id: str, + model_type: str, + model_name: str, +) -> str: + body = { + "name": "Daily Sales Summary", + "description": ( + "Use SalesProfile, then SalesBreakdown by category, and produce " + "a 5-bullet headline sales summary." + ), + "cron_expression": CRON_STATELESS, + "timezone": "Asia/Shanghai", + "agent_id": agent_id, + "chat_model_config": { + "type": model_type, + "credential_id": credential_id, + "model": model_name, + "parameters": {}, + }, + "stateful": False, + "permission_mode": "dont_ask", + } + resp = await client.post("/schedule/", json=body, headers=HEADERS) + resp.raise_for_status() + schedule_id = resp.json()["schedule_id"] + print(f" [stateless] schedule_id = {schedule_id} ({CRON_STATELESS})") + return schedule_id + + +async def create_stateful_schedule( + client: httpx.AsyncClient, + agent_id: str, + credential_id: str, + model_type: str, + model_name: str, +) -> str: + body = { + "name": "Sales Trend Tracker", + "description": ( + "Use SalesProfile and SalesBreakdown by region. Re-run the same " + "session so DataMuse can compare with its previous summary and " + "call out changes." + ), + "cron_expression": CRON_STATEFUL, + "timezone": "Asia/Shanghai", + "agent_id": agent_id, + "chat_model_config": { + "type": model_type, + "credential_id": credential_id, + "model": model_name, + "parameters": {}, + }, + "stateful": True, + "permission_mode": "dont_ask", + } + resp = await client.post("/schedule/", json=body, headers=HEADERS) + resp.raise_for_status() + schedule_id = resp.json()["schedule_id"] + print(f" [stateful] schedule_id = {schedule_id} ({CRON_STATEFUL})") + return schedule_id + + +async def list_schedules(client: httpx.AsyncClient) -> None: + resp = await client.get("/schedule/", headers=HEADERS) + resp.raise_for_status() + data = resp.json() + print(f" total = {data['total']}") + for record in data["schedules"]: + d = record["data"] + print( + f" - {record['id'][:8]} " + f"{d['name']!r} cron={d['cron_expression']} " + f"stateful={d['stateful']} enabled={d['enabled']}", + ) + + +async def list_sessions_for_schedule( + client: httpx.AsyncClient, + schedule_id: str, +) -> None: + resp = await client.get( + f"/schedule/{schedule_id}/sessions", + headers=HEADERS, + ) + resp.raise_for_status() + data = resp.json() + print( + f" schedule {schedule_id[:8]} triggered_sessions = {data['total']}", + ) + if data["total"] == 0: + print( + " (none yet — wait for the next cron tick, " + "or shorten the cron expression at the top of this file)", + ) + + +async def delete_schedule(client: httpx.AsyncClient, schedule_id: str) -> None: + resp = await client.delete(f"/schedule/{schedule_id}", headers=HEADERS) + if resp.status_code not in (200, 204): + print(f" WARN: delete returned {resp.status_code}: {resp.text}") + return + print(f" deleted {schedule_id[:8]}") + + +async def main() -> None: + print(f"Talking to {BASE_URL} as user {USER_ID!r}") + + async with httpx.AsyncClient(base_url=BASE_URL, timeout=30.0) as client: + print("\n[1/5] ensure credential + agent") + cred_id, model_type, model_name = await _ensure_credential(client) + agent_id = await _ensure_agent(client) + + print("\n[2/5] create a stateless schedule") + stateless_id = await create_stateless_schedule( + client, + agent_id, + cred_id, + model_type, + model_name, + ) + + print("\n[3/5] create a stateful schedule") + stateful_id = await create_stateful_schedule( + client, + agent_id, + cred_id, + model_type, + model_name, + ) + + print("\n[4/5] list every schedule we own") + await list_schedules(client) + + print("\n[5/5] peek at triggered sessions for each schedule") + await list_sessions_for_schedule(client, stateless_id) + await list_sessions_for_schedule(client, stateful_id) + + cleanup = os.getenv("CLEANUP", "1") != "0" + if cleanup: + print( + "\n[cleanup] delete the two schedules (set CLEANUP=0 to keep)", + ) + await delete_schedule(client, stateless_id) + await delete_schedule(client, stateful_id) + else: + print( + "\n[cleanup] skipped (CLEANUP=0). " + "Leave the schedules running and re-run " + "list_sessions_for_schedule after a few minutes to see " + "real trigger output.", + ) + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except httpx.ConnectError as exc: + print( + f"\nERROR: cannot reach {BASE_URL}. Start the service first:\n" + " cd ../13_agent_service && python main.py", + file=sys.stderr, + ) + raise SystemExit(1) from exc + except httpx.HTTPStatusError as exc: + print( + f"\nERROR: {exc.request.method} {exc.request.url} " + f"returned {exc.response.status_code}", + file=sys.stderr, + ) + try: + print(json.dumps(exc.response.json(), indent=2), file=sys.stderr) + except Exception: + print(exc.response.text, file=sys.stderr) + raise SystemExit(1) from exc diff --git a/tutorials/15_multi_agent/README.md b/tutorials/15_multi_agent/README.md new file mode 100644 index 0000000..22397ec --- /dev/null +++ b/tutorials/15_multi_agent/README.md @@ -0,0 +1,158 @@ +# Tutorial 15: Multi-Agent — 多 Agent 协作 + +> **什么时候需要这个?** 单 Agent 的工具太多导致上下文撑爆、LLM 选错工具,或者任务可以清晰拆成几个角色(采集 / 分析 / 写报告)。多 Agent 协作让每个 Agent 只关心自己那一摊;库模式下用 Python 编排,服务模式下也可以交给 Team tools 编排。 + +## 本章基于前序章节 + +- **T01 — Agent / `reply` / `reply_stream`**:每个角色都是一个独立 Agent 实例。 +- **T03 — 工具系统**:不同角色配不同工具集(Collector 配 `query_sales`,Writer 配 `Bash` 写文件等)。 +- **T02 — `Msg`**:Agent 之间通过传递消息接力,`observe()` 用来注入背景但不触发推理。 + +## 你将学到 + +- AgentScope 2.0 的多 Agent 设计思路 +- `observe()` 方法:无推理的消息注入 +- 多 Agent 编排模式:串行、并行、动态路由 +- Agent 间的消息传递和结果接力 +- Python 编排与 Agent Service Team tools 的边界 + +## 前置要求 + +- 完成 Tutorial 01-14 +- 理解 Agent 的 reply、reply_stream、observe API + +## 核心概念 + +### 为什么需要多 Agent? + +单个 Agent 虽然能力强大,但在复杂任务中会遇到瓶颈: + +- **上下文爆炸**:同时处理数据采集、分析、可视化时,上下文迅速膨胀 +- **工具冲突**:太多工具让 LLM 选择困难 +- **职责不清**:一个 Agent 同时承担多个角色效率低 + +多 Agent 的解决方案:每个 Agent 专注一个职责,通过编排逻辑协作。 + +但“任务有多个步骤”本身不是拆分理由。默认先用一个 Agent 加清晰工具完成任务;只有至少出现下面一种情况时,再考虑 Multi-Agent: + +- 不同角色需要明显不同的工具、权限或系统提示 +- 某些子任务可以并行,且并行收益足以覆盖通信成本 +- 单 Agent 的工具 Schema 或上下文已经过大,影响选择和推理稳定性 +- 业务上需要独立的责任边界,例如采集结果必须交给另一个角色审核 + +如果多个角色只是顺序复述同一份上下文,拆分通常只会增加模型调用、延迟和调试难度。 + +### AgentScope 的多 Agent 设计 + +AgentScope 2.0 的库模式不强制你使用某个"编排框架"——它提供**消息传递原语**,让你用 Python 代码实现编排: + +```python +# Agent 之间通过消息传递协作 +result = await agent_a.reply(user_msg) # A 处理 +await agent_b.observe(result) # B 接收 A 的结果(不触发推理) +final = await agent_b.reply(follow_up_msg) # B 基于上下文推理 +``` + +### observe() vs reply() + +| 方法 | 行为 | 用途 | +|------|------|------| +| `reply(msg)` | 接收消息 → 触发推理 → 返回回复 | 需要 Agent 思考和行动 | +| `observe(msg)` | 接收消息 → 仅存入上下文 | 提供背景信息,不触发推理 | + +`observe()` 是多 Agent 协作的关键:它让 Agent 获得上下文信息,而不需要立即响应。 + +### 库模式 vs 服务模式 + +| 模式 | 核心机制 | 什么时候用 | +|------|----------|------------| +| Python 编排 | `reply()` / `observe()` / `asyncio.gather()` | 单脚本、Notebook、你希望业务代码明确控制流程 | +| Agent Service Team | `TeamCreate` / `AgentCreate` / `AgentInvite` / `TeamSay` | 多用户服务、需要 leader agent 动态创建或邀请 worker | + +本章主线仍然使用 Python 编排,因为它最透明,方便学生看清 Agent 之间怎么传递上下文。T13 的 Agent Service 已经内置 Team tools;如果你把多 Agent 放到服务端,leader session 会拿到 `TeamCreate`、`AgentCreate`、`TeamSay` 等工具,worker 通过 `TeamSay` 汇报结果。 + +### 三种编排模式 + +#### 1. 串行流水线 + +``` +User → Agent A → Agent B → Agent C → Result + │ │ │ + 采集数据 分析数据 生成报告 +``` + +```python +data = await collector.reply(user_msg) +await analyst.observe(data) +analysis = await analyst.reply(analyze_msg) +await writer.observe(analysis) +report = await writer.reply(write_msg) +``` + +#### 2. 并行分支 + +``` + ┌→ Agent B (分析 A) ─┐ +User → Agent A ─┤ ├→ Agent D (汇总) + └→ Agent C (分析 B) ─┘ +``` + +```python +data = await collector.reply(user_msg) +await analyst_a.observe(data) +await analyst_b.observe(data) +result_a, result_b = await asyncio.gather( + analyst_a.reply(task_a_msg), + analyst_b.reply(task_b_msg), +) +await summarizer.observe(result_a) +await summarizer.observe(result_b) +summary = await summarizer.reply(summarize_msg) +``` + +#### 3. 动态路由 + +``` +User → Router Agent ──┬→ Agent A (简单任务) + ├→ Agent B (复杂任务) + └→ Agent C (特殊任务) +``` + +```python +routing = await router.reply(user_msg) +route = parse_route(routing) + +if route == "simple": + result = await simple_agent.reply(user_msg) +elif route == "complex": + result = await complex_agent.reply(user_msg) +``` + +## 示例:DataMuse 团队 + +本期把 DataMuse 展开为一个**可选的团队化形态**,创建三个角色: + +1. **DataMuse_Collector** — 数据采集员,配备 Read、Glob、query_sales 工具 +2. **DataMuse_Analyst** — 数据分析师,配备 SalesSummary 和 Bash 工具 +3. **DataMuse_Writer** — 报告撰写员,配备 Bash 工具(用于写文件) + +编排流程:用户提出分析需求 → Collector 采集 → Analyst 分析 → Writer 出报告 + +## 运行示例 + +```bash +cd tutorials/15_multi_agent +python main.py +``` + +## 进一步探索 + +- 实现并行分析:让 RegionAnalyst 和 CategoryAnalyst 同时工作 +- 添加动态路由:根据用户请求复杂度选择不同的处理流程 +- 创建一个"审核员" Agent,检查报告质量并决定是否需要重新分析 +- 用 Middleware 实现 Agent 间通信的日志追踪 +- 在 Agent Service 中用 `TeamCreate` + `AgentCreate` 复刻本章流水线 + +## 下一期预告 + +**Tutorial 16: Complete DataMuse** — 回到一个自包含应用,把核心模块组装成可运行的命令行和轻量 Web 版本。T15 的团队化方案是扩展路径,不是 T16 的必选依赖。 diff --git a/tutorials/15_multi_agent/main.py b/tutorials/15_multi_agent/main.py new file mode 100644 index 0000000..d541167 --- /dev/null +++ b/tutorials/15_multi_agent/main.py @@ -0,0 +1,489 @@ +# -*- coding: utf-8 -*- +"""Tutorial 15: Multi-Agent — Collaborative agent teams. + +This tutorial demonstrates: +- Multiple specialized agents working together +- observe() for context injection without triggering reasoning +- Sequential pipeline pattern (Collector → Analyst → Writer) +- Parallel branch pattern with asyncio.gather +- Message passing between agents +""" +# pylint: disable=missing-function-docstring,unused-argument +import asyncio +import csv +import os +from pathlib import Path +from typing import Any + +from agentscope.agent import Agent +from agentscope.credential import DashScopeCredential +from agentscope.event import EventType +from agentscope.message import UserMsg, AssistantMsg, TextBlock +from agentscope.model import DashScopeChatModel +from agentscope.permission import ( + PermissionBehavior, + PermissionContext, + PermissionDecision, + PermissionMode, +) +from agentscope.state import AgentState +from agentscope.tool import ( + Toolkit, + ToolBase, + ToolChunk, + FunctionTool, + Bash, + Read, + Glob, + Grep, +) + +DATA_DIR = Path(__file__).resolve().parent.parent / "data" +SALES_CSV = DATA_DIR / "sales_data.csv" + + +# ========================================================================= +# Tools +# ========================================================================= +def query_sales( + category: str = "", + region: str = "", + limit: int = 10, +) -> ToolChunk: + """Query and filter the sales dataset. + + Args: + category: Product category to filter. Empty means no filter. + region: Region to filter. Empty means no filter. + limit: Maximum number of rows to return. + """ + rows = [] + with open(SALES_CSV, "r", encoding="utf-8") as f: + for row in csv.DictReader(f): + if category and row["category"] != category: + continue + if region and row["region"] != region: + continue + rows.append(row) + if len(rows) >= limit: + break + if not rows: + return ToolChunk( + content=[TextBlock(text="No matching records found.")], + ) + header = " | ".join(rows[0].keys()) + lines = [header, "-" * len(header)] + for row in rows: + lines.append(" | ".join(row.values())) + return ToolChunk( + content=[ + TextBlock(text=f"Found {len(rows)} records:\n" + "\n".join(lines)), + ], + ) + + +class SalesSummary(ToolBase): + """Compute aggregate statistics on the sales dataset.""" + + name = "SalesSummary" + description = ( + "Compute summary statistics (count, total revenue, avg) for the " + "sales dataset, optionally grouped by a column." + ) + input_schema = { + "type": "object", + "properties": { + "group_by": { + "type": "string", + "description": "Column to group by. Empty for overall.", + "default": "", + }, + }, + "required": [], + } + is_concurrency_safe = True + is_read_only = True + + async def check_permissions( + self, + tool_input: dict[str, Any], + context: PermissionContext, + ) -> PermissionDecision: + return PermissionDecision( + behavior=PermissionBehavior.ALLOW, + message="Read-only analytics, always allowed.", + ) + + async def call(self, group_by: str = "") -> ToolChunk: + rows = [] + with open(SALES_CSV, "r", encoding="utf-8") as f: + for row in csv.DictReader(f): + rows.append(row) + + if not group_by: + total = sum(float(r["total"]) for r in rows) + avg = total / len(rows) if rows else 0 + return ToolChunk( + content=[ + TextBlock( + text=f"Overall: {len(rows)} orders, " + f"${total:,.2f} revenue, ${avg:,.2f} avg", + ), + ], + ) + + groups: dict[str, list] = {} + for row in rows: + groups.setdefault(row.get(group_by, "?"), []).append(row) + + lines = [f"Summary by '{group_by}':"] + for key in sorted(groups): + g = groups[key] + rev = sum(float(r["total"]) for r in g) + lines.append(f" {key}: {len(g)} orders, ${rev:,.2f}") + return ToolChunk(content=[TextBlock(text="\n".join(lines))]) + + +# ========================================================================= +# Stream + print helper +# ========================================================================= +async def agent_reply(agent: Agent, content: str) -> str: + """Send a message to an agent, stream events, return text response.""" + msg = UserMsg(name="user", content=content) + print(f"\n [{agent.name}] Processing...", end="", flush=True) + + text_parts = [] + async for event in agent.reply_stream(msg): + match event.type: + case EventType.TEXT_BLOCK_DELTA: + text_parts.append(event.delta) + case EventType.TOOL_CALL_START: + print(f"\n >> {event.tool_call_name}", end="", flush=True) + case EventType.TOOL_RESULT_END: + print(f" [{event.state}]", end="", flush=True) + case EventType.REPLY_END: + pass + + text = "".join(text_parts) + preview = text[:120].replace("\n", " ") + print(f"\n [{agent.name}] Done: {preview}...") + return text + + +# ========================================================================= +# Agent factory +# ========================================================================= +def create_agents(model): + """Create the three-agent DataMuse team.""" + bypass = AgentState( + permission_context=PermissionContext( + mode=PermissionMode.BYPASS, + ), + ) + + collector = Agent( + name="DataMuse_Collector", + system_prompt=( + "You are DataMuse_Collector, a member of the DataMuse team. " + "Your role is gathering raw sales data using query tools and " + "presenting it clearly with numbers. Stay focused on data — no " + "analysis or recommendations. Downstream teammates " + "(DataMuse_Analyst, DataMuse_Writer) will turn your output into " + "the final report." + ), + model=model, + toolkit=Toolkit( + tools=[ + Read(), + Glob(), + Grep(), + FunctionTool(query_sales, is_read_only=True), + ], + ), + state=bypass, + ) + + analyst = Agent( + name="DataMuse_Analyst", + system_prompt=( + "You are DataMuse_Analyst, a member of the DataMuse team. " + "DataMuse_Collector hands you raw rows; you compute insights — " + "trends, comparisons, rankings, anomalies — using SalesSummary " + "for aggregations. Provide specific numbers and percentages, " + "and keep responses concise so DataMuse_Writer can turn them " + "into a report." + ), + model=model, + toolkit=Toolkit( + tools=[SalesSummary(), Bash()], + ), + state=bypass, + ) + + writer = Agent( + name="DataMuse_Writer", + system_prompt=( + "You are DataMuse_Writer, the report-writing member of the " + "DataMuse team. DataMuse_Analyst hands you analysis results; " + "you turn them into clear, structured markdown summaries with " + "key findings and actionable insights. Keep reports brief " + "(under 200 words)." + ), + model=model, + toolkit=Toolkit(tools=[]), + state=bypass, + ) + + return collector, analyst, writer + + +# ========================================================================= +# Example 1: Sequential pipeline +# ========================================================================= +async def example_sequential_pipeline(model) -> None: + """Three agents in a sequential pipeline.""" + print("\n" + "=" * 60) + print("Example 1: Sequential Pipeline") + print(" User → DataMuse_Collector → DataMuse_Analyst → DataMuse_Writer") + print("=" * 60) + + collector, analyst, writer = create_agents(model) + + # Step 1: Collector gathers data + print("\n Step 1: DataMuse_Collector gathers data") + collected_data = await agent_reply( + collector, + "Collect sales data: query 10 Electronics orders and 10 " + "Clothing orders. Show all records.", + ) + + # Step 2: Analyst receives data and analyzes + print( + "\n Step 2: DataMuse_Analyst analyzes (receives collector's output)", + ) + # Use observe() to inject the collector's output as context + collector_msg = AssistantMsg( + name="DataMuse_Collector", + content=collected_data, + ) + await analyst.observe(collector_msg) + + analysis = await agent_reply( + analyst, + "Based on the collected data above, compare Electronics vs " + "Clothing: which has higher revenue? Use SalesSummary grouped " + "by category for precise numbers.", + ) + + # Step 3: Writer receives analysis and creates report + print("\n Step 3: DataMuse_Writer creates final report") + analyst_msg = AssistantMsg( + name="DataMuse_Analyst", + content=analysis, + ) + await writer.observe(analyst_msg) + + report = await agent_reply( + writer, + "Write a brief analysis report based on the data analysis above. " + "Include key findings and one recommendation.", + ) + + print("\n" + "─" * 40) + print(" Final Report:") + print("─" * 40) + print(report) + + +# ========================================================================= +# Example 2: Parallel branches +# ========================================================================= +async def example_parallel_branches(model) -> None: + """Two analysts work in parallel, then results are merged.""" + print("\n" + "=" * 60) + print("Example 2: Parallel Branches") + print(" Collector → [Analyst A, Analyst B] → Writer") + print("=" * 60) + + collector, _, writer = create_agents(model) + + # Create two specialized analysts + bypass = AgentState( + permission_context=PermissionContext( + mode=PermissionMode.BYPASS, + ), + ) + + region_analyst = Agent( + name="DataMuse_RegionAnalyst", + system_prompt=( + "You are DataMuse_RegionAnalyst, a parallel-branch member of " + "the DataMuse team. Analyze sales data by region — identify the " + "top and bottom performing regions, with specific numbers. " + "DataMuse_Writer will merge your output with " + "DataMuse_CategoryAnalyst's." + ), + model=model, + toolkit=Toolkit(tools=[SalesSummary()]), + state=bypass, + ) + + category_analyst = Agent( + name="DataMuse_CategoryAnalyst", + system_prompt=( + "You are DataMuse_CategoryAnalyst, a parallel-branch member of " + "the DataMuse team. Analyze sales data by category — identify " + "the top and bottom performing categories, with specific " + "numbers. DataMuse_Writer will merge your output with " + "DataMuse_RegionAnalyst's." + ), + model=model, + toolkit=Toolkit(tools=[SalesSummary()]), + state=bypass, + ) + + # Step 1: Collect data + print("\n Step 1: Collect data") + data = await agent_reply( + collector, + f"Read the first 5 lines of {SALES_CSV} to show the data " + "structure.", + ) + + # Step 2: Two analysts work in parallel + print("\n Step 2: Two analysts work in parallel") + data_msg = AssistantMsg(name="DataMuse_Collector", content=data) + await region_analyst.observe(data_msg) + await category_analyst.observe(data_msg) + + region_result, category_result = await asyncio.gather( + agent_reply( + region_analyst, + "Analyze sales by region using SalesSummary. Which region " + "performs best?", + ), + agent_reply( + category_analyst, + "Analyze sales by category using SalesSummary. Which " + "category has the highest revenue?", + ), + ) + + # Step 3: Writer merges results + print("\n Step 3: Writer merges parallel results") + await writer.observe( + AssistantMsg(name="DataMuse_RegionAnalyst", content=region_result), + ) + await writer.observe( + AssistantMsg(name="DataMuse_CategoryAnalyst", content=category_result), + ) + + report = await agent_reply( + writer, + "Combine the region analysis and category analysis above " + "into a unified summary. Highlight the top performers.", + ) + + print("\n" + "─" * 40) + print(" Merged Report:") + print("─" * 40) + print(report) + + +# ========================================================================= +# Example 3: Architecture overview +# ========================================================================= +async def example_architecture() -> None: + """Display multi-agent architecture patterns.""" + print("\n" + "=" * 60) + print("Example 3: Multi-Agent Architecture Patterns") + print("=" * 60) + + print( + """ + Key Methods: + ──────────── + reply(msg) → Triggers reasoning + acting, returns response + observe(msg) → Injects message into context (no reasoning) + + Pattern 1: Sequential Pipeline + ─────────────────────────────── + data = await collector.reply(user_msg) + + await analyst.observe(data_as_msg) # inject context + analysis = await analyst.reply(task_msg) # then reason + + await writer.observe(analysis_as_msg) + report = await writer.reply(task_msg) + + Pattern 2: Parallel Branches + ──────────────────────────── + data = await collector.reply(user_msg) + + await analyst_a.observe(data) + await analyst_b.observe(data) + + result_a, result_b = await asyncio.gather( + analyst_a.reply(task_a), + analyst_b.reply(task_b), + ) + + await summarizer.observe(result_a) + await summarizer.observe(result_b) + summary = await summarizer.reply(merge_task) + + Pattern 3: Dynamic Routing + ────────────────────────── + routing = await router.reply(user_msg) + target = parse_route(routing) + + if target == "simple": + result = await fast_agent.reply(user_msg) + else: + result = await thorough_agent.reply(user_msg) + + Design Principles: + ────────────────── + • Each agent has a focused role and minimal tools + • Use observe() for context sharing (no unnecessary reasoning) + • Python code IS the orchestration (no framework needed) + • Parallel branches with asyncio.gather for speed +""", + ) + + +# ========================================================================= +# Main +# ========================================================================= +async def main() -> None: + print("Tutorial 15: Multi-Agent Collaboration") + print("=" * 60) + + if not SALES_CSV.exists(): + print(f"ERROR: {SALES_CSV} not found.") + print("Run: cd tutorials/data && python generate_sales_data.py") + return + + model = DashScopeChatModel( + credential=DashScopeCredential( + api_key=os.environ["DASHSCOPE_API_KEY"], + ), + model="qwen-plus", + ) + + # Example 1: Sequential pipeline + await example_sequential_pipeline(model) + + # Example 2: Parallel branches + await example_parallel_branches(model) + + # Example 3: Architecture overview + await example_architecture() + + print("\n" + "=" * 60) + print("Tutorial 15 complete! Next: Tutorial 16 — Complete DataMuse") + print("=" * 60) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tutorials/16_complete_datamuse/README.md b/tutorials/16_complete_datamuse/README.md new file mode 100644 index 0000000..37a766d --- /dev/null +++ b/tutorials/16_complete_datamuse/README.md @@ -0,0 +1,180 @@ +# Tutorial 16: Complete DataMuse — 最终整合 + +本章把 T01-T12 中适合放进单体应用的核心模块合成一个可运行的 DataMuse:它会读取同一份销售数据,做数据概览和维度拆解,在写入报告前触发确认事件,并把最终 Markdown 报告保存到本地 workspace 中。 + +> **什么时候需要这个?** 你学完本地 Agent 的核心模块后,想看一个端到端、最小但完整的应用如何组装出来——既能在命令行里跑流程,也能在浏览器里跑流式对话。 + +## 本章基于前序章节 + +- **T01 — Agent / Model**:DataMuse 的推理主体 +- **T02 — Event 流**:`reply_stream` 推送的事件,前端用来实时渲染 +- **T03 — 自定义 `ToolBase`**:本章的 `SalesProfile` / `SalesBreakdown` / `ReportWriter` +- **T07 — Permission ASK 行为**:读数据自动 ALLOW,写报告触发 ASK +- **T08 — `UserConfirmResultEvent`**:把前端用户的确认结果送回 Agent +- **T09 — 流式 UI 渲染**:Web UI 复用 T09 的事件分发思路 +- **T10 — `ContextConfig.tool_result_limit`**:截断过长的工具结果 +- **T11 — Middleware**:`TimingMiddleware` 记录每轮耗时 +- **T12 — `LocalWorkspace`**:作为报告的落地空间和 Offloader + +T13-T15 是同一业务案例的另外两条扩展路径,不是本章必须嵌入的运行时:T13-T14 把 DataMuse 变成多用户服务和定时任务,T15 在确有必要时把单 Agent 展开成团队。本章选择保留一个容易读、容易跑的自包含应用。 + +## 你将学到 + +- 如何把自定义分析工具组织成一个完整 Data Agent +- 如何在一个入口里同时使用事件流、权限确认、ContextConfig、Middleware 和 LocalWorkspace +- 如何把"分析过程"和"分析产物"都落到同一个工作区 +- 如何用一个极简的 FastAPI + HTML 构建浏览器交互界面 +- 如何从教程 demo 过渡到可复用的应用骨架 + +## 前置要求 + +- 建议完成 Tutorial 01-12 +- T13-T15 可选:用于理解服务化、调度和团队化扩展 +- Python 3.12 +- 安装 AgentScope:`pip install agentscope` +- 准备好 `tutorials/data/sales_data.csv` +- 设置 `DASHSCOPE_API_KEY` 或 `OPENAI_API_KEY` + +## 两种运行模式 + +### 模式 A:命令行 Demo + +最快的方式,直接在终端跑完整流程: + +```bash +cd tutorials/16_complete_datamuse +python main.py +``` + +运行后会看到: + +1. DataMuse 先调用 `SalesProfile` 检查数据结构和样例。 +2. 再调用 `SalesBreakdown` 分别按 category、region、payment_method、customer_tier 做拆解。 +3. 当它准备调用 `ReportWriter` 写报告时,事件流会出现 `REQUIRE_USER_CONFIRM`。 +4. 本教程为了开箱即用会自动确认;真实场景中可以由用户手动确认。 +5. 报告会写入 `workspace/reports/`。 + +### 模式 B:浏览器 Web UI + +用浏览器交互,体验流式输出 + 权限确认弹窗。无需 Redis 或 Node.js: + +```bash +cd tutorials/16_complete_datamuse +pip install uvicorn fastapi +python serve.py +``` + +打开 http://localhost:8000,你会看到一个简洁的对话界面。试试输入: + +``` +Analyze sales by category and region, then write a report. +``` + +Web UI 会实时展示: +- 流式文本生成 +- 工具调用卡片(名称 + 参数) +- 工具执行结果 +- **权限确认弹窗**(ReportWriter 写文件时触发,你可以选择 Allow 或 Deny) + +``` +┌─────────────────────────────────────────────┐ +│ DataMuse - Sales Analyst [header] │ +├─────────────────────────────────────────────┤ +│ │ +│ [user message] ────► 右对齐蓝色气泡│ +│ │ +│ [tool call card] ────► 工具名 + 参数 │ +│ [tool result] ────► 执行结果摘要 │ +│ │ +│ ┌─ Permission Required ──────────────┐ │ +│ │ ReportWriter │ │ +│ │ {"title": "...", "markdown": ...} │ │ +│ │ [Allow] [Deny] │ │ +│ └────────────────────────────────────┘ │ +│ │ +│ [assistant response] ────► 左对齐白色气泡│ +│ │ +├─────────────────────────────────────────────┤ +│ [input box] [Send] │ +└─────────────────────────────────────────────┘ +``` + +## 技术实现 + +### serve.py 做了什么 + +这里的 `/chat` 是本章轻量 Web UI 自己定义的端点;如果使用 T13 的 Agent Service,事件流入口是 `/sessions/{id}/stream`。 + +```python +# 1. 复用 main.py 的自定义工具 (SalesProfile, SalesBreakdown, ReportWriter) +# 2. 极简 FastAPI:两个端点 +# POST /chat → SSE 流式推送 AgentEvent +# POST /confirm → 接收前端的确认/拒绝结果 + +@app.post("/chat") +async def chat(req: ChatRequest): + async def event_stream(): + async for event in agent.reply_stream(msg): + yield f"data: {json.dumps(event.model_dump())}\n\n" + if event.type == EventType.REQUIRE_USER_CONFIRM: + # 暂停,等待前端调用 /confirm + await pending_confirm.wait() + # 继续流式处理 + async for e in agent.reply_stream(confirm_result): + yield f"data: {json.dumps(e.model_dump())}\n\n" + return StreamingResponse(event_stream(), media_type="text/event-stream") +``` + +### index.html 做了什么 + +```javascript +// 1. fetch('/chat', {method: 'POST', body: ...}) +// 2. 逐行解析 SSE: "data: {...}\n\n" +// 3. 根据 event.type 分发渲染: +// TEXT_BLOCK_DELTA → 追加文本到气泡 +// TOOL_CALL_* → 渲染工具卡片 +// TOOL_RESULT_* → 渲染结果框 +// REQUIRE_USER_CONFIRM → 弹出确认卡片 +// 4. 用户点击 Allow/Deny → POST /confirm → 流继续 +``` + +## 模块串联 + +| 模块 | 本章中的作用 | +|---|---| +| Agent / Model | 构建 DataMuse 的推理入口 | +| Message / Event | `reply_stream` 产生事件流,SSE 推送到前端 | +| Toolkit / ToolBase | `SalesProfile`、`SalesBreakdown`、`ReportWriter` | +| Permission | 读数据自动允许(ALLOW),写报告触发确认(ASK) | +| Context | `tool_result_limit=1200` 截断过长结果 | +| Middleware | `TimingMiddleware` 记录每轮耗时 | +| Workspace | `LocalWorkspace` 保存报告并支持 offload | + +### 哪些能力没有硬塞进本章 + +| 能力 | 本章选择 | 对应章节 | +|---|---|---| +| MCP / Skill | 分析工具已经是本地 Python Tool,不额外增加远程依赖或操作手册 | T05-T06 | +| Agent Service / Schedule | 保持单用户、单进程示例,服务化版本单独运行 | T13-T14 | +| Multi-Agent | 当前任务一个 Agent 足够,避免不必要的角色通信 | T15 | + +“完整”在这里指业务闭环完整:有输入、分析、权限边界、过程反馈和落地产物;不表示一个进程必须同时启用 AgentScope 的每一项能力。 + +## 文件结构 + +``` +16_complete_datamuse/ +├── README.md ← 你在读的文档 +├── main.py ← 模式 A:命令行 Demo +├── serve.py ← 模式 B:FastAPI + SSE 服务 +├── index.html ← 模式 B:单文件前端 +└── workspace/ ← 运行后生成 + └── reports/ +``` + +## 为什么这是最终章 + +前面的章节分别讲概念和 API,本章回答"学完以后能不能搭出一个 Data Agent"。它不是新的抽象,也不是把每种架构堆到一起,而是把 DataMuse 收束成一个能跑的最小完整应用:有数据输入、有分析工具、有权限边界、有运行过程、有最终产物。 + +- **模式 A** 展示所有核心模块如何在一个脚本里协作 +- **模式 B** 展示如何用最少代码(一个 serve.py + 一个 HTML)把 Agent 暴露为 Web 应用 diff --git a/tutorials/16_complete_datamuse/index.html b/tutorials/16_complete_datamuse/index.html new file mode 100644 index 0000000..c960ea9 --- /dev/null +++ b/tutorials/16_complete_datamuse/index.html @@ -0,0 +1,697 @@ + + + + + +DataMuse - Sales Analyst + + + + + +
+

DataMuse

+ Sales Analyst + Tutorial 15 +
+ +
+
+
DataMuse
+
+

Hello! I'm DataMuse, your data analyst. I can analyze the sales dataset for you.

+

Try asking me to:

+
    +
  • Inspect the sales data structure
  • +
  • Compare revenue by category or region
  • +
  • Write a full analysis report
  • +
+
+
+
+ +
+ +
+ + +
+ + + + diff --git a/tutorials/16_complete_datamuse/main.py b/tutorials/16_complete_datamuse/main.py new file mode 100644 index 0000000..4cb63f4 --- /dev/null +++ b/tutorials/16_complete_datamuse/main.py @@ -0,0 +1,171 @@ +# -*- coding: utf-8 -*- +"""Tutorial 16: Complete DataMuse — end-to-end CLI demo. + +Combines pieces from earlier chapters: Agent + model, custom tools (defined +once in tools.py and shared with serve.py), streaming events, permission +confirmation, ContextConfig, middleware, and a LocalWorkspace. +""" +# pylint: disable=missing-function-docstring,wrong-import-order +import asyncio +from typing import AsyncGenerator + +from agentscope.agent import Agent, ContextConfig +from agentscope.event import ( + ConfirmResult, + EventType, + RequireUserConfirmEvent, + UserConfirmResultEvent, +) +from agentscope.message import UserMsg +from agentscope.permission import ( + PermissionContext, + PermissionMode, +) +from agentscope.state import AgentState +from agentscope.tool import Toolkit +from agentscope.workspace import LocalWorkspace + +from tools import ( + ConsoleTraceMiddleware, + ReportWriter, + REPORTS_DIR, + SALES_CSV, + SalesBreakdown, + SalesProfile, + WORKSPACE_DIR, + create_model, +) + + +async def _process_stream( + agent: Agent, + stream: AsyncGenerator, + *, + auto_confirm: bool = True, +) -> None: + async for event in stream: + match event.type: + case EventType.TEXT_BLOCK_DELTA: + print(event.delta, end="", flush=True) + case EventType.TOOL_CALL_START: + print(f"\n >> Calling: {event.tool_call_name}") + case EventType.TOOL_RESULT_END: + print(f" >> Tool finished: {event.state}") + case EventType.REQUIRE_USER_CONFIRM: + await _handle_confirmation( + agent, + event, + auto_confirm=auto_confirm, + ) + case EventType.REPLY_END: + print() + + +async def _handle_confirmation( + agent: Agent, + event: RequireUserConfirmEvent, + *, + auto_confirm: bool, +) -> None: + print("\n >> Confirmation required") + confirm_results = [] + for tool_call in event.tool_calls: + print(f" tool: {tool_call.name}") + print(f" input: {tool_call.input[:160]}") + if not auto_confirm: + answer = input("Allow this tool call? [y/N] ").strip().lower() + confirmed = answer in {"y", "yes"} + else: + print(" auto-confirmed for this tutorial demo") + confirmed = True + + confirm_results.append( + ConfirmResult( + confirmed=confirmed, + tool_call=tool_call, + rules=(tool_call.suggested_rules or None) + if confirmed + else None, + ), + ) + + confirm_event = UserConfirmResultEvent( + reply_id=event.reply_id, + confirm_results=confirm_results, + ) + await _process_stream( + agent, + agent.reply_stream(confirm_event), + auto_confirm=auto_confirm, + ) + + +async def main() -> None: + if not SALES_CSV.exists(): + print(f"ERROR: {SALES_CSV} not found.") + print("Run: cd tutorials/data && python generate_sales_data.py") + return + + model = create_model() + workspace = LocalWorkspace(workdir=str(WORKSPACE_DIR)) + await workspace.initialize() + + try: + agent = Agent( + name="DataMuse", + system_prompt=( + "You are DataMuse, a careful data analyst. Always inspect " + "the dataset before making claims. Use SalesProfile first, " + "then SalesBreakdown for relevant dimensions, then write a " + "concise Markdown report with ReportWriter. Mention the " + "report path in your final answer." + ), + model=model, + toolkit=Toolkit( + tools=[ + SalesProfile(), + SalesBreakdown(), + ReportWriter(), + ], + ), + context_config=ContextConfig( + tool_result_limit=1200, + ), + state=AgentState( + permission_context=PermissionContext( + mode=PermissionMode.DEFAULT, + ), + ), + middlewares=[ConsoleTraceMiddleware()], + offloader=workspace, + ) + + task = ( + "Analyze the sales dataset end to end. First inspect the data, " + "then compare revenue by category, region, payment_method, and " + "customer_tier. Identify the strongest business signals and write " + "a short Markdown report named datamuse_sales_report.md." + ) + + print("Tutorial 16: Complete DataMuse") + print("=" * 60) + print(f"Dataset: {SALES_CSV}") + print(f"Workspace: {WORKSPACE_DIR}") + print("\n[User]: " + task) + print("\n[DataMuse]: ", end="", flush=True) + + await _process_stream( + agent, + agent.reply_stream(UserMsg(name="user", content=task)), + auto_confirm=True, + ) + + print("\nDone. Check the workspace reports directory:") + print(f" {REPORTS_DIR}") + + finally: + await workspace.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tutorials/16_complete_datamuse/serve.py b/tutorials/16_complete_datamuse/serve.py new file mode 100644 index 0000000..2596108 --- /dev/null +++ b/tutorials/16_complete_datamuse/serve.py @@ -0,0 +1,205 @@ +# -*- coding: utf-8 -*- +"""Tutorial 16: Complete DataMuse — lightweight Web UI mode. + +Minimal FastAPI server that wraps the DataMuse agent and streams events via +SSE. Pairs with the included index.html for a browser-based experience. +Tools and middleware are imported from tools.py so this server stays in lock +step with main.py. + +No Redis, no Node.js — just: + pip install agentscope uvicorn fastapi + python serve.py + +Then open http://localhost:8000 in a browser. +""" +# pylint: disable=missing-function-docstring,missing-class-docstring +# pylint: disable=wrong-import-order +import asyncio +import json +from pathlib import Path +from typing import Any + +import uvicorn +from fastapi import FastAPI +from fastapi.responses import FileResponse, StreamingResponse +from pydantic import BaseModel + +from agentscope.agent import Agent, ContextConfig +from agentscope.event import ( + ConfirmResult, + EventType, + UserConfirmResultEvent, +) +from agentscope.message import UserMsg +from agentscope.permission import ( + PermissionContext, + PermissionMode, +) +from agentscope.state import AgentState +from agentscope.tool import Toolkit +from agentscope.workspace import LocalWorkspace + +from tools import ( + ReportWriter, + SALES_CSV, + SalesBreakdown, + SalesProfile, + TimingMiddleware, + WORKSPACE_DIR, + create_model, +) + +TUTORIAL_DIR = Path(__file__).resolve().parent + + +# ========================================================================= +# Agent singleton +# ========================================================================= +agent: Agent | None = None +workspace: LocalWorkspace | None = None +_pending_confirm: asyncio.Event | None = None +_confirm_result: UserConfirmResultEvent | None = None + + +async def get_agent() -> Agent: + global agent, workspace + if agent is not None: + return agent + + model = create_model() + workspace = LocalWorkspace(workdir=str(WORKSPACE_DIR)) + await workspace.initialize() + + agent = Agent( + name="DataMuse", + system_prompt=( + "You are DataMuse, a careful data analyst. Always inspect " + "the dataset before making claims. Use SalesProfile first, " + "then SalesBreakdown for relevant dimensions, then write a " + "concise Markdown report with ReportWriter. Mention the " + "report path in your final answer." + ), + model=model, + toolkit=Toolkit( + tools=[SalesProfile(), SalesBreakdown(), ReportWriter()], + ), + context_config=ContextConfig(tool_result_limit=1200), + state=AgentState( + permission_context=PermissionContext( + mode=PermissionMode.DEFAULT, + ), + ), + middlewares=[TimingMiddleware()], + offloader=workspace, + ) + return agent + + +# ========================================================================= +# FastAPI app +# ========================================================================= +app = FastAPI(title="DataMuse Demo") + + +@app.get("/") +async def index(): + return FileResponse(TUTORIAL_DIR / "index.html") + + +class ChatRequest(BaseModel): + message: str + + +class ConfirmRequest(BaseModel): + reply_id: str + tool_calls: list[dict[str, Any]] + confirmed: bool + + +@app.post("/chat") +async def chat(req: ChatRequest): + """Stream agent events as SSE.""" + ag = await get_agent() + + async def event_stream(): + global _pending_confirm, _confirm_result + + msg = UserMsg(name="user", content=req.message) + async for event in ag.reply_stream(msg): + payload = event.model_dump(mode="json") + yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n" + + if event.type == EventType.REQUIRE_USER_CONFIRM: + _pending_confirm = asyncio.Event() + _confirm_result = None + yield f"data: {json.dumps({'type': 'WAITING_CONFIRM'})}\n\n" + await _pending_confirm.wait() + _pending_confirm = None + + async for cont_event in ag.reply_stream(_confirm_result): + cont_payload = cont_event.model_dump(mode="json") + yield ( + f"data: {json.dumps(cont_payload, ensure_ascii=False)}" + "\n\n" + ) + + yield "data: [DONE]\n\n" + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + ) + + +@app.post("/confirm") +async def confirm(req: ConfirmRequest): + """Receive user confirmation for pending tool calls.""" + global _confirm_result + + from agentscope.message import ToolCallBlock + + confirm_results = [] + for tc_data in req.tool_calls: + tc = ToolCallBlock(**tc_data) + confirm_results.append( + ConfirmResult( + confirmed=req.confirmed, + tool_call=tc, + rules=None, + ), + ) + + _confirm_result = UserConfirmResultEvent( + reply_id=req.reply_id, + confirm_results=confirm_results, + ) + + if _pending_confirm: + _pending_confirm.set() + + return {"status": "ok"} + + +if __name__ == "__main__": + if not SALES_CSV.exists(): + print(f"ERROR: {SALES_CSV} not found.") + print("Run: cd tutorials/data && python generate_sales_data.py") + raise SystemExit(1) + + print("DataMuse Web Demo") + print("=" * 50) + print("Open http://localhost:8000 in your browser") + print(f"Dataset: {SALES_CSV}") + print(f"Workspace: {WORKSPACE_DIR}") + print("=" * 50) + + uvicorn.run( + "serve:app", + host="0.0.0.0", + port=8000, + reload=False, + ) diff --git a/tutorials/16_complete_datamuse/tools.py b/tutorials/16_complete_datamuse/tools.py new file mode 100644 index 0000000..0884bf2 --- /dev/null +++ b/tutorials/16_complete_datamuse/tools.py @@ -0,0 +1,288 @@ +# -*- coding: utf-8 -*- +"""Tutorial 16: Shared tools for both main.py (CLI) and serve.py (Web UI). + +By extracting tool definitions here, the two run modes consume the exact same +SalesProfile / SalesBreakdown / ReportWriter implementations and any tweak +shows up in both flows automatically. +""" +# pylint: disable=missing-function-docstring,unused-argument +import csv +import os +import re +import time +from pathlib import Path +from typing import Any, AsyncGenerator, Callable + +from agentscope.agent import Agent +from agentscope.message import TextBlock +from agentscope.middleware import MiddlewareBase +from agentscope.permission import ( + PermissionBehavior, + PermissionContext, + PermissionDecision, +) +from agentscope.tool import ToolBase, ToolChunk + + +TUTORIAL_DIR = Path(__file__).resolve().parent +DATA_DIR = TUTORIAL_DIR.parent / "data" +SALES_CSV = DATA_DIR / "sales_data.csv" +WORKSPACE_DIR = TUTORIAL_DIR / "workspace" +REPORTS_DIR = WORKSPACE_DIR / "reports" + + +def create_model(): + """Create a chat model from available API keys.""" + if os.environ.get("DASHSCOPE_API_KEY"): + from agentscope.credential import DashScopeCredential + from agentscope.model import DashScopeChatModel + + return DashScopeChatModel( + credential=DashScopeCredential( + api_key=os.environ["DASHSCOPE_API_KEY"], + ), + model="qwen-plus", + ) + + if os.environ.get("OPENAI_API_KEY"): + from agentscope.credential import OpenAICredential + from agentscope.model import OpenAIChatModel + + return OpenAIChatModel( + credential=OpenAICredential( + api_key=os.environ["OPENAI_API_KEY"], + ), + model="gpt-4o", + ) + + raise EnvironmentError( + "No API key found. Set DASHSCOPE_API_KEY or OPENAI_API_KEY.", + ) + + +def _load_rows() -> list[dict[str, str]]: + with open(SALES_CSV, "r", encoding="utf-8") as f: + return list(csv.DictReader(f)) + + +def _money(value: float) -> str: + return f"${value:,.2f}" + + +class SalesProfile(ToolBase): + """Inspect the sales CSV and return schema plus basic quality checks.""" + + name = "SalesProfile" + description = ( + "Inspect the sales dataset and return row count, columns, date range, " + "missing value counts, and a few sample rows." + ) + input_schema = {"type": "object", "properties": {}, "required": []} + is_concurrency_safe = True + is_read_only = True + + async def check_permissions( + self, + tool_input: dict[str, Any], + context: PermissionContext, + ) -> PermissionDecision: + return PermissionDecision( + behavior=PermissionBehavior.ALLOW, + message="SalesProfile is read-only.", + ) + + async def call(self) -> ToolChunk: + rows = _load_rows() + columns = list(rows[0].keys()) if rows else [] + missing = { + col: sum(1 for row in rows if row.get(col, "") == "") + for col in columns + } + dates = sorted(row["date"] for row in rows) + sample = rows[:3] + + lines = [ + "Sales data profile", + f"- file: {SALES_CSV}", + f"- rows: {len(rows)}", + f"- columns: {', '.join(columns)}", + f"- date range: {dates[0]} to {dates[-1]}" if dates else "", + "- missing values:", + ] + lines.extend(f" - {col}: {count}" for col, count in missing.items()) + lines.append("- sample rows:") + lines.extend(f" - {row}" for row in sample) + + return ToolChunk(content=[TextBlock(text="\n".join(lines))]) + + +class SalesBreakdown(ToolBase): + """Compute revenue and order breakdowns by a selected dimension.""" + + name = "SalesBreakdown" + description = ( + "Compute order count, revenue, and average order value grouped by " + "category, region, payment_method, or customer_tier." + ) + input_schema = { + "type": "object", + "properties": { + "group_by": { + "type": "string", + "description": ( + "Dimension to group by: category, region, payment_method, " + "or customer_tier." + ), + }, + }, + "required": ["group_by"], + } + is_concurrency_safe = True + is_read_only = True + + async def check_permissions( + self, + tool_input: dict[str, Any], + context: PermissionContext, + ) -> PermissionDecision: + return PermissionDecision( + behavior=PermissionBehavior.ALLOW, + message="SalesBreakdown is read-only.", + ) + + async def call(self, group_by: str) -> ToolChunk: + rows = _load_rows() + allowed = {"category", "region", "payment_method", "customer_tier"} + if group_by not in allowed: + return ToolChunk( + content=[ + TextBlock( + text=( + f"Unsupported group_by={group_by!r}. " + f"Choose one of: {', '.join(sorted(allowed))}." + ), + ), + ], + ) + + groups: dict[str, list[dict[str, str]]] = {} + for row in rows: + groups.setdefault(row[group_by], []).append(row) + + total_revenue = sum(float(row["total"]) for row in rows) + lines = [ + f"Sales breakdown by {group_by}", + "group | orders | revenue | revenue_share | avg_order", + "--- | ---: | ---: | ---: | ---:", + ] + for key, group_rows in sorted( + groups.items(), + key=lambda item: sum(float(row["total"]) for row in item[1]), + reverse=True, + ): + revenue = sum(float(row["total"]) for row in group_rows) + share = revenue / total_revenue if total_revenue else 0 + avg = revenue / len(group_rows) if group_rows else 0 + lines.append( + f"{key} | {len(group_rows)} | {_money(revenue)} | " + f"{share:.1%} | {_money(avg)}", + ) + + return ToolChunk(content=[TextBlock(text="\n".join(lines))]) + + +class ReportWriter(ToolBase): + """Write a Markdown analysis report into the tutorial workspace.""" + + name = "ReportWriter" + description = ( + "Write the final sales analysis report to the local workspace. " + "Use this only after analysis is complete." + ) + input_schema = { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Report title.", + }, + "markdown": { + "type": "string", + "description": "Complete Markdown report body.", + }, + "filename": { + "type": "string", + "description": "Optional Markdown filename.", + "default": "sales_analysis_report.md", + }, + }, + "required": ["title", "markdown"], + } + is_concurrency_safe = False + is_read_only = False + + async def check_permissions( + self, + tool_input: dict[str, Any], + context: PermissionContext, + ) -> PermissionDecision: + return PermissionDecision( + behavior=PermissionBehavior.ASK, + message="ReportWriter writes a file into the workspace.", + ) + + async def call( + self, + title: str, + markdown: str, + filename: str = "sales_analysis_report.md", + ) -> ToolChunk: + REPORTS_DIR.mkdir(parents=True, exist_ok=True) + safe_name = re.sub(r"[^A-Za-z0-9_.-]+", "_", filename).strip("._") + if not safe_name.endswith(".md"): + safe_name += ".md" + + path = REPORTS_DIR / safe_name + content = f"# {title}\n\n{markdown.strip()}\n" + path.write_text(content, encoding="utf-8") + + return ToolChunk( + content=[ + TextBlock( + text=f"Report written successfully: {path}", + ), + ], + ) + + +class ConsoleTraceMiddleware(MiddlewareBase): + """Print per-turn timing to the console without changing behavior.""" + + async def on_reply( + self, + agent: Agent, + input_kwargs: dict, + next_handler: Callable[[], AsyncGenerator], + ) -> AsyncGenerator: + started = time.perf_counter() + print(f"\n[trace] reply started: agent={agent.name}") + async for item in next_handler(): + yield item + elapsed = time.perf_counter() - started + print(f"\n[trace] reply finished in {elapsed:.2f}s") + + +class TimingMiddleware(MiddlewareBase): + """ConsoleTraceMiddleware with quieter server-side output.""" + + async def on_reply( + self, + agent: Agent, + input_kwargs: dict, + next_handler: Callable[[], AsyncGenerator], + ) -> AsyncGenerator: + started = time.perf_counter() + async for item in next_handler(): + yield item + elapsed = time.perf_counter() - started + print(f" [timing] reply finished in {elapsed:.2f}s") diff --git a/tutorials/16_complete_datamuse/workspace/.mcp b/tutorials/16_complete_datamuse/workspace/.mcp new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/tutorials/16_complete_datamuse/workspace/.mcp @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/tutorials/16_complete_datamuse/workspace/reports/sales_analysis_report.md b/tutorials/16_complete_datamuse/workspace/reports/sales_analysis_report.md new file mode 100644 index 0000000..b235a28 --- /dev/null +++ b/tutorials/16_complete_datamuse/workspace/reports/sales_analysis_report.md @@ -0,0 +1,30 @@ +# Sales Performance Analysis Report + +# Sales Performance Analysis Report + +## Executive Summary +This report analyzes sales performance across product categories and geographic regions for the year 2024. The analysis reveals significant variations in revenue contribution, order volume, and average order value across different segments. + +## Key Findings + +### Category Performance +- **Electronics** dominates sales with 64.9% of total revenue ($387,866), despite representing only 21.8% of total orders +- **Sports** category has the highest order volume (222 orders) but contributes only 6.3% of revenue, indicating lower average order values +- **Home & Kitchen** shows strong performance with $69,373 revenue and the highest average order value among mid-tier categories ($379) + +### Region Performance +- **South** region leads in revenue generation ($147,085) and has the highest average order value ($714) +- **East** region has the highest order volume (224 orders) but the lowest average order value ($469), suggesting higher volume of smaller transactions +- Revenue distribution is relatively balanced across regions, with South (24.6%), Central (22.5%), North (18.1%), East (17.6%), and West (17.2%) + +## Strategic Recommendations +1. **Electronics Focus**: Continue investing in Electronics category growth given its dominant revenue contribution +2. **Regional Optimization**: Explore opportunities to increase average order value in East region through bundling or premium offerings +3. **Category Expansion**: Consider strategies to boost revenue from high-volume, low-revenue categories like Sports +4. **Cross-regional Analysis**: Further analysis could identify which category-region combinations drive the strongest performance + +## Data Overview +- Total Orders: 1,000 +- Total Revenue: $597,689.85 +- Date Range: January 1, 2024 - December 31, 2024 +- No missing values detected in any column diff --git a/tutorials/MODULE_GUIDE.md b/tutorials/MODULE_GUIDE.md new file mode 100644 index 0000000..0a3ea17 --- /dev/null +++ b/tutorials/MODULE_GUIDE.md @@ -0,0 +1,1417 @@ +# AgentScope 2.0 模块全景与完整应用 + +AgentScope 2.0 不只是一个 `Agent` 类,而是一套用于构建、控制和服务化 +Agent 应用的模块化运行时。本教程先建立完整的模块地图,再分别说明各模块 +“是什么、负责什么、什么时候用”,最后把这些模块组装成一个可运行的 +DataMuse 销售分析应用。 + +## 环境准备 + +在本仓库中运行示例时使用 Python 3.12 环境: + +```bash +conda activate agentscope-tutorial-py312 +export DASHSCOPE_API_KEY="your-api-key" +``` + +本文代码以 DashScope 为例。使用其他模型时,只需要替换 Credential 和 Model, +Agent、Tool、Permission、Middleware 等上层模块不需要改写。 + +## 先建立整体认识 + +一个 AgentScope 应用可以分成五层: + +```mermaid +flowchart LR + U["用户 / UI / API"] --> E["Message 与 Event"] + E --> A["Agent 运行时"] + A --> M["Model 与 Formatter"] + A --> K["Toolkit"] + A --> S["AgentState 与 Context"] + A --> MW["Middleware"] + K --> T["Python / 内置 Tool"] + K --> MCP["MCP Server"] + K --> SK["Skill"] + T --> P["Permission 与 HITL"] + MCP --> P + A --> W["Workspace"] + W --> O["文件、执行环境、MCP、Skill、Offloader"] + APP["Agent Service"] --> A + APP --> ST["Storage / MessageBus / Schedule / Team"] +``` + +- **Agent 运行时**负责推理、行动和状态推进。 +- **能力层**通过 Tool、MCP 和 Skill 告诉 Agent 能做什么以及怎么做。 +- **控制层**通过 Permission、HITL、Context 和 Middleware 控制执行边界。 +- **Workspace**提供文件、进程、MCP、Skill 和上下文卸载所需的工作环境。 +- **Agent Service**把本地对象变成多用户、可持久化、可调度的 HTTP 服务。 + +## 模块地图 + +| 模块 | 主要入口 | 负责什么 | 什么时候需要 | +|---|---|---|---| +| Credential | `agentscope.credential` | 保存并校验模型凭证 | 接入任意外部模型或语音服务时 | +| Model | `agentscope.model` | 调用 LLM,返回统一响应 | 所有 Agent 应用 | +| Formatter | `agentscope.formatter` | 在 AgentScope `Msg` 与供应商消息格式间转换 | 自定义模型协议或多 Agent 消息格式时 | +| Message | `agentscope.message` | 表示用户、助手、系统消息及多模态内容块 | 所有输入、上下文和最终输出 | +| Event | `agentscope.event` | 暴露推理、文本、工具、审批等增量事件 | 流式 UI、服务端 SSE、HITL | +| Agent | `agentscope.agent` | 执行 reasoning-acting 循环 | 所有 Agent 应用 | +| State | `agentscope.state` | 保存会话、上下文、权限、任务和 middleware 状态 | 多轮对话、恢复执行、持久化 | +| Tool | `agentscope.tool` | 把 Python 或系统能力暴露给 Agent | Agent 需要读取、计算或执行操作时 | +| MCP | `agentscope.mcp` | 连接标准化外部工具服务器 | 能力由独立服务提供或需要跨框架复用时 | +| Skill | `agentscope.skill` | 按需加载可复用的操作指南 | 流程复杂但不需要新增可执行接口时 | +| Permission | `agentscope.permission` | 对每次工具调用做 ALLOW、ASK 或 DENY 决策 | Agent 能产生真实副作用时 | +| Middleware | `agentscope.middleware` | 横切扩展 Agent 生命周期 | tracing、RAG、记忆、预算、TTS、审计 | +| Workspace | `agentscope.workspace` | 提供隔离工作目录、工具、MCP、Skill 和 Offloader | 文件操作、沙箱执行、服务化隔离 | +| Embedding / RAG | `agentscope.embedding`、`agentscope.rag` | 文档解析、切块、向量化、检索和上下文注入 | 回答私有或持续更新的知识时 | +| TTS | `agentscope.tts` | 把文本事件转换成音频事件 | 语音助手或无障碍输出 | +| Agent Service | `agentscope.app` | 提供 Agent、Session、Chat、SSE、Schedule 等 API | 多用户、持久化、远程调用和部署 | + +下面从运行时核心开始逐层展开。 + +--- + +## 1. Credential、Model 与 Formatter + +### 是什么 + +- `Credential` 只负责认证信息,不负责业务逻辑。 +- `ChatModelBase` 的具体实现负责调用模型供应商。 +- `Formatter` 负责把统一的 `Msg` 转成供应商 API 接受的消息格式。 + +### 什么时候用 + +每个 Agent 至少需要一个 Model。只有在切换供应商、自定义模型协议,或需要备用 +模型时,才需要深入配置这一层。 + +### 最小代码 + +```python +import os + +from agentscope.agent import Agent, ModelConfig +from agentscope.credential import DashScopeCredential +from agentscope.model import DashScopeChatModel + + +credential = DashScopeCredential( + api_key=os.environ["DASHSCOPE_API_KEY"], +) + +primary = DashScopeChatModel( + credential=credential, + model="qwen-plus", +) +backup = DashScopeChatModel( + credential=credential, + model="qwen-turbo", +) + +agent = Agent( + name="assistant", + system_prompt="You are a concise assistant.", + model=primary, + model_config=ModelConfig( + max_retries=2, + fallback_model=backup, + ), +) +``` + +`ModelConfig.max_retries=2` 表示主模型在初次失败后再重试两次,仍失败才切换 +到 `fallback_model`。具体 Model 自己也可能有 API 层重试,两层配置不要盲目叠加。 + +通常不需要手动传 Formatter;模型会使用匹配的默认实现: + +```python +from agentscope.formatter import DashScopeChatFormatter + +model = DashScopeChatModel( + credential=credential, + model="qwen-plus", + formatter=DashScopeChatFormatter(), +) +``` + +只有自定义供应商协议或消息组织方式时,才需要实现 `FormatterBase`。 + +--- + +## 2. Message、ContentBlock 与 Event + +### 是什么 + +`Msg` 是可持久化的完整消息,`ContentBlock` 是消息内部的内容单元,`AgentEvent` +是一次回复执行过程中的增量事件。 + +常用消息和内容块包括: + +- `UserMsg`、`AssistantMsg`、`SystemMsg` +- `TextBlock`、`ThinkingBlock`、`DataBlock`、`HintBlock` +- `ToolCallBlock`、`ToolResultBlock` + +### 什么时候用 + +- 只要最终答案:使用 `await agent.reply(...)`,得到完整 `Msg`。 +- 构建终端、Web UI、SSE 或审批流程:使用 `agent.reply_stream(...)` 处理事件。 +- 传递图片、音频或结构化内容:使用 `DataBlock`,不要把数据硬塞进字符串。 + +### 消息与流式事件 + +```python +from agentscope.event import EventType +from agentscope.message import UserMsg + + +message = UserMsg( + name="user", + content="Summarize revenue by region.", +) + +async for event in agent.reply_stream(message): + if event.type == EventType.TEXT_BLOCK_DELTA: + print(event.delta, end="", flush=True) + elif event.type == EventType.TOOL_CALL_START: + print(f"\n[tool] {event.tool_call_name}") + elif event.type == EventType.TOOL_RESULT_END: + print(f"[tool result] {event.state}") + elif event.type == EventType.REPLY_END: + print() +``` + +事件流是 Agent 与界面的稳定边界。UI 不需要知道 Agent 内部如何推理,只需要处理 +文本、工具、数据、确认和结束事件。 + +`AssistantMsg.append_event(event)` 可以把事件流重新聚合为完整消息,适合网关或 +自定义客户端保存最终结果。 + +--- + +## 3. Agent、AgentState 与运行配置 + +### 是什么 + +`Agent` 组合模型、工具、状态、控制配置和 middleware,并执行多轮 +reasoning-acting 循环。它本身不是能力仓库,真正的能力来自 Toolkit 和 +Middleware。 + +### 关键组装点 + +```python +from agentscope.agent import Agent, ContextConfig, ModelConfig, ReActConfig +from agentscope.permission import PermissionContext, PermissionMode +from agentscope.state import AgentState +from agentscope.tool import Toolkit + + +agent = Agent( + name="DataMuse", + system_prompt="You are a careful data analyst.", + model=primary, + toolkit=Toolkit(), + state=AgentState( + permission_context=PermissionContext( + mode=PermissionMode.DEFAULT, + ), + ), + model_config=ModelConfig(max_retries=2, fallback_model=backup), + context_config=ContextConfig( + trigger_ratio=0.8, + reserve_ratio=0.1, + tool_result_limit=2000, + ), + react_config=ReActConfig( + max_iters=12, + stop_on_reject=False, + ), +) +``` + +这些参数分别控制: + +| 参数 | 控制什么 | +|---|---| +| `model_config` | 模型重试和 fallback | +| `context_config` | 自动压缩阈值、保留比例、工具结果上限 | +| `react_config` | 单次回复最大推理轮数和拒绝后的行为 | +| `state` | 会话上下文、权限、任务、激活工具组和 middleware 状态 | +| `offloader` | 被截断内容和多模态数据的卸载位置,通常由 Workspace 提供 | + +### 四个常用方法 + +| 方法 | 行为 | 适用情况 | +|---|---|---| +| `reply()` | 消费完整事件流并返回最终 `Msg` | 脚本、测试、无需展示过程 | +| `reply_stream()` | 逐个返回 `AgentEvent` | UI、SSE、HITL、进度展示 | +| `observe()` | 把消息放入上下文,不触发推理 | 多 Agent 之间传递结果 | +| `compress_context()` | 按配置压缩当前上下文 | 主动控制长会话上下文 | + +--- + +## 4. Tool、Toolkit 与 ToolGroup + +### 是什么 + +Tool 是模型可以调用的可执行接口;Toolkit 是 Agent 的能力注册表。Toolkit 可以 +同时接收四类能力来源: + +1. 内置 Tool,例如 `Read`、`Write`、`Bash`、`Grep`。 +2. `FunctionTool` 包装的 Python 函数。 +3. 自定义 `ToolBase` 子类。 +4. MCP Client 暴露的远程 Tool。 + +Skill 也由 Toolkit 管理,但它提供的是操作指南,不是新的执行接口。 + +### FunctionTool + +```python +from agentscope.tool import FunctionTool, Toolkit + + +def query_sales(region: str = "") -> str: + """Return a compact sales summary for one region.""" + return f"Sales summary for {region or 'all regions'}" + + +toolkit = Toolkit( + tools=[ + FunctionTool( + query_sales, + is_read_only=True, + ), + ], +) +``` + +`FunctionTool` 会从函数签名和 docstring 生成 JSON Schema。它适合快速包装已有 +函数;需要精细控制输入 Schema、流式结果或权限时,使用 `ToolBase`。 + +### 自定义 ToolBase + +```python +from typing import Any + +from agentscope.message import TextBlock +from agentscope.permission import ( + PermissionBehavior, + PermissionContext, + PermissionDecision, +) +from agentscope.tool import ToolBase, ToolChunk + + +class SalesSummary(ToolBase): + name = "SalesSummary" + description = "Compute revenue grouped by category or region." + input_schema = { + "type": "object", + "properties": { + "group_by": { + "type": "string", + "enum": ["category", "region"], + }, + }, + "required": ["group_by"], + } + is_concurrency_safe = True + is_read_only = True + + async def check_permissions( + self, + _tool_input: dict[str, Any], + _context: PermissionContext, + ) -> PermissionDecision: + return PermissionDecision(behavior=PermissionBehavior.ALLOW) + + async def call(self, group_by: str) -> ToolChunk: + return ToolChunk( + content=[TextBlock(text=f"Grouped sales by {group_by}")], + ) +``` + +当前 `ToolBase` 的业务执行入口是 `call()`;`__call__()` 由基类负责叠加 Tool +Middleware,不应在新 Tool 中绕过它。 + +### ToolGroup + +工具很多时,把所有 Schema 每轮都发给模型会浪费上下文,也会增加误选概率: + +```python +from agentscope.tool import ToolGroup, Toolkit + + +toolkit = Toolkit( + tools=[always_available_tool], + tool_groups=[ + ToolGroup( + name="analysis", + description="Statistical analysis tools.", + instructions="Validate the selected dimension first.", + tools=[sales_summary_tool], + ), + ToolGroup( + name="reporting", + description="Report generation tools.", + tools=[report_writer_tool], + ), + ], +) +``` + +存在非 `basic` 组时,Toolkit 会自动注入 `reset_tools` 元工具。它不属于 +`basic` 组,但和 `basic` 工具一样始终可见。每次调用表示“最终期望的激活状态”, +未明确设为 `True` 的非 basic 组都会停用。 + +--- + +## 5. MCP + +### 是什么 + +MCP 把外部工具服务器转换成 AgentScope Tool。它适合连接文件系统、数据库、 +浏览器或其他独立服务,并保持能力协议与 Agent 实现解耦。 + +### 什么时候用 + +- 能力已经由 MCP Server 提供。 +- 希望同一套工具被不同 Agent 框架复用。 +- 工具进程需要独立部署、升级或隔离。 + +如果只是调用本进程里的一个 Python 函数,`FunctionTool` 更直接。 + +### Stdio MCP + +```python +from agentscope.mcp import MCPClient, StdioMCPConfig +from agentscope.tool import Toolkit + + +filesystem = MCPClient( + name="filesystem", + is_stateful=True, + mcp_config=StdioMCPConfig( + command="npx", + args=[ + "-y", + "@modelcontextprotocol/server-filesystem", + "/absolute/path/to/data", + ], + ), + enable_tools=["list_directory", "read_file"], +) + +await filesystem.connect() +try: + toolkit = Toolkit(mcps=[filesystem]) +finally: + await filesystem.close() +``` + +HTTP MCP 使用 `HttpMCPConfig(url=..., headers=...)`。MCP 工具名会被命名空间化为 +`mcp__{server_name}__{tool_name}`,避免不同 Server 的同名工具冲突。 + +`is_stateful=True` 的 Client 需要显式维护连接生命周期;交给 Workspace 后, +Workspace 会负责初始化和关闭。MCP Server 提供的 `readOnlyHint` 也会参与 +Permission 决策。 + +--- + +## 6. Skill + +### 是什么 + +Skill 是带 YAML frontmatter 的 Markdown 操作指南。它告诉 Agent “完成某类任务 +应该遵循什么步骤”,但不会新增可执行代码。 + +### 什么时候用 + +- 一项任务需要稳定的多步流程、格式或检查清单。 +- 指南很长,不希望永久放进 system prompt。 +- 同一套工作方法需要在多个 Agent 或 Workspace 复用。 + +如果需要访问数据库或执行 API,仍然要配套 Tool 或 MCP。 + +### SKILL.md + +```markdown +--- +name: report_writer +description: Create a concise Markdown sales report from verified metrics. +--- + +# Report Writer + +1. Use a data tool to obtain every metric. +2. Separate observations from recommendations. +3. Include data scope and output path. +``` + +### 加载 Skill + +```python +from agentscope.skill import LocalSkillLoader +from agentscope.tool import Toolkit + + +toolkit = Toolkit( + skills_or_loaders=[ + LocalSkillLoader( + directory="./skills", + scan_subdir=True, + ), + ], +) +``` + +有可用 Skill 时,Toolkit 会暴露名为 `Skill` 的元工具。模型先看到名称和描述, +需要完整指南时再调用 `Skill(skill="report_writer")`,从而按需占用上下文。 + +--- + +## 7. Permission 与 Human-in-the-Loop + +### 是什么 + +Permission Engine 在工具真正执行前综合判断: + +1. 当前 `PermissionMode` +2. 用户配置的 allow、ask、deny rules +3. Tool 自己的 `check_permissions()` 结果 +4. 调用是否只读、路径是否在允许的工作目录 + +最终结果是 `ALLOW`、`ASK` 或 `DENY`。`ASK` 会暂停当前回复,并产生 +`REQUIRE_USER_CONFIRM` 事件。 + +### 五种模式 + +| 模式 | 核心行为 | 适用情况 | +|---|---|---| +| `DEFAULT` | 规则或 Tool 未明确允许时进入 ASK | 有交互界面的普通应用 | +| `ACCEPT_EDITS` | 工作目录内的读写和受支持文件命令自动允许 | 本地协作开发 | +| `EXPLORE` | 只读操作允许,修改操作拒绝 | 浏览代码、数据探索 | +| `BYPASS` | 跳过安全 ASK,但仍服从显式 deny/ask 规则和 Tool DENY | 完全可信的隔离沙箱 | +| `DONT_ASK` | 把所有 ASK 转成 DENY | 定时任务、后台无人值守执行 | + +`BYPASS` 不是默认安全模式;它会跳过 Tool 返回的安全 ASK。无人值守但仍希望保守 +执行时,优先使用 `DONT_ASK`。 + +### 把权限装入 Agent + +```python +from agentscope.permission import ( + PermissionBehavior, + PermissionContext, + PermissionMode, + PermissionRule, +) +from agentscope.state import AgentState + + +write_rule = PermissionRule( + tool_name="Write", + rule_content="reports/**", + behavior=PermissionBehavior.ALLOW, + source="application", +) + +state = AgentState( + permission_context=PermissionContext( + mode=PermissionMode.DEFAULT, + allow_rules={"Write": [write_rule]}, + ), +) +``` + +权限的准确接线位置是 `Agent(state=AgentState(permission_context=...))`,不是 +Toolkit 构造器。 + +### 处理审批事件 + +```python +from agentscope.event import ConfirmResult, EventType, UserConfirmResultEvent + + +async def run_with_approval(agent, message): + async def process(stream): + async for event in stream: + if event.type == EventType.TEXT_BLOCK_DELTA: + print(event.delta, end="", flush=True) + + elif event.type == EventType.REQUIRE_USER_CONFIRM: + results = [] + for tool_call in event.tool_calls: + answer = input( + f"Approve {tool_call.name} {tool_call.input}? [y/N] ", + ) + results.append( + ConfirmResult( + confirmed=answer.lower() == "y", + tool_call=tool_call, + ), + ) + + await process( + agent.reply_stream( + UserConfirmResultEvent( + reply_id=event.reply_id, + confirm_results=results, + ), + ), + ) + + await process(agent.reply_stream(message)) +``` + +`UserConfirmResultEvent` 恢复的是同一次 reply,不是创建一轮新对话。外部系统代为 +执行的 Tool 使用对应的 `REQUIRE_EXTERNAL_EXECUTION` 和 +`ExternalExecutionResultEvent`。 + +--- + +## 8. Context 管理 + +### 是什么 + +AgentState 中的 `context` 保存未压缩消息,`summary` 保存压缩后的历史。 +`ContextConfig` 控制何时压缩,以及工具结果进入模型上下文前的大小上限。 + +### 什么时候用 + +- 对话会跨很多轮持续运行。 +- Tool 可能返回大文件、大表格或长日志。 +- 模型上下文成本和延迟开始明显增长。 + +### 配置与主动压缩 + +```python +from agentscope.agent import ContextConfig +from agentscope.message import HintBlock + + +config = ContextConfig( + trigger_ratio=0.8, + reserve_ratio=0.1, + tool_result_limit=2000, +) + +await agent.compress_context( + context_config=config, + instructions=HintBlock( + hint="Preserve decisions, verified metrics, and output paths.", + ), +) +``` + +`tool_result_limit` 限制的是进入上下文的工具结果,不等于工具不能产生更大输出。 +给 Agent 配置 `offloader=workspace` 后,被截断内容可以卸载到 Workspace,而不是 +直接丢失。 + +--- + +## 9. Middleware + +### 是什么 + +Middleware 在不修改 Agent 主流程的前提下拦截生命周期。适合 tracing、审计、 +限额、RAG、长期记忆和 TTS 等横切能力。 + +### Hook 边界 + +| Hook | 拦截范围 | +|---|---| +| `on_reply` | 一次完整回复,包括 HITL 暂停与恢复 | +| `on_reasoning` | 一轮 reasoning | +| `on_acting` | 已完成校验和权限判断后的纯工具执行 | +| `on_model_call` | 原始模型 API 调用 | +| `on_compress_context` | 上下文压缩 | +| `on_system_prompt` | 顺序变换 system prompt | +| `list_tools()` | 声明 Middleware 提供的额外工具 | + +### 自定义耗时 Middleware + +```python +import time + +from agentscope.middleware import MiddlewareBase + + +class TimingMiddleware(MiddlewareBase): + async def on_reply(self, agent, input_kwargs, next_handler): + started = time.perf_counter() + try: + async for item in next_handler(**input_kwargs): + yield item + finally: + elapsed = time.perf_counter() - started + print(f"[trace] {agent.name} reply took {elapsed:.2f}s") + + +agent = Agent( + name="DataMuse", + system_prompt="...", + model=primary, + middlewares=[TimingMiddleware()], +) +``` + +`on_acting` 看不到权限判断过程,因为它只包裹已经允许执行的 Tool 调用。需要记录 +完整审批流程时,应在 `on_reply` 观察事件流。 + +### 内置 Middleware + +| Middleware | 什么时候用 | +|---|---| +| `TracingMiddleware` | 需要 OpenTelemetry tracing | +| `ReplyBudgetControlMiddleware` | 需要限制单次回复的加权 token 预算 | +| `RAGMiddleware` | 需要静态注入或 Agent 主动检索知识库 | +| `TTSMiddleware` | 需要把文本增量转换为音频事件 | +| `AgenticMemoryMiddleware` | 需要 Agent 主动管理长期记忆 | +| `Mem0Middleware`、`ReMeMiddleware` | 接入对应长期记忆后端 | + +普通库模式下,Middleware 的 `list_tools()` 不会被 `Agent` 构造器自动放进 +Toolkit,需要手动执行并注册;Agent Service 在组装 Toolkit 时会自动收集。 + +--- + +## 10. Workspace + +### 是什么 + +Workspace 是 Agent 的统一工作环境。它同时提供: + +- 绑定到环境的 `Bash`、`Read`、`Write`、`Edit`、`Glob`、`Grep` +- MCP 与 Skill 的生命周期和持久化 +- system prompt 中的工作目录说明 +- Context 和大结果的 Offloader +- 本地目录、Docker、E2B 或 Kubernetes 隔离 + +### 什么时候用 + +只聊天或只调用纯函数时可以不使用 Workspace。一旦 Agent 需要文件、命令执行、 +可恢复工作目录或服务端租户隔离,就应该引入 Workspace。 + +### 组装方式 + +```python +from agentscope.tool import Toolkit +from agentscope.workspace import LocalWorkspace + + +async with LocalWorkspace( + workdir="./workspace", + default_mcps=[filesystem_mcp], + skill_paths=["./skills/report_writer"], +) as workspace: + workspace_tools = await workspace.list_tools() + workspace_mcps = await workspace.list_mcps() + workspace_skills = await workspace.list_skills() + + agent = Agent( + name="DataMuse", + system_prompt=( + "You are a data analyst.\n" + + await workspace.get_instructions() + ), + model=primary, + toolkit=Toolkit( + tools=[SalesSummary(), *workspace_tools], + mcps=workspace_mcps, + skills_or_loaders=workspace_skills, + ), + offloader=workspace, + ) +``` + +库模式要显式把 Workspace 暴露的能力装进 Toolkit。Agent Service 的 +WorkspaceManager 会在每次组装 Agent 时完成这一步。 + +--- + +## 11. Embedding、RAG 与 KnowledgeBase + +### 是什么 + +RAG 模块把流程拆成可替换的组件:Parser 解析文档,Chunker 切块,EmbeddingModel +生成向量,VectorStore 保存和检索,KnowledgeBase 把这些组件绑定在一起, +`RAGMiddleware` 再把检索能力接入 Agent。 + +### 什么时候用 + +当答案依赖私有文档、企业制度、持续更新的资料或需要可追溯依据时使用 RAG。 +固定且很短的背景信息直接放 system prompt 更简单。 + +### 最小组装 + +```python +from agentscope.embedding import DashScopeEmbeddingModel +from agentscope.middleware import RAGMiddleware +from agentscope.rag import ( + ApproxTokenChunker, + KnowledgeBase, + QdrantStore, + TextParser, +) +from agentscope.tool import Toolkit + + +embedding = DashScopeEmbeddingModel( + credential=credential, + model="text-embedding-v4", + dimensions=1024, +) +store = QdrantStore(location=":memory:") + +async with store: + knowledge = KnowledgeBase( + name="sales-handbook", + description="Sales definitions and reporting policies.", + embedding_model=embedding, + vector_store=store, + collection="sales-handbook", + ) + + sections = await TextParser().parse( + file=b"Revenue means paid order value after discount.", + filename="definitions.md", + ) + chunks = await ApproxTokenChunker( + chunk_size=256, + overlap=32, + ).chunk(sections) + await knowledge.insert_document( + chunks, + document_metadata={"filename": "definitions.md"}, + ) + + rag = RAGMiddleware( + knowledge_bases=[knowledge], + parameters=RAGMiddleware.Parameters( + mode="agentic", + top_k=3, + ), + ) + + agent = Agent( + name="DataMuse", + system_prompt="Use the knowledge base for business definitions.", + model=primary, + toolkit=Toolkit(tools=await rag.list_tools()), + middlewares=[rag], + ) +``` + +`mode="static"` 会在每个新输入上自动检索并注入 Hint;`mode="agentic"` 会提供 +`search_knowledge` Tool,让模型决定何时搜索。上例显式把 `rag.list_tools()` +加入 Toolkit,这是库模式的必要接线。 + +--- + +## 12. TTS + +### 是什么 + +TTS Model 把文本转换成音频,`TTSMiddleware` 监听文本事件并向同一个回复流注入 +`DATA_BLOCK_START`、`DATA_BLOCK_DELTA` 和 `DATA_BLOCK_END`。 + +### 什么时候用 + +语音助手、实时播报、无障碍输出或需要前端直接消费音频流时使用。 + +```python +from agentscope.middleware import TTSMiddleware +from agentscope.tts import DashScopeTTSModel + + +tts = DashScopeTTSModel( + credential=credential, + model="qwen3-tts-flash", + parameters=DashScopeTTSModel.Parameters(voice="Cherry"), + stream=True, +) + +agent = Agent( + name="voice-assistant", + system_prompt="Reply concisely.", + model=primary, + middlewares=[TTSMiddleware(tts_model=tts)], +) +``` + +前端应按 `block_id` 聚合同一个 DataBlock 的增量音频,而不是把每个 delta 当成 +独立音频文件。 + +--- + +## 13. Agent Service + +### 是什么 + +`create_app()` 生成 FastAPI 应用,提供 Credential、Model、Agent、Session、Chat、 +SSE、Workspace、KnowledgeBase、Schedule 和 Team 等服务能力。 + +服务模式下各对象的职责不同: + +| 对象 | 保存什么 | +|---|---| +| Agent 模板 | 名称、system prompt、Context/ReAct 配置 | +| Session | Agent、模型和备用模型、权限、对话状态、知识库配置 | +| Workspace | 每个隔离单元的文件、MCP、Skill 和执行环境 | +| Storage | Agent、Session、消息、Schedule、Team 等持久数据 | +| MessageBus | SSE、跨 Session 消息、后台唤醒等实时传输 | +| 服务宿主 | Python Tool、Middleware、Credential 扩展和沙箱策略 | + +### 创建服务 + +```python +from agentscope.app import create_app +from agentscope.app.message_bus import RedisMessageBus +from agentscope.app.storage import RedisStorage +from agentscope.app.workspace_manager import LocalWorkspaceManager + + +async def tool_factory(user_id, agent_id, session_id): + del user_id, agent_id, session_id + return [SalesSummary()] + + +app = create_app( + storage=RedisStorage(host="localhost", port=6379), + message_bus=RedisMessageBus(host="localhost", port=6379), + workspace_manager=LocalWorkspaceManager( + basedir="./workspaces", + default_mcps=[filesystem_mcp], + skill_paths=["./skills/report_writer"], + ), + extra_agent_tools=tool_factory, + title="DataMuse Service", +) +``` + +`POST /agent/` 创建的是可序列化模板,不能在 JSON 里塞 Python `ToolBase` 对象。 +`extra_agent_tools` 和 `extra_agent_middlewares` 才是服务端运行期能力的注入点, +并且可以按 `user_id`、`agent_id`、`session_id` 返回不同能力。 + +### 一次服务调用的顺序 + +```text +创建 Credential + -> 创建 Agent 模板 + -> 创建 Session,并绑定 chat_model_config / fallback_chat_model_config + -> 建立 GET /sessions/{session_id}/stream SSE + -> POST /chat/ 触发回复 + -> 从 SSE 消费 AgentEvent + -> 如遇 ASK,再 POST UserConfirmResultEvent 恢复同一次回复 +``` + +`POST /chat/` 是触发器,事件从 Session Stream 返回。这样同一条流可以承载多次 +回复、HITL 恢复、后台唤醒和 Team Worker 的事件投影。 + +--- + +## 14. Schedule + +### 是什么 + +Schedule 把一个 Agent 模板、模型配置和 cron 表达式绑定起来,按计划自动创建或 +复用 Session 执行任务。 + +### 什么时候用 + +日报、监控、定时资料收集、周期性数据分析等无人值守任务。 + +```python +body = { + "name": "Daily Sales Summary", + "description": "Summarize yesterday's sales and save a report.", + "cron_expression": "0 9 * * *", + "timezone": "Asia/Shanghai", + "agent_id": agent_id, + "chat_model_config": { + "type": "dashscope_chat", + "credential_id": credential_id, + "model": "qwen-plus", + "parameters": {}, + }, + "enabled": True, + "stateful": False, + "permission_mode": "dont_ask", +} + +response = await client.post( + "/schedule/", + json=body, + headers={"X-User-Id": "demo-user"}, +) +response.raise_for_status() +``` + +- `stateful=False`:每次触发创建独立 Session,适合彼此独立的日报。 +- `stateful=True`:连续触发共享上下文,适合持续跟踪同一任务。 +- 默认 `DONT_ASK`:ASK 会被拒绝,避免无人值守任务永久等待确认。 + +定时任务应配合可重试模型、fallback 和可观测性,而不是依赖人工重跑。 + +--- + +## 15. Multi-Agent 与 Team + +### 是什么 + +Multi-Agent 不是“多个步骤”的同义词,而是把不同工具、上下文、责任或并行任务 +交给不同 Agent。库模式可以直接用 Python 编排;Agent Service 还提供 Team 与 +子 Agent 工具。 + +### 什么时候用 + +- 角色拥有明显不同的工具和 system prompt。 +- 上下文隔离能降低干扰或权限风险。 +- 多个分支可以并行执行。 +- 需要由 Leader 动态创建、邀请或调度 Worker。 + +单个 Agent 能清晰完成的线性任务,不要仅为了形式拆成 Multi-Agent。 + +### 串行与并行编排 + +```python +import asyncio + +from agentscope.message import UserMsg + + +request = UserMsg(name="user", content="Analyze this month's sales.") + +collected = await collector.reply(request) +await analyst.observe(collected) +analysis = await analyst.reply( + UserMsg(name="user", content="Find the strongest business signals."), +) + +region_result, category_result = await asyncio.gather( + region_analyst.reply(request), + category_analyst.reply(request), +) +``` + +`observe()` 只共享上下文,不会让接收方立刻推理。库模式下 Python 代码就是最直接 +的编排层;服务模式需要持久化团队关系或动态 Worker 时,再使用 Team 能力。 + +--- + +## 16. 完整示例:DataMuse 销售分析应用 + +下面把核心模块组装为一个本地应用。它会: + +1. 使用主模型、自动重试和备用模型。 +2. 通过 MCP 查看数据目录。 +3. 使用只读 Tool 计算销售指标。 +4. 按需加载报告 Skill。 +5. 在写报告前触发 Permission 和 HITL。 +6. 使用 ContextConfig、Middleware 和 LocalWorkspace。 +7. 通过事件流展示模型、工具和审批过程。 + +### 文件结构 + +```text +datamuse_demo/ +├── main.py +├── data/ +│ └── sales_data.csv +└── skills/ + └── report_writer/ + └── SKILL.md +``` + +准备一个最小 `data/sales_data.csv`: + +```csv +order_id,category,region,total +1001,Electronics,North,1200.00 +1002,Home,South,480.00 +1003,Electronics,East,860.00 +1004,Sports,North,320.00 +1005,Home,East,640.00 +``` + +`skills/report_writer/SKILL.md`: + +```markdown +--- +name: report_writer +description: Turn verified sales metrics into a concise Markdown report. +--- + +# Report Writer + +1. Obtain all metrics from SalesSummary. +2. Include data scope, findings, and recommendations. +3. Never invent a number that is absent from tool results. +4. Save the final report with WriteReport. +``` + +### main.py + +```python +import asyncio +import csv +import os +import time +from pathlib import Path +from typing import Any + +from agentscope.agent import ( + Agent, + ContextConfig, + ModelConfig, + ReActConfig, +) +from agentscope.credential import DashScopeCredential +from agentscope.event import ( + ConfirmResult, + EventType, + UserConfirmResultEvent, +) +from agentscope.mcp import MCPClient, StdioMCPConfig +from agentscope.message import TextBlock, UserMsg +from agentscope.middleware import MiddlewareBase +from agentscope.model import DashScopeChatModel +from agentscope.permission import ( + PermissionBehavior, + PermissionContext, + PermissionDecision, + PermissionMode, +) +from agentscope.state import AgentState +from agentscope.tool import ToolBase, ToolChunk, Toolkit +from agentscope.workspace import LocalWorkspace + + +ROOT = Path(__file__).resolve().parent +DATA_DIR = ROOT / "data" +SALES_CSV = DATA_DIR / "sales_data.csv" +SKILL_DIR = ROOT / "skills" / "report_writer" +WORKSPACE_DIR = ROOT / "workspace" +REPORTS_DIR = WORKSPACE_DIR / "reports" + + +filesystem_mcp = MCPClient( + name="filesystem", + is_stateful=True, + mcp_config=StdioMCPConfig( + command="npx", + args=[ + "-y", + "@modelcontextprotocol/server-filesystem", + str(DATA_DIR), + ], + ), + enable_tools=["list_directory", "read_file"], +) + + +class SalesSummary(ToolBase): + """Read-only aggregate calculation over the demo CSV.""" + + name = "SalesSummary" + description = "Compute order count and revenue grouped by a column." + input_schema = { + "type": "object", + "properties": { + "group_by": { + "type": "string", + "enum": ["category", "region"], + }, + }, + "required": ["group_by"], + } + is_concurrency_safe = True + is_read_only = True + + async def check_permissions( + self, + _tool_input: dict[str, Any], + _context: PermissionContext, + ) -> PermissionDecision: + return PermissionDecision( + behavior=PermissionBehavior.ALLOW, + message="Read-only aggregate calculation.", + ) + + async def call(self, group_by: str) -> ToolChunk: + with SALES_CSV.open(encoding="utf-8") as file: + rows = list(csv.DictReader(file)) + + groups: dict[str, list[dict[str, str]]] = {} + for row in rows: + groups.setdefault(row[group_by], []).append(row) + + lines = [f"Sales grouped by {group_by}:"] + for key, items in sorted(groups.items()): + revenue = sum(float(item["total"]) for item in items) + lines.append( + f"- {key}: {len(items)} orders, revenue ${revenue:,.2f}", + ) + + return ToolChunk(content=[TextBlock(text="\n".join(lines))]) + + +class WriteReport(ToolBase): + """Write a Markdown report after explicit confirmation.""" + + name = "WriteReport" + description = "Save a verified Markdown sales report to the workspace." + input_schema = { + "type": "object", + "properties": { + "filename": {"type": "string"}, + "content": {"type": "string"}, + }, + "required": ["filename", "content"], + } + is_concurrency_safe = False + is_read_only = False + + def __init__(self, reports_dir: Path = REPORTS_DIR) -> None: + super().__init__() + self.reports_dir = reports_dir + + async def check_permissions( + self, + _tool_input: dict[str, Any], + _context: PermissionContext, + ) -> PermissionDecision: + return PermissionDecision( + behavior=PermissionBehavior.ASK, + message="Writing a report changes the workspace.", + ) + + async def call(self, filename: str, content: str) -> ToolChunk: + safe_name = Path(filename).name + if not safe_name.endswith(".md"): + safe_name += ".md" + + self.reports_dir.mkdir(parents=True, exist_ok=True) + target = self.reports_dir / safe_name + target.write_text(content, encoding="utf-8") + return ToolChunk( + content=[TextBlock(text=f"Report saved to {target}")], + ) + + +class TimingMiddleware(MiddlewareBase): + """Print total wall time for each reply.""" + + async def on_reply(self, agent, input_kwargs, next_handler): + started = time.perf_counter() + try: + async for item in next_handler(**input_kwargs): + yield item + finally: + elapsed = time.perf_counter() - started + print(f"\n[trace] {agent.name} reply took {elapsed:.2f}s") + + +async def process_events(agent: Agent, stream) -> None: + """Render events and resume the same reply after approval.""" + async for event in stream: + if event.type == EventType.TEXT_BLOCK_DELTA: + print(event.delta, end="", flush=True) + + elif event.type == EventType.TOOL_CALL_START: + print(f"\n[tool] {event.tool_call_name}") + + elif event.type == EventType.TOOL_RESULT_END: + print(f"[tool result] {event.state}") + + elif event.type == EventType.REQUIRE_USER_CONFIRM: + confirm_results = [] + for tool_call in event.tool_calls: + print(f"\n[approval required] {tool_call.name}") + print(f"input: {tool_call.input}") + answer = await asyncio.to_thread( + input, + "Approve this tool call? [y/N] ", + ) + confirm_results.append( + ConfirmResult( + confirmed=answer.strip().lower() == "y", + tool_call=tool_call, + ), + ) + + await process_events( + agent, + agent.reply_stream( + UserConfirmResultEvent( + reply_id=event.reply_id, + confirm_results=confirm_results, + ), + ), + ) + + elif event.type == EventType.REPLY_END: + print() + + +async def main() -> None: + credential = DashScopeCredential( + api_key=os.environ["DASHSCOPE_API_KEY"], + ) + primary = DashScopeChatModel( + credential=credential, + model="qwen-plus", + ) + backup = DashScopeChatModel( + credential=credential, + model="qwen-turbo", + ) + + async with LocalWorkspace( + workdir=str(WORKSPACE_DIR), + default_mcps=[filesystem_mcp], + skill_paths=[str(SKILL_DIR)], + ) as workspace: + workspace_tools = await workspace.list_tools() + workspace_mcps = await workspace.list_mcps() + workspace_skills = await workspace.list_skills() + workspace_instructions = await workspace.get_instructions() + + agent = Agent( + name="DataMuse", + system_prompt=( + "You are DataMuse, a careful sales analyst. " + "First inspect the data directory with filesystem MCP. " + "Use SalesSummary for every numeric claim. Before writing, " + "load report_writer with the Skill tool, then call " + "WriteReport. Mention the saved path in the final answer.\n" + + workspace_instructions + ), + model=primary, + toolkit=Toolkit( + tools=[ + SalesSummary(), + WriteReport(), + *workspace_tools, + ], + mcps=workspace_mcps, + skills_or_loaders=workspace_skills, + ), + state=AgentState( + permission_context=PermissionContext( + mode=PermissionMode.DEFAULT, + ), + ), + model_config=ModelConfig( + max_retries=2, + fallback_model=backup, + ), + context_config=ContextConfig(tool_result_limit=2000), + react_config=ReActConfig(max_iters=12), + middlewares=[TimingMiddleware()], + offloader=workspace, + ) + + task = UserMsg( + name="user", + content=( + "Inspect the data directory, compare revenue by category " + "and region, then write datamuse_report.md." + ), + ) + await process_events(agent, agent.reply_stream(task)) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +### 运行 + +```bash +conda activate agentscope-tutorial-py312 +cd datamuse_demo +python main.py +``` + +首次运行 filesystem MCP 时,`npx` 可能需要下载对应 Server 包。运行过程中 +`SalesSummary` 会直接执行;`WriteReport` 会产生确认事件,批准后才会在 +`workspace/reports/` 写入 Markdown 文件。 + +### 将同一组能力装入 Agent Service + +本地示例已经验证 Agent 的能力闭环。服务化时不需要重写 Tool,而是把它们放到 +`create_app()` 的运行期工厂中,并让 WorkspaceManager 接管 MCP 和 Skill: + +```python +from pathlib import Path + +import uvicorn + +from agentscope.app import create_app +from agentscope.app.message_bus import RedisMessageBus +from agentscope.app.storage import RedisStorage +from agentscope.app.workspace_manager import LocalWorkspaceManager +from agentscope.mcp import MCPClient, StdioMCPConfig + +from main import DATA_DIR, SKILL_DIR, SalesSummary, WriteReport + + +SERVICE_WORKSPACES = Path("./service_workspaces").resolve() + +filesystem_mcp = MCPClient( + name="filesystem", + is_stateful=True, + mcp_config=StdioMCPConfig( + command="npx", + args=[ + "-y", + "@modelcontextprotocol/server-filesystem", + str(DATA_DIR), + ], + ), + enable_tools=["list_directory", "read_file"], +) + + +async def datamuse_tools(user_id, agent_id, session_id): + del user_id, session_id + reports_dir = SERVICE_WORKSPACES / agent_id / "reports" + return [SalesSummary(), WriteReport(reports_dir=reports_dir)] + + +app = create_app( + storage=RedisStorage(host="localhost", port=6379), + message_bus=RedisMessageBus(host="localhost", port=6379), + workspace_manager=LocalWorkspaceManager( + basedir=str(SERVICE_WORKSPACES), + default_mcps=[filesystem_mcp], + skill_paths=[str(SKILL_DIR)], + ), + extra_agent_tools=datamuse_tools, + title="DataMuse Service", +) + + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=8000) +``` + +这里完成的是“能力装配层”。客户端再通过 Agent Service API 创建 Credential、 +Agent 模板和 Session,即可通过 REST 触发任务,并通过 SSE 消费与本地模式相同的 +AgentEvent。 + +--- + +## 如何选择模块 + +面对一个新需求,可以按下面的顺序判断: + +1. 先用 Model、Agent、Message 构成最小闭环。 +2. 需要执行能力时,优先判断是 Python Tool、MCP 还是 Skill。 +3. 只要存在副作用,就定义 Permission,并为 ASK 接好 HITL。 +4. 对话变长或工具结果变大时,再配置 Context 和 Workspace Offloader。 +5. tracing、RAG、记忆、预算和 TTS 放进 Middleware,不污染业务 Agent。 +6. 需要多用户、持久化、远程调用或定时任务时,再进入 Agent Service。 +7. 只有责任、工具、上下文或并行性确实需要拆分时,才使用 Multi-Agent。 + +AgentScope 2.0 的核心不是把所有模块一次性打开,而是让这些模块拥有清晰边界, +并能按应用复杂度逐层组合。 diff --git a/tutorials/QUICKSTART.md b/tutorials/QUICKSTART.md new file mode 100644 index 0000000..fbb0a9a --- /dev/null +++ b/tutorials/QUICKSTART.md @@ -0,0 +1,804 @@ +# AgentScope 2.0 完整入门:从零搭建可服务化 Agent 应用 + +本教程以 DataMuse 销售分析助手为例,从空目录开始,逐步搭建一个能调用真实工具、输出流式事件,并可以通过 HTTP/SSE 对外提供服务的 Agent 应用。 + +最终应用包含两种运行方式: + +- 本地模式:Toolkit 同时装配 Python Tool、filesystem MCP 和报告 Skill,通过事件流展示运行过程,并在写文件前请求审批。 +- 服务模式:同一组能力由 Agent Service 托管,通过 REST 创建 Agent 和 Session,通过 SSE 返回事件和审批请求。 + +最终项目结构如下: + +```text +tutorials/datamuse_app/ +├── skills/ +│ └── report_writer/ +│ └── SKILL.md # 按需加载的报告编写指南 +├── tools.py # SalesSummary + 需要审批的 ReportWriter +├── local_app.py # Python Tool + MCP + Skill 的本地 Agent +├── service.py # Agent Service 入口 +├── client.py # REST + SSE + 审批客户端 +└── reports/ # 审批通过后生成的报告 +``` + +## AgentScope 2.0 的核心特色 + +AgentScope 2.0 不只是封装一次模型请求,而是提供构建完整 Agent 应用所需的运行时能力: + +- **Agent 原生异步**:`reply()` 和 `reply_stream()` 统一支持多轮推理、工具调用和流式输出。 +- **结构化消息与事件**:文本、思考、工具调用、工具结果、用户确认等过程都有明确的数据结构,便于连接终端、Web UI 和日志系统。 +- **统一工具体系**:Python Tool、内置文件工具、MCP 和 Skill 可以由 Toolkit 统一注册和发现。 +- **权限与 Human-in-the-Loop**:工具调用可以返回 ALLOW、DENY 或 ASK,把高风险动作交给用户确认。 +- **Context 与 Workspace**:上下文压缩、工具结果截断、文件空间和沙箱能力都有明确边界。 +- **Middleware 扩展**:Tracing、计费、日志、长期记忆和 RAG 可以作为横切能力接入,而不需要改写 Agent 主流程。 +- **内置 Agent Service**:`create_app()` 提供 Credential、Agent、Session、Chat、SSE 和 Schedule 等服务接口,让本地 Agent 可以自然过渡到多用户服务。 + +本教程先建立本地 Agent 的业务闭环,再把同一组工具注入 Agent Service。这样可以同时看清 AgentScope 的库模式和服务模式。 + +开始前准备环境和目录: + +```bash +conda activate agentscope-tutorial-py312 +pip install -e ".[service]" fakeredis httpx +export DASHSCOPE_API_KEY="your-key" + +mkdir -p tutorials/datamuse_app/skills/report_writer +cd tutorials/datamuse_app +npx --version +``` + +仓库已经包含 `tutorials/data/sales_data.csv`,两种运行方式都会读取这份数据。 + +filesystem MCP 通过 `npx` 启动,因此需要提前安装 Node.js。第一次运行时,`npx` 会下载 `@modelcontextprotocol/server-filesystem`。 + +--- + +## 搭建能调用真实工具的本地 Agent + +先完成最小业务闭环:用户提出销售问题,DataMuse 判断应该调用哪个工具,工具读取 CSV 并返回真实数据,Agent 再组织最终回答。 + +### 1. AgentScope 应用的基本结构 + +```text +Credential → ChatModel → Agent + ├── Msg:用户输入和对话上下文 + ├── Toolkit:Agent 当前可调用的工具 + ├── Permission:工具调用边界 + └── Event:文本、模型调用、工具调用等运行过程 +``` + +各部分的职责: + +| 组件 | 做什么 | 什么时候需要 | +|---|---|---| +| Credential | 保存模型服务认证信息 | 调用任何远程模型时 | +| ChatModel | 适配具体模型提供商 | 选择 DashScope、OpenAI、Ollama 等模型时 | +| Agent | 维护提示词、上下文和 reasoning-acting 循环 | 应用需要多轮推理或工具调用时 | +| Msg | 表示用户、助手和系统消息 | 向 Agent 输入结构化内容时 | +| Toolkit | 注册 Python Tool、MCP 和 Skill | Agent 需要访问真实数据、外部服务或操作指南时 | +| Permission | 对工具调用做 ALLOW、DENY、ASK 判定 | 工具会读取、修改或访问外部系统时 | +| Event | 暴露 Agent 的运行过程 | 构建终端、Web UI、日志或 HITL 时 | + +### 2. 定义 Python Tool + +新建 `tools.py`: + +```python +import csv +import re +from pathlib import Path +from typing import Any + +from agentscope.message import TextBlock +from agentscope.permission import ( + PermissionBehavior, + PermissionContext, + PermissionDecision, +) +from agentscope.tool import ToolBase, ToolChunk + + +DATA_DIR = Path(__file__).resolve().parent.parent / "data" +SALES_CSV = DATA_DIR / "sales_data.csv" +REPORTS_DIR = Path(__file__).resolve().parent / "reports" + + +class SalesSummary(ToolBase): + """Summarize the shared sales dataset by a business dimension.""" + + name = "SalesSummary" + description = ( + "Read the sales dataset and calculate order count, revenue, and " + "average order value grouped by category or region." + ) + input_schema = { + "type": "object", + "properties": { + "group_by": { + "type": "string", + "enum": ["category", "region"], + "description": "Business dimension used for grouping.", + }, + }, + "required": ["group_by"], + } + is_read_only = True + is_concurrency_safe = True + + async def check_permissions( + self, + _tool_input: dict[str, Any], + _context: PermissionContext, + ) -> PermissionDecision: + return PermissionDecision( + behavior=PermissionBehavior.ALLOW, + message="SalesSummary only reads the fixed demo dataset.", + ) + + async def call(self, group_by: str) -> ToolChunk: + with SALES_CSV.open("r", encoding="utf-8") as csv_file: + rows = list(csv.DictReader(csv_file)) + + groups: dict[str, list[dict[str, str]]] = {} + for row in rows: + groups.setdefault(row[group_by], []).append(row) + + lines = [ + f"Sales summary by {group_by}", + "group | orders | revenue | avg_order", + "--- | ---: | ---: | ---:", + ] + for name, group_rows in sorted(groups.items()): + revenue = sum(float(row["total"]) for row in group_rows) + average = revenue / len(group_rows) + lines.append( + f"{name} | {len(group_rows)} | ${revenue:,.2f} | " + f"${average:,.2f}", + ) + + return ToolChunk(content=[TextBlock(text="\n".join(lines))]) + + +class ReportWriter(ToolBase): + """Write an approved Markdown report to the application directory.""" + + name = "ReportWriter" + description = ( + "Write a completed sales analysis report to a Markdown file. " + "Call this only after the analysis is complete." + ) + input_schema = { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Report title.", + }, + "markdown": { + "type": "string", + "description": "Complete Markdown report body.", + }, + "filename": { + "type": "string", + "description": "Output filename.", + "default": "sales_report.md", + }, + }, + "required": ["title", "markdown"], + } + is_read_only = False + is_concurrency_safe = False + + async def check_permissions( + self, + _tool_input: dict[str, Any], + _context: PermissionContext, + ) -> PermissionDecision: + return PermissionDecision( + behavior=PermissionBehavior.ASK, + message="ReportWriter creates a file and requires approval.", + ) + + async def call( + self, + title: str, + markdown: str, + filename: str = "sales_report.md", + ) -> ToolChunk: + REPORTS_DIR.mkdir(parents=True, exist_ok=True) + safe_name = re.sub(r"[^A-Za-z0-9_.-]+", "_", filename).strip("._") + if not safe_name.endswith(".md"): + safe_name += ".md" + + output_path = REPORTS_DIR / safe_name + output_path.write_text( + f"# {title}\n\n{markdown.strip()}\n", + encoding="utf-8", + ) + return ToolChunk( + content=[TextBlock(text=f"Report written to {output_path}")], + ) + + +def build_tools() -> list[ToolBase]: + """Build fresh tools for a local Agent.""" + return [SalesSummary(), ReportWriter()] + + +async def build_service_tools( + _user_id: str, + _agent_id: str, + _session_id: str, +) -> list[ToolBase]: + """Build fresh tools whenever Agent Service assembles an Agent.""" + return build_tools() +``` + +这里选择自定义 `ToolBase`,是因为同一工具随后还要注入 Agent Service,并且需要明确声明权限行为: + +- `input_schema` 告诉模型应该怎样调用工具。 +- `is_read_only=True` 描述工具没有写入副作用。 +- `SalesSummary.check_permissions()` 返回 ALLOW,因为它只读取固定数据。 +- `ReportWriter.check_permissions()` 返回 ASK,因为它会创建文件。 +- `call()` 执行真实计算并返回 `ToolChunk`。 +- `build_tools()` 和 `build_service_tools()` 分别对应本地组装和服务端组装。 + +### 3. 添加 Skill + +Skill 不是可执行函数,而是一份按需加载的操作指南。Toolkit 只把 Skill 的名称和描述放进系统提示;需要执行对应任务时,Agent 再调用内置的 `Skill` 工具读取完整内容。 + +新建 `skills/report_writer/SKILL.md`: + +```markdown +--- +name: report_writer +description: Create a concise Markdown sales analysis report from verified tool results. +--- + +# Report Writer + +1. Only use figures returned by SalesSummary. +2. Start with an executive summary, then list key findings and actions. +3. Keep the report concise and include the grouping dimension. +4. Call ReportWriter only after the complete Markdown body is ready. +5. Use `sales_report.md` as the default filename. +``` + +Skill 解决的是“应该按什么步骤组合工具”,Python Tool 解决的是“具体执行什么动作”。二者不能互相替代。 + +### 4. 接入 MCP 并创建本地 Agent + +Toolkit 可以同时接收三类能力来源: + +| 来源 | 本例 | 作用 | +|---|---|---| +| `tools` | `SalesSummary`、`ReportWriter` | 本地 Python 业务能力 | +| `mcps` | filesystem MCP | 通过标准协议列目录、读取文件 | +| `skills_or_loaders` | `report_writer` | 按需加载报告编写指南 | + +MCP 工具会使用 `mcp__{server_name}__{tool_name}` 命名空间,避免多个服务出现同名工具。Skill 存在时,Toolkit 会额外暴露名为 `Skill` 的只读工具。 + +新建 `local_app.py`: + +```python +import asyncio +import os +from collections.abc import AsyncIterator +from pathlib import Path + +from agentscope.agent import Agent +from agentscope.credential import DashScopeCredential +from agentscope.event import ( + AgentEvent, + ConfirmResult, + EventType, + UserConfirmResultEvent, +) +from agentscope.mcp import MCPClient, StdioMCPConfig +from agentscope.message import UserMsg +from agentscope.model import DashScopeChatModel +from agentscope.permission import PermissionContext, PermissionMode +from agentscope.skill import LocalSkillLoader +from agentscope.state import AgentState +from agentscope.tool import Toolkit + +from tools import DATA_DIR, build_tools + + +SKILLS_DIR = Path(__file__).resolve().parent / "skills" + + +async def stream_with_approval(agent: Agent, message: UserMsg) -> None: + """Render events and resume the Agent after user confirmation.""" + + async def process(stream: AsyncIterator[AgentEvent]) -> None: + async for event in stream: + if event.type == EventType.TOOL_CALL_START: + print(f"\n[tool] {event.tool_call_name}") + + elif event.type == EventType.TOOL_RESULT_END: + print(f"[tool result] {event.state}") + + elif event.type == EventType.TEXT_BLOCK_DELTA: + print(event.delta, end="", flush=True) + + elif event.type == EventType.REQUIRE_USER_CONFIRM: + results = [] + for tool_call in event.tool_calls: + print(f"\n[approval required] {tool_call.name}") + print(f"input: {tool_call.input}") + answer = await asyncio.to_thread( + input, + "Approve this tool call? [y/N] ", + ) + results.append( + ConfirmResult( + confirmed=answer.strip().lower() == "y", + tool_call=tool_call, + ), + ) + + await process( + agent.reply_stream( + UserConfirmResultEvent( + reply_id=event.reply_id, + confirm_results=results, + ), + ), + ) + + elif event.type == EventType.REPLY_END: + print() + + await process(agent.reply_stream(message)) + + +async def main() -> None: + model = DashScopeChatModel( + credential=DashScopeCredential( + api_key=os.environ["DASHSCOPE_API_KEY"], + ), + model="qwen-plus", + ) + + filesystem_mcp = MCPClient( + name="filesystem", + is_stateful=True, + mcp_config=StdioMCPConfig( + command="npx", + args=[ + "-y", + "@modelcontextprotocol/server-filesystem", + str(DATA_DIR), + ], + ), + enable_tools=["list_directory", "read_file"], + ) + await filesystem_mcp.connect() + + try: + agent = Agent( + name="DataMuse", + system_prompt=( + "You are DataMuse, a concise sales-data analyst. " + "Use filesystem MCP tools to inspect available data files. " + "Use SalesSummary for every sales figure. Before writing " + "a report, call Skill with skill='report_writer', follow " + "its instructions, then call ReportWriter." + ), + model=model, + toolkit=Toolkit( + tools=build_tools(), + mcps=[filesystem_mcp], + skills_or_loaders=[ + LocalSkillLoader( + directory=str(SKILLS_DIR), + scan_subdir=True, + ), + ], + ), + state=AgentState( + permission_context=PermissionContext( + mode=PermissionMode.DEFAULT, + ), + ), + ) + + await stream_with_approval( + agent, + UserMsg( + name="user", + content=( + "Use filesystem MCP to list the data directory, " + "summarize revenue by region, then use the " + "report_writer skill to write a Markdown report." + ), + ), + ) + finally: + await filesystem_mcp.close() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +运行: + +```bash +conda activate agentscope-tutorial-py312 +cd tutorials/datamuse_app +python local_app.py +``` + +`reply_stream()` 返回的是 `AgentEvent` 异步流。当前终端处理五类关键事件: + +- `TOOL_CALL_START`:显示 Agent 选择了哪个工具。 +- `TOOL_RESULT_END`:显示工具是否执行成功。 +- `TEXT_BLOCK_DELTA`:实时打印模型生成的文本。 +- `REQUIRE_USER_CONFIRM`:展示待执行工具并询问是否批准。 +- `REPLY_END`:标记一次回复结束。 + +如果只关心最终结果,可以改用 `await agent.reply(message)`;如果要构建 UI、展示工具进度或处理用户确认,就保留 `reply_stream()`。 + +### 5. 权限与审批流程 + +权限配置通过 `state=AgentState(permission_context=...)` 装入 Agent。本例使用 `PermissionMode.DEFAULT`:只读工具可以由工具自身明确 ALLOW,写文件工具返回 ASK 并暂停执行。 + +五种模式的核心差异: + +| 模式 | 行为 | 适用情况 | +|---|---|---| +| `DEFAULT` | 未明确允许的操作进入 ASK | 有交互界面的普通应用 | +| `ACCEPT_EDITS` | 工作目录内编辑和只读操作可自动允许 | 本地开发和代码修改 | +| `EXPLORE` | 只读 ALLOW,修改 DENY | 浏览数据或代码 | +| `BYPASS` | 跳过工具 ASK,默认 ALLOW | 完全可信的隔离环境 | +| `DONT_ASK` | 把所有 ASK 转成 DENY | 定时任务和无人值守运行 | + +`BYPASS` 会跳过工具返回的安全 ASK,不能把它当作带保护的默认模式。 + +本例的审批链路: + +```text +ReportWriter.check_permissions() → ASK + ↓ +REQUIRE_USER_CONFIRM Agent 暂停 + ↓ +终端输入 y / n + ↓ +UserConfirmResultEvent + ↓ +agent.reply_stream(confirm_event) 恢复同一次回复 +``` + +`ConfirmResult` 不带 `rules` 时只批准当前调用;如果把事件中的 `suggested_rules` 一并返回,可以把本次决定沉淀为后续调用的权限规则。 + +完成本地模式后,DataMuse 已经具备完整的 Agent 运行闭环:模型负责推理,Toolkit 提供真实能力,Permission 控制调用边界,Event 暴露运行过程。 + +--- + +## 把同一个 Agent 变成 HTTP 服务 + +服务化过程不重写业务工具,只替换应用的组装和调用方式:工具由服务宿主注入,模型在创建 Session 时绑定,客户端通过 REST 触发运行并通过 SSE 接收事件。 + +### 1. 从本地对象到 Agent Service + +```text +HTTP Client + ├── POST /credential/ 注册模型凭据 + ├── POST /agent/ 创建 Agent 模板 + ├── POST /sessions/ 创建 Session 并绑定模型 + ├── GET /sessions/{id}/stream 订阅 SSE 事件 + └── POST /chat/ 触发一次 Agent 运行 + ↓ +Agent Service + ├── Storage:保存 Credential、Agent、Session 和消息 + ├── MessageBus:传递实时事件 + ├── WorkspaceManager:为 Session 注入 MCP、Skill 和工作目录 + └── extra_agent_tools:注入 SalesSummary、ReportWriter +``` + +Agent 和 Session 在服务模式下分工不同: + +| 对象 | 保存什么 | +|---|---| +| Agent 模板 | `name`、`system_prompt`、Context/ReAct 配置 | +| Session | `agent_id`、模型配置、对话历史、运行状态 | +| 服务宿主 | Python Tool、MCP、Skill、Middleware、Storage、MessageBus、Workspace | + +Python 工具不能放进 `POST /agent/` 的 JSON。`extra_agent_tools` 是工具进入服务端 Agent 的组装点。 + +### 2. 创建 Agent Service + +新建 `service.py`: + +```python +from pathlib import Path +from typing import Any + +import fakeredis.aioredis +import uvicorn + +from agentscope.app import create_app +from agentscope.app.message_bus import InMemoryMessageBus +from agentscope.app.storage import RedisStorage +from agentscope.app.workspace_manager import LocalWorkspaceManager +from agentscope.mcp import MCPClient, StdioMCPConfig + +from tools import DATA_DIR, build_service_tools + + +WORKDIR = Path(__file__).resolve().parent / "workspaces" +SKILLS_DIR = Path(__file__).resolve().parent / "skills" + + +def make_demo_storage() -> Any: + """Use RedisStorage's data model with an in-process fakeredis client.""" + storage = RedisStorage.__new__(RedisStorage) + storage._client = fakeredis.aioredis.FakeRedis(decode_responses=True) + storage._external_pool = None + storage._owned_pool = None + storage.key_ttl = None + storage.key_config = RedisStorage.KeyConfig() + return storage + + +filesystem_mcp = MCPClient( + name="filesystem", + is_stateful=True, + mcp_config=StdioMCPConfig( + command="npx", + args=[ + "-y", + "@modelcontextprotocol/server-filesystem", + str(DATA_DIR), + ], + ), + enable_tools=["list_directory", "read_file"], +) + + +app = create_app( + storage=make_demo_storage(), + message_bus=InMemoryMessageBus(), + workspace_manager=LocalWorkspaceManager( + basedir=str(WORKDIR), + default_mcps=[filesystem_mcp], + skill_paths=[str(SKILLS_DIR / "report_writer")], + ), + extra_agent_tools=build_service_tools, + title="DataMuse Service", + version="1.0.0", +) + + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=8000) +``` + +先使用 `fakeredis`,可以在不启动外部 Redis 的情况下走通完整 HTTP 流程。`LocalWorkspaceManager` 会初始化 filesystem MCP,并把 `report_writer` Skill 放进新建的 Workspace。需要持久化或多进程部署时,把 `make_demo_storage()` 替换成真实 `RedisStorage(...)`,并把 `InMemoryMessageBus` 换成跨进程 MessageBus。 + +启动服务: + +```bash +conda activate agentscope-tutorial-py312 +cd tutorials/datamuse_app +python service.py +``` + +OpenAPI 文档位于 `http://localhost:8000/docs`。 + +### 3. 创建 REST + SSE 客户端 + +新建 `client.py`: + +```python +import asyncio +import json +import os + +import httpx + + +BASE_URL = "http://localhost:8000" +HEADERS = { + "X-User-Id": "demo-user", + "Content-Type": "application/json", +} + + +async def submit_approval( + client: httpx.AsyncClient, + agent_id: str, + session_id: str, + event: dict, +) -> None: + """Ask for approval and resume the parked service-side reply.""" + results = [] + for tool_call in event["tool_calls"]: + print(f"\n[approval required] {tool_call['name']}") + print(f"input: {tool_call['input']}") + answer = await asyncio.to_thread( + input, + "Approve this tool call? [y/N] ", + ) + results.append( + { + "confirmed": answer.strip().lower() == "y", + "tool_call": tool_call, + "rules": None, + }, + ) + + response = await client.post( + "/chat/", + headers=HEADERS, + json={ + "agent_id": agent_id, + "session_id": session_id, + "input": { + "type": "USER_CONFIRM_RESULT", + "reply_id": event["reply_id"], + "confirm_results": results, + }, + }, + ) + response.raise_for_status() + + +async def main() -> None: + async with httpx.AsyncClient( + base_url=BASE_URL, + timeout=30.0, + ) as client: + credential_response = await client.post( + "/credential/", + headers=HEADERS, + json={ + "data": { + "type": "dashscope_credential", + "api_key": os.environ["DASHSCOPE_API_KEY"], + }, + }, + ) + credential_response.raise_for_status() + credential_id = credential_response.json()["credential_id"] + + agent_response = await client.post( + "/agent/", + headers=HEADERS, + json={ + "name": "DataMuse", + "system_prompt": ( + "You are DataMuse, a concise sales-data analyst. " + "Use filesystem MCP tools to inspect available data " + "files. Use SalesSummary for every sales figure. " + "Before writing a report, call Skill with " + "skill='report_writer', follow its instructions, " + "then call ReportWriter." + ), + }, + ) + agent_response.raise_for_status() + agent_id = agent_response.json()["agent_id"] + + session_response = await client.post( + "/sessions/", + headers=HEADERS, + json={ + "agent_id": agent_id, + "name": "DataMuse demo session", + "chat_model_config": { + "type": "dashscope_chat", + "credential_id": credential_id, + "model": "qwen-plus", + "parameters": {}, + }, + }, + ) + session_response.raise_for_status() + session_id = session_response.json()["session_id"] + + chat_body = { + "agent_id": agent_id, + "session_id": session_id, + "input": { + "name": "user", + "role": "user", + "content": [ + { + "type": "text", + "text": ( + "Use filesystem MCP to list the data directory, " + "summarize revenue by category, then use the " + "report_writer skill to write a Markdown report." + ), + }, + ], + }, + } + + async with client.stream( + "GET", + f"/sessions/{session_id}/stream", + params={"agent_id": agent_id}, + headers=HEADERS, + timeout=httpx.Timeout(60.0, read=None), + ) as stream_response: + stream_response.raise_for_status() + + chat_response = await client.post( + "/chat/", + headers=HEADERS, + json=chat_body, + ) + chat_response.raise_for_status() + + async for line in stream_response.aiter_lines(): + if not line.startswith("data:"): + continue + payload = line[len("data:") :].strip() + if not payload or payload == "[DONE]": + continue + + event = json.loads(payload) + event_type = event.get("type") + if event_type == "TOOL_CALL_START": + print(f"\n[tool] {event.get('tool_call_name')}") + elif event_type == "TOOL_RESULT_END": + print(f"[tool result] {event.get('state')}") + elif event_type == "TEXT_BLOCK_DELTA": + print(event.get("delta", ""), end="", flush=True) + elif event_type == "REQUIRE_USER_CONFIRM": + await submit_approval( + client, + agent_id, + session_id, + event, + ) + elif event_type == "REPLY_END": + print() + break + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +保持 `service.py` 运行,在另一个终端执行: + +```bash +conda activate agentscope-tutorial-py312 +cd tutorials/datamuse_app +python client.py +``` + +客户端遵循固定顺序:先创建 Credential、Agent 和 Session,再建立 SSE 连接,最后用 `/chat/` 触发运行。`POST /chat/` 只返回任务已经启动,真正的 AgentEvent 来自 `/sessions/{id}/stream`。 + +服务端审批沿用本地模式的同一协议:SSE 推送 `REQUIRE_USER_CONFIRM`,客户端展示工具名称和输入,随后把 `USER_CONFIRM_RESULT` 作为新的 `/chat/` 输入提交。回复恢复后产生的工具结果和文本仍然沿原 SSE 连接返回。 + +### 4. 从本地演示过渡到实际服务 + +当前代码已经具备服务化应用的主要边界: + +- 使用 `X-User-Id` 隔离不同用户的资源。 +- 使用 Agent 模板复用系统提示和运行配置。 +- 使用 Session 隔离模型、历史消息和运行状态。 +- 使用 `extra_agent_tools` 在服务宿主侧注入 Python Tool。 +- 使用 WorkspaceManager 为 Session 注入 MCP、Skill 和独立工作目录。 +- 使用 REST 触发任务,使用 SSE 推送运行事件。 +- 使用 `REQUIRE_USER_CONFIRM` / `USER_CONFIRM_RESULT` 完成跨 HTTP 的权限审批。 + +进一步部署时,保持业务 Tool 和 Agent 模板不变,替换基础设施即可: + +| 当前实现 | 部署时替换为 | +|---|---| +| fakeredis | 独立 Redis / 托管 Redis | +| InMemoryMessageBus | 支持多进程的 MessageBus | +| LocalWorkspaceManager | Docker、E2B 或 K8s WorkspaceManager | +| 单一模型配置 | 主模型 + `fallback_chat_model_config` | +| `X-User-Id` 直接传入 | 网关或认证中间件解析出的用户身份 | + +到这里,DataMuse 已经从一个本地脚本演进为完整的可服务化 Agent 应用:Python Tool 提供业务动作,MCP 连接外部能力,Skill 提供按需操作指南,Permission/HITL 保护写入操作;同一套能力既能在本地 Agent 中运行,也能由 Agent Service 按 Session 组装,并通过标准 HTTP/SSE 接入终端、Web 或其他业务系统。 + +这个项目可以继续扩展图表生成、报告写入、用户确认、模型 fallback、定时任务和多 Agent 协作,但这些能力都建立在当前的 Agent、Tool、Event、Workspace 和 Session 边界之上,不需要推翻现有结构。 diff --git a/tutorials/README.md b/tutorials/README.md new file mode 100644 index 0000000..f2880d4 --- /dev/null +++ b/tutorials/README.md @@ -0,0 +1,118 @@ +# AgentScope 2.0 Tutorial Series + +> **DataMuse** — 一个从零到可部署的智能数据分析助手 + +本教程系列以一个**数据分析助手 DataMuse** 为业务主线:学生始终围绕同一份销售数据解决“读取、分析、解释、交付报告”的问题,再逐步学习工具调用、权限控制、人机协作、流式 UI、上下文管理、中间件、服务化、定时任务和多 Agent 协作。 + +这里复用的是**业务目标和数据语境**,不是要求 16 章始终运行同一个 Python 进程。随着部署方式和协作方式变化,DataMuse 会出现三种应用形态。 + +## DataMuse 的三种应用形态 + +| 形态 | 章节 | 解决的问题 | +|---|---|---| +| 本地单 Agent | T01-T12,T16 收束 | 从最小对话开始,逐块加入 Tool、Permission、HITL、Context、Middleware 和 Workspace,最后组装成自包含应用 | +| Agent Service | T13-T14 | 把相同能力放到 HTTP 服务中,支持多用户、Session、SSE 和定时任务 | +| Agent Team | T15 | 当工具、上下文、并行性或责任边界确实需要拆分时,把 DataMuse 展开为多个角色 | + +推荐顺序是先掌握单 Agent,再根据应用需要选择服务化或团队化。Multi-Agent 不是“更完整”的必经阶段,T16 也不会为了形式完整而把三种架构强行塞进一个示例。 + +## 目标受众 + +有 LLM API 调用经验的**中级开发者**,了解 Agent 基本概念,想系统学习 AgentScope 2.0。 + +## 前置要求 + +- Python 3.12 +- `pip install agentscope` +- 至少一个 LLM API Key(DashScope / OpenAI / Ollama) + +## 教程列表 + +### Phase 1: 基础篇 + +| # | 主题 | 你将学到 | +|---|------|----------| +| [01](01_hello_agentscope/) | **Hello AgentScope** | 核心四要素、reply vs reply_stream、切换模型 | +| [02](02_message_and_event/) | **Message & Event** | 消息结构、事件生命周期、append_event 重建消息 | +| [03](03_tools/) | **Tool 系统** | 内置工具、FunctionTool、自定义 ToolBase | + +### Phase 2: 进阶篇 + +| # | 主题 | 你将学到 | +|---|------|----------| +| [04](04_tool_groups/) | **Tool Group** | 工具分组、动态切换、reset_tools 元工具 | +| [05](05_mcp_integration/) | **MCP 集成** | MCP 协议、Stdio/HTTP 连接、与本地工具混合使用 | +| [06](06_skills/) | **Skill** | Markdown 技能定义、`Skill` 工具、按需加载 | +| [07](07_permissions/) | **Permission 系统** | 五种模式、规则配置、危险路径保护 | +| [08](08_human_in_the_loop/) | **Human-in-the-Loop** | 用户确认、外部执行、渐进式信任 | +| [09](09_streaming_ui/) | **流式 UI** | 事件分发、Token 追踪、终端 UI | +| [10](10_context_management/) | **Context 管理** | 上下文压缩、工具结果截断、Offloader | + +### Phase 3: 工程篇 + +| # | 主题 | 你将学到 | +|---|------|----------| +| [11](11_middleware/) | **Middleware** | 执行 Hook、压缩 Hook、TracingMiddleware、计费/日志 | +| [12](12_workspace/) | **Workspace** | LocalWorkspace、Docker/E2B 隔离、Offloader、MCP/Skill 管理 | +| [13](13_agent_service/) | **Agent Service** | FastAPI 服务、多租户、Session、Credential、Web UI、**模型 fallback / 自动重试** | +| [14](14_scheduling/) | **Schedule** | Cron 定时任务、Stateful/Stateless 模式 | + +### Phase 4: 高级篇 + +| # | 主题 | 你将学到 | +|---|------|----------| +| [15](15_multi_agent/) | **Multi-Agent** | 多 Agent 编排、observe()、串行/并行/动态路由 | +| [16](16_complete_datamuse/) | **Complete DataMuse** | 把本地单 Agent 核心模块收束为命令行与轻量 Web 应用 | + +## 示例数据 + +所有教程共用同一份电商销售数据集 `data/sales_data.csv`(1000 行),包含: + +``` +order_id, date, product, category, quantity, unit_price, discount, total, region, payment_method, customer_tier +``` + +生成方式: + +```bash +cd tutorials/data +python generate_sales_data.py +``` + +## 快速开始 + +如果希望从零搭出一个可服务化 Agent 应用,请直接使用 [AgentScope 2.0 完整入门教程](QUICKSTART.md)。 + +如果希望先系统理解各模块的职责、接线位置和选型边界,再查看完整组装示例,请阅读 +[AgentScope 2.0 模块全景与完整应用](MODULE_GUIDE.md)。 + +```bash +# 准备环境 +conda create -n agentscope-tutorial-py312 python=3.12 -y +conda activate agentscope-tutorial-py312 +pip install agentscope + +# 设置 API Key +export DASHSCOPE_API_KEY="your-key" + +# 运行第一个教程 +cd tutorials/01_hello_agentscope +python main.py +``` + +## 最终整合 + +如果你想先看完整应用的样子,可以直接运行 [16_complete_datamuse](16_complete_datamuse/): + +```bash +# 如果还没有准备环境,先执行: +conda create -n agentscope-tutorial-py312 python=3.12 -y +conda activate agentscope-tutorial-py312 +pip install agentscope +export DASHSCOPE_API_KEY="your-key" + +cd tutorials/16_complete_datamuse +python main.py +``` + +它会把 T01-T12 中适合本地应用的关键模块收束成一个最小可用的 DataMuse:读取销售数据、做维度拆解、在写报告前触发确认,并把 Markdown 报告保存到本地 workspace。T13-T15 则分别保留为服务化、调度和团队化的独立扩展路径。 diff --git a/tutorials/data/generate_sales_data.py b/tutorials/data/generate_sales_data.py new file mode 100644 index 0000000..11f37c7 --- /dev/null +++ b/tutorials/data/generate_sales_data.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +"""Generate sample e-commerce sales data for the tutorial series.""" +# pylint: disable=missing-function-docstring +import csv +import random +from datetime import datetime, timedelta + +random.seed(42) + +PRODUCTS = { + "Electronics": [ + ("Laptop Pro 15", 1299.99), + ("Wireless Mouse", 29.99), + ("USB-C Hub", 49.99), + ("Mechanical Keyboard", 89.99), + ("Monitor 27-inch", 399.99), + ("Webcam HD", 59.99), + ], + "Clothing": [ + ("Cotton T-Shirt", 19.99), + ("Denim Jeans", 49.99), + ("Running Shoes", 79.99), + ("Winter Jacket", 129.99), + ("Wool Scarf", 24.99), + ], + "Books": [ + ("Python Programming", 39.99), + ("Data Science Handbook", 44.99), + ("AI Revolution", 29.99), + ("Machine Learning Guide", 54.99), + ], + "Home & Kitchen": [ + ("Coffee Maker", 89.99), + ("Air Purifier", 199.99), + ("Smart Lamp", 34.99), + ("Water Bottle", 14.99), + ("Desk Organizer", 22.99), + ], + "Sports": [ + ("Yoga Mat", 29.99), + ("Resistance Bands Set", 19.99), + ("Dumbbell Set", 69.99), + ("Jump Rope", 12.99), + ], +} + +REGIONS = ["North", "South", "East", "West", "Central"] +PAYMENT_METHODS = [ + "Credit Card", + "PayPal", + "Bank Transfer", + "Cash on Delivery", +] +CUSTOMER_TIERS = ["Standard", "Premium", "VIP"] + + +def generate_sales_data(n_rows: int = 1000) -> list[dict]: + start_date = datetime(2024, 1, 1) + end_date = datetime(2024, 12, 31) + delta = (end_date - start_date).days + + rows = [] + for i in range(1, n_rows + 1): + category = random.choice(list(PRODUCTS.keys())) + product_name, base_price = random.choice(PRODUCTS[category]) + quantity = random.randint(1, 10) + discount = random.choice([0, 0, 0, 0.05, 0.10, 0.15, 0.20]) + unit_price = round(base_price * (1 - discount), 2) + total = round(unit_price * quantity, 2) + order_date = start_date + timedelta(days=random.randint(0, delta)) + + rows.append( + { + "order_id": f"ORD-{i:05d}", + "date": order_date.strftime("%Y-%m-%d"), + "product": product_name, + "category": category, + "quantity": quantity, + "unit_price": unit_price, + "discount": discount, + "total": total, + "region": random.choice(REGIONS), + "payment_method": random.choice(PAYMENT_METHODS), + "customer_tier": random.choice(CUSTOMER_TIERS), + }, + ) + + rows.sort(key=lambda r: r["date"]) + return rows + + +if __name__ == "__main__": + data = generate_sales_data(1000) + fieldnames = list(data[0].keys()) + + with open("sales_data.csv", "w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(data) + + print(f"Generated {len(data)} rows -> sales_data.csv") diff --git a/tutorials/data/sales_data.csv b/tutorials/data/sales_data.csv new file mode 100644 index 0000000..d4203bd --- /dev/null +++ b/tutorials/data/sales_data.csv @@ -0,0 +1,1001 @@ +order_id,date,product,category,quantity,unit_price,discount,total,region,payment_method,customer_tier +ORD-00123,2024-01-01,Mechanical Keyboard,Electronics,9,71.99,0.2,647.91,Central,Cash on Delivery,Premium +ORD-00480,2024-01-01,Data Science Handbook,Books,3,38.24,0.15,114.72,Central,Cash on Delivery,VIP +ORD-00569,2024-01-01,Coffee Maker,Home & Kitchen,9,80.99,0.1,728.91,North,Bank Transfer,VIP +ORD-00646,2024-01-01,Air Purifier,Home & Kitchen,10,179.99,0.1,1799.9,West,Cash on Delivery,Premium +ORD-00661,2024-01-01,USB-C Hub,Electronics,4,42.49,0.15,169.96,South,Credit Card,Premium +ORD-00810,2024-01-01,Resistance Bands Set,Sports,5,19.99,0,99.95,North,Credit Card,Premium +ORD-00019,2024-01-02,Machine Learning Guide,Books,3,52.24,0.05,156.72,East,PayPal,VIP +ORD-00966,2024-01-02,Jump Rope,Sports,4,12.99,0,51.96,South,Bank Transfer,Premium +ORD-00540,2024-01-03,Data Science Handbook,Books,1,38.24,0.15,38.24,South,PayPal,VIP +ORD-00612,2024-01-03,Resistance Bands Set,Sports,2,19.99,0,39.98,Central,Cash on Delivery,VIP +ORD-00649,2024-01-03,Machine Learning Guide,Books,5,54.99,0,274.95,North,Credit Card,VIP +ORD-00004,2024-01-04,Desk Organizer,Home & Kitchen,5,18.39,0.2,91.95,South,Cash on Delivery,Premium +ORD-00027,2024-01-04,Resistance Bands Set,Sports,10,19.99,0,199.9,North,Credit Card,Standard +ORD-00345,2024-01-04,Jump Rope,Sports,8,12.99,0,103.92,Central,Cash on Delivery,Standard +ORD-00907,2024-01-04,Mechanical Keyboard,Electronics,2,89.99,0,179.98,South,Credit Card,VIP +ORD-00050,2024-01-05,Desk Organizer,Home & Kitchen,7,20.69,0.1,144.83,North,Credit Card,VIP +ORD-00684,2024-01-05,Denim Jeans,Clothing,3,47.49,0.05,142.47,South,Cash on Delivery,Premium +ORD-00483,2024-01-06,Webcam HD,Electronics,6,56.99,0.05,341.94,East,PayPal,Standard +ORD-00854,2024-01-06,Running Shoes,Clothing,3,79.99,0,239.97,North,Credit Card,Standard +ORD-00862,2024-01-06,USB-C Hub,Electronics,9,39.99,0.2,359.91,West,Cash on Delivery,VIP +ORD-00105,2024-01-07,Winter Jacket,Clothing,2,103.99,0.2,207.98,Central,Bank Transfer,Premium +ORD-00439,2024-01-07,Yoga Mat,Sports,1,23.99,0.2,23.99,South,Credit Card,Premium +ORD-00552,2024-01-07,Smart Lamp,Home & Kitchen,4,34.99,0,139.96,North,Credit Card,VIP +ORD-00584,2024-01-07,Dumbbell Set,Sports,7,55.99,0.2,391.93,Central,Credit Card,Standard +ORD-00034,2024-01-08,Laptop Pro 15,Electronics,9,1039.99,0.2,9359.91,North,PayPal,Standard +ORD-00167,2024-01-08,Webcam HD,Electronics,1,59.99,0,59.99,East,PayPal,VIP +ORD-00428,2024-01-08,Laptop Pro 15,Electronics,10,1299.99,0,12999.9,East,PayPal,Standard +ORD-00280,2024-01-09,Dumbbell Set,Sports,8,55.99,0.2,447.92,East,Bank Transfer,VIP +ORD-00118,2024-01-11,Wireless Mouse,Electronics,10,26.99,0.1,269.9,East,Credit Card,Standard +ORD-00546,2024-01-11,Webcam HD,Electronics,2,50.99,0.15,101.98,North,Credit Card,Premium +ORD-00544,2024-01-12,Denim Jeans,Clothing,3,49.99,0,149.97,Central,PayPal,Standard +ORD-00944,2024-01-12,USB-C Hub,Electronics,4,49.99,0,199.96,South,Bank Transfer,Premium +ORD-00156,2024-01-13,Python Programming,Books,4,37.99,0.05,151.96,Central,Bank Transfer,VIP +ORD-00696,2024-01-14,Mechanical Keyboard,Electronics,9,89.99,0,809.91,West,Credit Card,Standard +ORD-00780,2024-01-14,Cotton T-Shirt,Clothing,3,19.99,0,59.97,Central,Bank Transfer,VIP +ORD-00058,2024-01-15,Machine Learning Guide,Books,9,54.99,0,494.91,North,Bank Transfer,Standard +ORD-00079,2024-01-15,Running Shoes,Clothing,7,75.99,0.05,531.93,West,Bank Transfer,VIP +ORD-00518,2024-01-15,Running Shoes,Clothing,2,79.99,0,159.98,West,Credit Card,Standard +ORD-00832,2024-01-15,Machine Learning Guide,Books,1,54.99,0,54.99,West,Credit Card,Standard +ORD-00469,2024-01-16,USB-C Hub,Electronics,10,49.99,0,499.9,East,Credit Card,Premium +ORD-00516,2024-01-16,Jump Rope,Sports,2,11.04,0.15,22.08,South,Bank Transfer,Premium +ORD-00002,2024-01-17,Yoga Mat,Sports,10,28.49,0.05,284.9,North,Credit Card,Standard +ORD-00012,2024-01-17,Python Programming,Books,4,31.99,0.2,127.96,East,Cash on Delivery,Premium +ORD-00820,2024-01-17,USB-C Hub,Electronics,6,42.49,0.15,254.94,East,Cash on Delivery,Standard +ORD-00744,2024-01-18,Water Bottle,Home & Kitchen,7,14.99,0,104.93,East,Credit Card,VIP +ORD-00950,2024-01-18,Denim Jeans,Clothing,9,42.49,0.15,382.41,East,Bank Transfer,Standard +ORD-00440,2024-01-19,Machine Learning Guide,Books,3,54.99,0,164.97,Central,Cash on Delivery,Standard +ORD-00297,2024-01-20,Resistance Bands Set,Sports,9,16.99,0.15,152.91,South,Cash on Delivery,Standard +ORD-00052,2024-01-21,Cotton T-Shirt,Clothing,5,19.99,0,99.95,East,PayPal,VIP +ORD-00990,2024-01-21,AI Revolution,Books,9,25.49,0.15,229.41,North,Bank Transfer,Premium +ORD-00089,2024-01-22,Python Programming,Books,10,31.99,0.2,319.9,East,Cash on Delivery,VIP +ORD-00193,2024-01-22,Dumbbell Set,Sports,2,66.49,0.05,132.98,West,Credit Card,Premium +ORD-00717,2024-01-22,AI Revolution,Books,3,25.49,0.15,76.47,Central,Credit Card,Premium +ORD-00752,2024-01-22,Coffee Maker,Home & Kitchen,5,80.99,0.1,404.95,East,PayPal,Premium +ORD-00234,2024-01-23,Monitor 27-inch,Electronics,6,399.99,0,2399.94,East,Bank Transfer,VIP +ORD-00236,2024-01-23,Coffee Maker,Home & Kitchen,5,89.99,0,449.95,South,Credit Card,Premium +ORD-00323,2024-01-23,Yoga Mat,Sports,4,25.49,0.15,101.96,West,Cash on Delivery,Standard +ORD-00352,2024-01-24,Wool Scarf,Clothing,8,24.99,0,199.92,North,Bank Transfer,VIP +ORD-00228,2024-01-25,Data Science Handbook,Books,5,44.99,0,224.95,South,Cash on Delivery,VIP +ORD-00038,2024-01-26,Yoga Mat,Sports,6,29.99,0,179.94,Central,Cash on Delivery,VIP +ORD-00457,2024-01-26,Running Shoes,Clothing,7,71.99,0.1,503.93,East,Bank Transfer,Standard +ORD-00491,2024-01-26,AI Revolution,Books,4,29.99,0,119.96,West,Cash on Delivery,Standard +ORD-00755,2024-01-26,Python Programming,Books,8,35.99,0.1,287.92,Central,Cash on Delivery,Premium +ORD-00048,2024-01-27,Machine Learning Guide,Books,5,43.99,0.2,219.95,North,Cash on Delivery,Premium +ORD-00106,2024-01-27,Water Bottle,Home & Kitchen,6,14.99,0,89.94,East,Cash on Delivery,Standard +ORD-00168,2024-01-27,Python Programming,Books,3,39.99,0,119.97,South,Cash on Delivery,VIP +ORD-00442,2024-01-27,Python Programming,Books,9,33.99,0.15,305.91,South,PayPal,VIP +ORD-00718,2024-01-27,Smart Lamp,Home & Kitchen,4,34.99,0,139.96,South,Credit Card,Standard +ORD-00865,2024-01-27,Desk Organizer,Home & Kitchen,3,20.69,0.1,62.07,East,Credit Card,Premium +ORD-00915,2024-01-27,Jump Rope,Sports,1,12.99,0,12.99,Central,PayPal,Standard +ORD-00104,2024-01-28,Resistance Bands Set,Sports,5,19.99,0,99.95,Central,Bank Transfer,VIP +ORD-00564,2024-01-28,Air Purifier,Home & Kitchen,9,179.99,0.1,1619.91,North,PayPal,VIP +ORD-00589,2024-01-28,Water Bottle,Home & Kitchen,6,13.49,0.1,80.94,North,Bank Transfer,Standard +ORD-00605,2024-01-28,Python Programming,Books,2,39.99,0,79.98,South,Bank Transfer,VIP +ORD-00615,2024-01-28,Wireless Mouse,Electronics,10,23.99,0.2,239.9,Central,Cash on Delivery,VIP +ORD-00762,2024-01-28,Data Science Handbook,Books,2,44.99,0,89.98,South,PayPal,Premium +ORD-00850,2024-01-28,Python Programming,Books,6,35.99,0.1,215.94,North,Bank Transfer,Standard +ORD-00919,2024-01-28,Mechanical Keyboard,Electronics,10,85.49,0.05,854.9,West,PayPal,Standard +ORD-00999,2024-01-28,Denim Jeans,Clothing,1,49.99,0,49.99,North,Cash on Delivery,VIP +ORD-00350,2024-01-29,Water Bottle,Home & Kitchen,9,14.24,0.05,128.16,North,PayPal,Standard +ORD-00471,2024-01-29,Running Shoes,Clothing,9,63.99,0.2,575.91,South,Cash on Delivery,Premium +ORD-00585,2024-01-29,Air Purifier,Home & Kitchen,1,169.99,0.15,169.99,South,Bank Transfer,Standard +ORD-00750,2024-01-29,Jump Rope,Sports,2,10.39,0.2,20.78,East,Bank Transfer,Premium +ORD-00243,2024-01-30,Machine Learning Guide,Books,5,46.74,0.15,233.7,North,Cash on Delivery,Premium +ORD-00880,2024-01-30,Laptop Pro 15,Electronics,10,1299.99,0,12999.9,Central,PayPal,VIP +ORD-00110,2024-01-31,Smart Lamp,Home & Kitchen,10,31.49,0.1,314.9,Central,Credit Card,Standard +ORD-00559,2024-01-31,Cotton T-Shirt,Clothing,3,16.99,0.15,50.97,South,Credit Card,Premium +ORD-00766,2024-01-31,Yoga Mat,Sports,5,29.99,0,149.95,East,PayPal,VIP +ORD-00459,2024-02-01,Wireless Mouse,Electronics,2,29.99,0,59.98,South,PayPal,Standard +ORD-00586,2024-02-01,AI Revolution,Books,3,23.99,0.2,71.97,East,PayPal,VIP +ORD-00026,2024-02-02,Coffee Maker,Home & Kitchen,4,89.99,0,359.96,East,Credit Card,VIP +ORD-00436,2024-02-02,Machine Learning Guide,Books,5,49.49,0.1,247.45,East,Credit Card,Premium +ORD-00629,2024-02-02,Wireless Mouse,Electronics,8,29.99,0,239.92,East,Bank Transfer,Standard +ORD-00233,2024-02-04,Python Programming,Books,6,31.99,0.2,191.94,East,PayPal,VIP +ORD-00616,2024-02-04,Yoga Mat,Sports,5,25.49,0.15,127.45,East,Credit Card,Standard +ORD-00668,2024-02-04,Mechanical Keyboard,Electronics,2,89.99,0,179.98,West,PayPal,VIP +ORD-00805,2024-02-04,AI Revolution,Books,6,28.49,0.05,170.94,West,Credit Card,Premium +ORD-00813,2024-02-04,Running Shoes,Clothing,5,63.99,0.2,319.95,East,Bank Transfer,VIP +ORD-00878,2024-02-04,Webcam HD,Electronics,1,47.99,0.2,47.99,North,Cash on Delivery,Standard +ORD-00708,2024-02-05,Dumbbell Set,Sports,7,69.99,0,489.93,South,PayPal,Premium +ORD-00890,2024-02-05,Water Bottle,Home & Kitchen,7,14.24,0.05,99.68,Central,PayPal,Standard +ORD-00604,2024-02-06,Wool Scarf,Clothing,7,21.24,0.15,148.68,South,Credit Card,Premium +ORD-00734,2024-02-06,Data Science Handbook,Books,10,38.24,0.15,382.4,North,PayPal,VIP +ORD-00887,2024-02-06,Python Programming,Books,1,39.99,0,39.99,Central,Bank Transfer,Premium +ORD-00899,2024-02-06,Resistance Bands Set,Sports,10,19.99,0,199.9,North,PayPal,Premium +ORD-00916,2024-02-06,Mechanical Keyboard,Electronics,9,89.99,0,809.91,South,Cash on Delivery,VIP +ORD-00203,2024-02-07,Yoga Mat,Sports,6,26.99,0.1,161.94,East,Cash on Delivery,Premium +ORD-00315,2024-02-07,Python Programming,Books,4,35.99,0.1,143.96,North,Cash on Delivery,Premium +ORD-00359,2024-02-07,Dumbbell Set,Sports,4,59.49,0.15,237.96,North,Bank Transfer,Standard +ORD-00153,2024-02-08,AI Revolution,Books,2,28.49,0.05,56.98,South,PayPal,VIP +ORD-00356,2024-02-08,Mechanical Keyboard,Electronics,4,71.99,0.2,287.96,East,Cash on Delivery,Premium +ORD-00488,2024-02-08,Data Science Handbook,Books,7,44.99,0,314.93,Central,Credit Card,Premium +ORD-00074,2024-02-09,Air Purifier,Home & Kitchen,5,189.99,0.05,949.95,East,PayPal,Premium +ORD-00748,2024-02-09,Jump Rope,Sports,10,12.99,0,129.9,East,Cash on Delivery,Standard +ORD-00866,2024-02-09,Coffee Maker,Home & Kitchen,3,80.99,0.1,242.97,East,Bank Transfer,Standard +ORD-00657,2024-02-10,USB-C Hub,Electronics,4,49.99,0,199.96,Central,Credit Card,VIP +ORD-00800,2024-02-10,Water Bottle,Home & Kitchen,7,14.24,0.05,99.68,Central,Credit Card,Standard +ORD-00039,2024-02-11,Resistance Bands Set,Sports,1,17.99,0.1,17.99,South,Credit Card,VIP +ORD-00083,2024-02-11,Smart Lamp,Home & Kitchen,7,34.99,0,244.93,West,Credit Card,VIP +ORD-00713,2024-02-11,Machine Learning Guide,Books,1,54.99,0,54.99,South,Credit Card,Premium +ORD-00414,2024-02-12,Python Programming,Books,6,39.99,0,239.94,East,PayPal,Standard +ORD-00602,2024-02-12,Data Science Handbook,Books,6,40.49,0.1,242.94,North,PayPal,VIP +ORD-00177,2024-02-13,Coffee Maker,Home & Kitchen,2,71.99,0.2,143.98,North,Cash on Delivery,Standard +ORD-00212,2024-02-13,Air Purifier,Home & Kitchen,10,169.99,0.15,1699.9,East,Cash on Delivery,VIP +ORD-00364,2024-02-13,USB-C Hub,Electronics,7,47.49,0.05,332.43,Central,PayPal,VIP +ORD-00423,2024-02-13,Denim Jeans,Clothing,5,42.49,0.15,212.45,South,Bank Transfer,VIP +ORD-00492,2024-02-14,USB-C Hub,Electronics,1,47.49,0.05,47.49,East,Cash on Delivery,Standard +ORD-00609,2024-02-14,USB-C Hub,Electronics,5,44.99,0.1,224.95,East,Cash on Delivery,Premium +ORD-00826,2024-02-14,Webcam HD,Electronics,10,47.99,0.2,479.9,East,Cash on Delivery,Standard +ORD-00122,2024-02-15,Desk Organizer,Home & Kitchen,1,21.84,0.05,21.84,East,Bank Transfer,Premium +ORD-00531,2024-02-15,Resistance Bands Set,Sports,6,16.99,0.15,101.94,South,Bank Transfer,Standard +ORD-00139,2024-02-16,Wireless Mouse,Electronics,5,29.99,0,149.95,West,Credit Card,VIP +ORD-00721,2024-02-16,Wireless Mouse,Electronics,3,28.49,0.05,85.47,Central,Bank Transfer,VIP +ORD-00363,2024-02-17,Laptop Pro 15,Electronics,6,1169.99,0.1,7019.94,Central,Bank Transfer,Premium +ORD-00557,2024-02-17,Desk Organizer,Home & Kitchen,5,19.54,0.15,97.7,East,Bank Transfer,Standard +ORD-00351,2024-02-18,Water Bottle,Home & Kitchen,6,13.49,0.1,80.94,Central,Credit Card,Standard +ORD-00030,2024-02-19,Water Bottle,Home & Kitchen,4,14.99,0,59.96,West,Bank Transfer,Premium +ORD-00844,2024-02-19,Jump Rope,Sports,6,11.69,0.1,70.14,West,PayPal,Premium +ORD-00824,2024-02-20,Dumbbell Set,Sports,6,69.99,0,419.94,Central,PayPal,Standard +ORD-00876,2024-02-20,AI Revolution,Books,5,29.99,0,149.95,East,Cash on Delivery,Premium +ORD-00135,2024-02-21,Wool Scarf,Clothing,10,24.99,0,249.9,West,Credit Card,VIP +ORD-00215,2024-02-21,Denim Jeans,Clothing,2,49.99,0,99.98,Central,Credit Card,VIP +ORD-00322,2024-02-21,Wireless Mouse,Electronics,4,29.99,0,119.96,Central,Credit Card,Premium +ORD-00548,2024-02-21,USB-C Hub,Electronics,7,42.49,0.15,297.43,Central,Bank Transfer,VIP +ORD-00906,2024-02-21,Wool Scarf,Clothing,1,24.99,0,24.99,Central,Cash on Delivery,Standard +ORD-00418,2024-02-22,Wireless Mouse,Electronics,7,29.99,0,209.93,North,Bank Transfer,Premium +ORD-00549,2024-02-23,Resistance Bands Set,Sports,7,15.99,0.2,111.93,Central,Credit Card,Premium +ORD-00070,2024-02-24,Denim Jeans,Clothing,1,39.99,0.2,39.99,West,PayPal,Standard +ORD-00196,2024-02-24,Python Programming,Books,7,31.99,0.2,223.93,South,Cash on Delivery,VIP +ORD-00266,2024-02-24,Running Shoes,Clothing,9,71.99,0.1,647.91,East,PayPal,Premium +ORD-00739,2024-02-24,Wool Scarf,Clothing,9,23.74,0.05,213.66,Central,Bank Transfer,VIP +ORD-00087,2024-02-25,Yoga Mat,Sports,3,29.99,0,89.97,Central,Credit Card,Premium +ORD-00484,2024-02-25,USB-C Hub,Electronics,5,39.99,0.2,199.95,North,Credit Card,Premium +ORD-00807,2024-02-25,Denim Jeans,Clothing,10,49.99,0,499.9,Central,Credit Card,Premium +ORD-00981,2024-02-25,Water Bottle,Home & Kitchen,1,14.99,0,14.99,North,Credit Card,Premium +ORD-00016,2024-02-26,Coffee Maker,Home & Kitchen,1,71.99,0.2,71.99,South,PayPal,VIP +ORD-00283,2024-02-26,Cotton T-Shirt,Clothing,10,16.99,0.15,169.9,South,Credit Card,Premium +ORD-00922,2024-02-26,Yoga Mat,Sports,5,29.99,0,149.95,West,Cash on Delivery,Premium +ORD-00989,2024-02-26,USB-C Hub,Electronics,2,47.49,0.05,94.98,Central,PayPal,Premium +ORD-00300,2024-02-27,Jump Rope,Sports,5,12.99,0,64.95,East,PayPal,VIP +ORD-00321,2024-02-27,Data Science Handbook,Books,5,44.99,0,224.95,North,Cash on Delivery,VIP +ORD-00198,2024-02-28,Cotton T-Shirt,Clothing,9,16.99,0.15,152.91,Central,PayPal,Premium +ORD-00060,2024-02-29,Machine Learning Guide,Books,10,49.49,0.1,494.9,West,PayPal,Premium +ORD-00158,2024-02-29,Laptop Pro 15,Electronics,5,1234.99,0.05,6174.95,North,PayPal,VIP +ORD-00108,2024-03-01,Dumbbell Set,Sports,2,69.99,0,139.98,Central,Cash on Delivery,VIP +ORD-00334,2024-03-01,Wireless Mouse,Electronics,4,25.49,0.15,101.96,West,PayPal,VIP +ORD-00391,2024-03-01,Python Programming,Books,3,39.99,0,119.97,South,Bank Transfer,Standard +ORD-00778,2024-03-01,Monitor 27-inch,Electronics,9,339.99,0.15,3059.91,South,Bank Transfer,Standard +ORD-00898,2024-03-01,USB-C Hub,Electronics,1,47.49,0.05,47.49,South,Cash on Delivery,VIP +ORD-00911,2024-03-01,Monitor 27-inch,Electronics,4,399.99,0,1599.96,Central,Credit Card,VIP +ORD-00040,2024-03-02,Webcam HD,Electronics,4,56.99,0.05,227.96,Central,PayPal,VIP +ORD-00101,2024-03-02,Cotton T-Shirt,Clothing,4,19.99,0,79.96,Central,PayPal,Standard +ORD-00417,2024-03-02,Webcam HD,Electronics,6,47.99,0.2,287.94,West,PayPal,Premium +ORD-00455,2024-03-02,Machine Learning Guide,Books,5,43.99,0.2,219.95,North,PayPal,VIP +ORD-00903,2024-03-02,Smart Lamp,Home & Kitchen,7,34.99,0,244.93,Central,PayPal,Premium +ORD-00969,2024-03-02,Smart Lamp,Home & Kitchen,8,29.74,0.15,237.92,East,Credit Card,Premium +ORD-00252,2024-03-03,Running Shoes,Clothing,9,79.99,0,719.91,South,PayPal,Standard +ORD-00998,2024-03-03,Data Science Handbook,Books,8,44.99,0,359.92,East,Cash on Delivery,Premium +ORD-00238,2024-03-04,Python Programming,Books,6,39.99,0,239.94,East,Cash on Delivery,Premium +ORD-00257,2024-03-04,Desk Organizer,Home & Kitchen,1,18.39,0.2,18.39,Central,PayPal,Premium +ORD-00255,2024-03-05,Water Bottle,Home & Kitchen,10,14.99,0,149.9,East,Credit Card,Premium +ORD-00259,2024-03-05,Data Science Handbook,Books,9,44.99,0,404.91,East,Credit Card,VIP +ORD-00435,2024-03-05,AI Revolution,Books,3,29.99,0,89.97,East,Credit Card,Standard +ORD-00949,2024-03-05,Laptop Pro 15,Electronics,6,1169.99,0.1,7019.94,West,Bank Transfer,Premium +ORD-00461,2024-03-06,Wireless Mouse,Electronics,8,23.99,0.2,191.92,North,Bank Transfer,Standard +ORD-00617,2024-03-06,Jump Rope,Sports,6,12.99,0,77.94,South,Credit Card,Premium +ORD-00979,2024-03-06,Python Programming,Books,2,39.99,0,79.98,South,PayPal,Premium +ORD-00049,2024-03-07,Laptop Pro 15,Electronics,6,1039.99,0.2,6239.94,East,PayPal,VIP +ORD-00192,2024-03-07,Mechanical Keyboard,Electronics,7,89.99,0,629.93,West,PayPal,VIP +ORD-00411,2024-03-07,AI Revolution,Books,9,26.99,0.1,242.91,Central,Cash on Delivery,Premium +ORD-00451,2024-03-07,Yoga Mat,Sports,7,25.49,0.15,178.43,South,Bank Transfer,Premium +ORD-00044,2024-03-08,Resistance Bands Set,Sports,9,19.99,0,179.91,East,Credit Card,Standard +ORD-00313,2024-03-08,Jump Rope,Sports,1,12.34,0.05,12.34,Central,Cash on Delivery,Premium +ORD-00929,2024-03-08,Webcam HD,Electronics,7,59.99,0,419.93,East,Bank Transfer,Standard +ORD-00977,2024-03-08,Coffee Maker,Home & Kitchen,2,71.99,0.2,143.98,North,Cash on Delivery,Premium +ORD-00419,2024-03-09,Data Science Handbook,Books,7,35.99,0.2,251.93,South,Credit Card,VIP +ORD-00450,2024-03-09,Monitor 27-inch,Electronics,2,359.99,0.1,719.98,West,Credit Card,VIP +ORD-00724,2024-03-09,Cotton T-Shirt,Clothing,7,19.99,0,139.93,East,Credit Card,Premium +ORD-00395,2024-03-10,USB-C Hub,Electronics,1,47.49,0.05,47.49,South,Cash on Delivery,VIP +ORD-00075,2024-03-11,AI Revolution,Books,9,29.99,0,269.91,South,PayPal,Premium +ORD-00285,2024-03-11,Jump Rope,Sports,1,11.04,0.15,11.04,Central,PayPal,VIP +ORD-00434,2024-03-11,Cotton T-Shirt,Clothing,1,16.99,0.15,16.99,North,PayPal,Premium +ORD-00556,2024-03-11,Laptop Pro 15,Electronics,1,1299.99,0,1299.99,South,Bank Transfer,Premium +ORD-00776,2024-03-11,Resistance Bands Set,Sports,4,16.99,0.15,67.96,Central,PayPal,Standard +ORD-00855,2024-03-11,Water Bottle,Home & Kitchen,3,14.99,0,44.97,West,Cash on Delivery,VIP +ORD-00931,2024-03-11,Yoga Mat,Sports,8,28.49,0.05,227.92,West,PayPal,Premium +ORD-00964,2024-03-11,Jump Rope,Sports,10,10.39,0.2,103.9,South,PayPal,Premium +ORD-00226,2024-03-12,USB-C Hub,Electronics,8,42.49,0.15,339.92,West,Cash on Delivery,VIP +ORD-00355,2024-03-12,Desk Organizer,Home & Kitchen,6,21.84,0.05,131.04,North,Bank Transfer,Standard +ORD-00365,2024-03-12,Resistance Bands Set,Sports,7,19.99,0,139.93,East,Bank Transfer,Premium +ORD-00573,2024-03-12,Laptop Pro 15,Electronics,3,1234.99,0.05,3704.97,West,Credit Card,Premium +ORD-00634,2024-03-12,Desk Organizer,Home & Kitchen,1,20.69,0.1,20.69,West,PayPal,Standard +ORD-00846,2024-03-12,Coffee Maker,Home & Kitchen,2,89.99,0,179.98,Central,Credit Card,Standard +ORD-00267,2024-03-13,USB-C Hub,Electronics,8,49.99,0,399.92,North,Credit Card,Premium +ORD-00611,2024-03-14,Desk Organizer,Home & Kitchen,5,19.54,0.15,97.7,East,Bank Transfer,Standard +ORD-00641,2024-03-14,Denim Jeans,Clothing,7,44.99,0.1,314.93,Central,PayPal,VIP +ORD-00129,2024-03-15,Air Purifier,Home & Kitchen,8,179.99,0.1,1439.92,West,PayPal,VIP +ORD-00166,2024-03-15,Wool Scarf,Clothing,1,24.99,0,24.99,South,Bank Transfer,Standard +ORD-00199,2024-03-15,Data Science Handbook,Books,4,44.99,0,179.96,East,PayPal,Standard +ORD-00656,2024-03-15,Coffee Maker,Home & Kitchen,2,71.99,0.2,143.98,West,Cash on Delivery,Standard +ORD-00777,2024-03-15,Data Science Handbook,Books,7,35.99,0.2,251.93,Central,PayPal,Premium +ORD-00796,2024-03-15,Resistance Bands Set,Sports,3,15.99,0.2,47.97,South,Credit Card,Premium +ORD-00802,2024-03-15,Cotton T-Shirt,Clothing,6,19.99,0,119.94,North,Bank Transfer,Premium +ORD-00476,2024-03-16,Running Shoes,Clothing,1,79.99,0,79.99,Central,PayPal,Standard +ORD-00635,2024-03-17,Monitor 27-inch,Electronics,5,399.99,0,1999.95,South,PayPal,Premium +ORD-00818,2024-03-17,Dumbbell Set,Sports,6,69.99,0,419.94,East,Credit Card,Premium +ORD-00926,2024-03-17,Desk Organizer,Home & Kitchen,4,20.69,0.1,82.76,Central,Credit Card,Premium +ORD-00942,2024-03-18,Python Programming,Books,3,37.99,0.05,113.97,South,Credit Card,Premium +ORD-00085,2024-03-19,Denim Jeans,Clothing,1,44.99,0.1,44.99,South,PayPal,Premium +ORD-00216,2024-03-19,Data Science Handbook,Books,10,42.74,0.05,427.4,South,PayPal,VIP +ORD-00722,2024-03-19,Laptop Pro 15,Electronics,6,1104.99,0.15,6629.94,Central,Credit Card,Standard +ORD-00431,2024-03-20,Python Programming,Books,9,39.99,0,359.91,Central,Bank Transfer,Premium +ORD-00453,2024-03-20,Denim Jeans,Clothing,10,49.99,0,499.9,North,PayPal,Premium +ORD-00673,2024-03-20,Denim Jeans,Clothing,10,49.99,0,499.9,North,Credit Card,Standard +ORD-00702,2024-03-20,AI Revolution,Books,7,29.99,0,209.93,East,Credit Card,Standard +ORD-00816,2024-03-20,Webcam HD,Electronics,3,56.99,0.05,170.97,West,Bank Transfer,Premium +ORD-00268,2024-03-22,Desk Organizer,Home & Kitchen,7,22.99,0,160.93,East,Bank Transfer,Standard +ORD-00384,2024-03-22,Wool Scarf,Clothing,3,22.49,0.1,67.47,East,Cash on Delivery,Premium +ORD-00470,2024-03-22,Dumbbell Set,Sports,10,69.99,0,699.9,North,PayPal,VIP +ORD-00222,2024-03-23,Yoga Mat,Sports,5,29.99,0,149.95,East,Cash on Delivery,VIP +ORD-00551,2024-03-23,Running Shoes,Clothing,7,79.99,0,559.93,North,Cash on Delivery,Standard +ORD-00637,2024-03-23,Water Bottle,Home & Kitchen,4,13.49,0.1,53.96,West,Credit Card,Standard +ORD-00638,2024-03-23,Wireless Mouse,Electronics,10,26.99,0.1,269.9,South,Credit Card,VIP +ORD-00692,2024-03-23,Jump Rope,Sports,2,12.99,0,25.98,East,PayPal,Premium +ORD-00354,2024-03-24,Winter Jacket,Clothing,2,129.99,0,259.98,Central,PayPal,VIP +ORD-00783,2024-03-24,Water Bottle,Home & Kitchen,8,14.99,0,119.92,South,PayPal,Standard +ORD-00961,2024-03-24,Water Bottle,Home & Kitchen,8,14.99,0,119.92,West,Cash on Delivery,Standard +ORD-00420,2024-03-25,Yoga Mat,Sports,10,25.49,0.15,254.9,West,Bank Transfer,VIP +ORD-00691,2024-03-26,Machine Learning Guide,Books,3,43.99,0.2,131.97,East,Cash on Delivery,VIP +ORD-00695,2024-03-26,Running Shoes,Clothing,2,79.99,0,159.98,West,Credit Card,Standard +ORD-00195,2024-03-28,Mechanical Keyboard,Electronics,1,89.99,0,89.99,Central,Cash on Delivery,VIP +ORD-00260,2024-03-28,Wool Scarf,Clothing,10,24.99,0,249.9,Central,Bank Transfer,VIP +ORD-00831,2024-03-28,Running Shoes,Clothing,10,79.99,0,799.9,North,Bank Transfer,VIP +ORD-00142,2024-03-30,Desk Organizer,Home & Kitchen,3,22.99,0,68.97,North,Cash on Delivery,VIP +ORD-00909,2024-03-30,Machine Learning Guide,Books,7,54.99,0,384.93,Central,Credit Card,VIP +ORD-00948,2024-03-30,Wool Scarf,Clothing,5,24.99,0,124.95,South,PayPal,Standard +ORD-00370,2024-03-31,Data Science Handbook,Books,8,44.99,0,359.92,West,Bank Transfer,Premium +ORD-00577,2024-03-31,Desk Organizer,Home & Kitchen,8,20.69,0.1,165.52,Central,PayPal,VIP +ORD-00054,2024-04-01,Denim Jeans,Clothing,7,49.99,0,349.93,East,Cash on Delivery,VIP +ORD-00103,2024-04-01,Running Shoes,Clothing,4,79.99,0,319.96,East,Credit Card,VIP +ORD-00792,2024-04-01,Smart Lamp,Home & Kitchen,8,34.99,0,279.92,West,PayPal,Premium +ORD-00803,2024-04-01,Laptop Pro 15,Electronics,2,1234.99,0.05,2469.98,South,Bank Transfer,VIP +ORD-00967,2024-04-01,Coffee Maker,Home & Kitchen,5,85.49,0.05,427.45,West,PayPal,Standard +ORD-00119,2024-04-02,Desk Organizer,Home & Kitchen,8,22.99,0,183.92,Central,Cash on Delivery,VIP +ORD-00639,2024-04-02,Cotton T-Shirt,Clothing,3,19.99,0,59.97,Central,Cash on Delivery,VIP +ORD-00714,2024-04-02,Coffee Maker,Home & Kitchen,9,85.49,0.05,769.41,South,Bank Transfer,Premium +ORD-00740,2024-04-02,Water Bottle,Home & Kitchen,2,14.99,0,29.98,Central,Cash on Delivery,VIP +ORD-00235,2024-04-03,Winter Jacket,Clothing,8,110.49,0.15,883.92,South,Credit Card,VIP +ORD-00694,2024-04-04,Machine Learning Guide,Books,7,54.99,0,384.93,North,PayPal,VIP +ORD-00728,2024-04-04,Webcam HD,Electronics,9,56.99,0.05,512.91,North,Bank Transfer,VIP +ORD-00882,2024-04-04,Resistance Bands Set,Sports,2,16.99,0.15,33.98,East,Bank Transfer,Premium +ORD-00983,2024-04-04,Resistance Bands Set,Sports,10,19.99,0,199.9,East,Credit Card,Standard +ORD-00394,2024-04-05,Winter Jacket,Clothing,5,129.99,0,649.95,East,Cash on Delivery,VIP +ORD-00857,2024-04-05,Water Bottle,Home & Kitchen,6,14.99,0,89.94,West,Credit Card,VIP +ORD-00543,2024-04-06,Running Shoes,Clothing,8,79.99,0,639.92,South,Cash on Delivery,Standard +ORD-00032,2024-04-07,Python Programming,Books,4,39.99,0,159.96,Central,Cash on Delivery,Standard +ORD-00490,2024-04-07,Laptop Pro 15,Electronics,6,1299.99,0,7799.94,West,Bank Transfer,VIP +ORD-00823,2024-04-07,Mechanical Keyboard,Electronics,9,89.99,0,809.91,East,Cash on Delivery,VIP +ORD-00501,2024-04-08,Air Purifier,Home & Kitchen,3,199.99,0,599.97,South,Credit Card,Premium +ORD-00738,2024-04-08,Wool Scarf,Clothing,7,24.99,0,174.93,Central,PayPal,VIP +ORD-00339,2024-04-10,Jump Rope,Sports,5,12.99,0,64.95,East,PayPal,Premium +ORD-00088,2024-04-11,Jump Rope,Sports,7,11.04,0.15,77.28,North,PayPal,Standard +ORD-00515,2024-04-11,Coffee Maker,Home & Kitchen,6,76.49,0.15,458.94,East,Bank Transfer,Premium +ORD-00834,2024-04-11,AI Revolution,Books,6,23.99,0.2,143.94,Central,Credit Card,Premium +ORD-00220,2024-04-12,Jump Rope,Sports,3,10.39,0.2,31.17,Central,PayPal,Premium +ORD-00444,2024-04-12,Jump Rope,Sports,1,12.99,0,12.99,East,Bank Transfer,VIP +ORD-00514,2024-04-12,Yoga Mat,Sports,3,29.99,0,89.97,North,PayPal,Standard +ORD-00311,2024-04-13,Laptop Pro 15,Electronics,9,1169.99,0.1,10529.91,Central,PayPal,Standard +ORD-00413,2024-04-13,Water Bottle,Home & Kitchen,1,14.24,0.05,14.24,South,Bank Transfer,Standard +ORD-00822,2024-04-13,USB-C Hub,Electronics,1,39.99,0.2,39.99,North,Cash on Delivery,VIP +ORD-00652,2024-04-14,AI Revolution,Books,7,23.99,0.2,167.93,West,Cash on Delivery,Standard +ORD-00936,2024-04-14,Yoga Mat,Sports,9,25.49,0.15,229.41,South,Bank Transfer,Standard +ORD-00312,2024-04-15,Machine Learning Guide,Books,2,46.74,0.15,93.48,Central,Cash on Delivery,Standard +ORD-00403,2024-04-15,Resistance Bands Set,Sports,4,15.99,0.2,63.96,North,Bank Transfer,VIP +ORD-00716,2024-04-16,Coffee Maker,Home & Kitchen,9,76.49,0.15,688.41,West,Cash on Delivery,VIP +ORD-00537,2024-04-17,Laptop Pro 15,Electronics,8,1299.99,0,10399.92,South,PayPal,Premium +ORD-00914,2024-04-17,Laptop Pro 15,Electronics,6,1299.99,0,7799.94,West,Bank Transfer,Premium +ORD-00082,2024-04-18,USB-C Hub,Electronics,7,49.99,0,349.93,West,Bank Transfer,Premium +ORD-00095,2024-04-18,Smart Lamp,Home & Kitchen,3,33.24,0.05,99.72,East,Bank Transfer,Premium +ORD-00410,2024-04-18,Air Purifier,Home & Kitchen,7,159.99,0.2,1119.93,West,Bank Transfer,Premium +ORD-00227,2024-04-20,Laptop Pro 15,Electronics,1,1299.99,0,1299.99,South,Cash on Delivery,Standard +ORD-00640,2024-04-20,Yoga Mat,Sports,5,29.99,0,149.95,West,Cash on Delivery,Premium +ORD-00400,2024-04-21,Webcam HD,Electronics,8,53.99,0.1,431.92,West,Credit Card,VIP +ORD-00581,2024-04-21,Dumbbell Set,Sports,9,59.49,0.15,535.41,South,PayPal,VIP +ORD-00671,2024-04-21,Winter Jacket,Clothing,6,129.99,0,779.94,Central,Bank Transfer,Standard +ORD-00397,2024-04-22,Cotton T-Shirt,Clothing,10,19.99,0,199.9,East,Credit Card,Premium +ORD-00879,2024-04-22,Yoga Mat,Sports,8,29.99,0,239.92,East,Bank Transfer,Standard +ORD-00338,2024-04-23,Coffee Maker,Home & Kitchen,10,71.99,0.2,719.9,South,Credit Card,Premium +ORD-00378,2024-04-23,Laptop Pro 15,Electronics,2,1299.99,0,2599.98,Central,PayPal,Premium +ORD-00614,2024-04-23,Resistance Bands Set,Sports,3,19.99,0,59.97,Central,Bank Transfer,Standard +ORD-00620,2024-04-23,USB-C Hub,Electronics,4,39.99,0.2,159.96,West,Bank Transfer,Premium +ORD-00745,2024-04-23,Running Shoes,Clothing,1,79.99,0,79.99,South,Bank Transfer,Standard +ORD-00784,2024-04-23,Coffee Maker,Home & Kitchen,5,85.49,0.05,427.45,South,PayPal,Standard +ORD-00976,2024-04-23,Wireless Mouse,Electronics,3,26.99,0.1,80.97,South,Credit Card,Premium +ORD-00001,2024-04-24,Laptop Pro 15,Electronics,5,1299.99,0,6499.95,South,Credit Card,VIP +ORD-00319,2024-04-24,Jump Rope,Sports,9,12.99,0,116.91,North,Credit Card,Premium +ORD-00836,2024-04-24,Machine Learning Guide,Books,8,54.99,0,439.92,South,Bank Transfer,Premium +ORD-00127,2024-04-25,Monitor 27-inch,Electronics,7,399.99,0,2799.93,North,Cash on Delivery,Standard +ORD-00258,2024-04-25,Denim Jeans,Clothing,6,42.49,0.15,254.94,East,Bank Transfer,Standard +ORD-00519,2024-04-25,Resistance Bands Set,Sports,4,16.99,0.15,67.96,South,Bank Transfer,VIP +ORD-00008,2024-04-26,Cotton T-Shirt,Clothing,1,16.99,0.15,16.99,East,Credit Card,Standard +ORD-00647,2024-04-27,Webcam HD,Electronics,9,56.99,0.05,512.91,West,Cash on Delivery,Premium +ORD-00143,2024-04-28,Winter Jacket,Clothing,10,129.99,0,1299.9,West,Bank Transfer,Premium +ORD-00781,2024-04-28,Resistance Bands Set,Sports,10,19.99,0,199.9,North,Bank Transfer,Standard +ORD-00474,2024-04-29,Coffee Maker,Home & Kitchen,2,89.99,0,179.98,Central,PayPal,Standard +ORD-00747,2024-04-29,AI Revolution,Books,10,29.99,0,299.9,East,Bank Transfer,Standard +ORD-00067,2024-04-30,Dumbbell Set,Sports,2,55.99,0.2,111.98,East,PayPal,Standard +ORD-00224,2024-04-30,Mechanical Keyboard,Electronics,9,89.99,0,809.91,West,Credit Card,Premium +ORD-00683,2024-04-30,Cotton T-Shirt,Clothing,5,15.99,0.2,79.95,Central,Cash on Delivery,Premium +ORD-00111,2024-05-02,Running Shoes,Clothing,2,79.99,0,159.98,South,Credit Card,Standard +ORD-00126,2024-05-02,Webcam HD,Electronics,10,47.99,0.2,479.9,South,Bank Transfer,VIP +ORD-00425,2024-05-02,Smart Lamp,Home & Kitchen,1,27.99,0.2,27.99,West,Cash on Delivery,Standard +ORD-00097,2024-05-03,Water Bottle,Home & Kitchen,9,11.99,0.2,107.91,West,Cash on Delivery,Premium +ORD-00270,2024-05-03,Mechanical Keyboard,Electronics,9,85.49,0.05,769.41,South,Bank Transfer,Standard +ORD-00329,2024-05-03,Air Purifier,Home & Kitchen,9,189.99,0.05,1709.91,South,Bank Transfer,VIP +ORD-00347,2024-05-03,Laptop Pro 15,Electronics,9,1299.99,0,11699.91,South,Bank Transfer,Premium +ORD-00526,2024-05-03,Data Science Handbook,Books,3,40.49,0.1,121.47,North,Cash on Delivery,VIP +ORD-00672,2024-05-03,Winter Jacket,Clothing,4,129.99,0,519.96,East,PayPal,Standard +ORD-00900,2024-05-04,Mechanical Keyboard,Electronics,10,89.99,0,899.9,South,Credit Card,Standard +ORD-00093,2024-05-05,Dumbbell Set,Sports,6,55.99,0.2,335.94,North,Bank Transfer,Premium +ORD-00263,2024-05-05,Dumbbell Set,Sports,5,66.49,0.05,332.45,West,Bank Transfer,Premium +ORD-00014,2024-05-06,Air Purifier,Home & Kitchen,5,199.99,0,999.95,Central,Bank Transfer,VIP +ORD-00367,2024-05-06,Coffee Maker,Home & Kitchen,6,89.99,0,539.94,West,PayPal,Premium +ORD-00033,2024-05-07,Air Purifier,Home & Kitchen,5,189.99,0.05,949.95,North,Cash on Delivery,VIP +ORD-00071,2024-05-07,Jump Rope,Sports,1,11.69,0.1,11.69,North,Cash on Delivery,Standard +ORD-00563,2024-05-07,Python Programming,Books,7,33.99,0.15,237.93,West,Bank Transfer,VIP +ORD-00579,2024-05-07,Water Bottle,Home & Kitchen,10,12.74,0.15,127.4,East,Cash on Delivery,Premium +ORD-00237,2024-05-09,Machine Learning Guide,Books,9,52.24,0.05,470.16,North,PayPal,Premium +ORD-00374,2024-05-09,Air Purifier,Home & Kitchen,5,199.99,0,999.95,South,Cash on Delivery,Premium +ORD-00521,2024-05-09,Wool Scarf,Clothing,1,24.99,0,24.99,Central,Bank Transfer,VIP +ORD-00547,2024-05-09,Water Bottle,Home & Kitchen,2,13.49,0.1,26.98,West,PayPal,Standard +ORD-00798,2024-05-09,Cotton T-Shirt,Clothing,10,15.99,0.2,159.9,North,Cash on Delivery,Premium +ORD-00191,2024-05-10,Data Science Handbook,Books,3,35.99,0.2,107.97,East,Bank Transfer,Standard +ORD-00545,2024-05-10,Python Programming,Books,7,33.99,0.15,237.93,Central,Credit Card,VIP +ORD-00648,2024-05-10,Yoga Mat,Sports,7,29.99,0,209.93,West,PayPal,Standard +ORD-00600,2024-05-11,Machine Learning Guide,Books,2,52.24,0.05,104.48,Central,Bank Transfer,Standard +ORD-00867,2024-05-11,Dumbbell Set,Sports,5,55.99,0.2,279.95,North,Credit Card,VIP +ORD-00073,2024-05-12,Air Purifier,Home & Kitchen,8,189.99,0.05,1519.92,South,Bank Transfer,VIP +ORD-00336,2024-05-13,Desk Organizer,Home & Kitchen,6,22.99,0,137.94,East,Cash on Delivery,Premium +ORD-00532,2024-05-13,Machine Learning Guide,Books,6,54.99,0,329.94,Central,Bank Transfer,Premium +ORD-00651,2024-05-13,Winter Jacket,Clothing,10,129.99,0,1299.9,Central,Cash on Delivery,VIP +ORD-00946,2024-05-13,Data Science Handbook,Books,5,44.99,0,224.95,South,Cash on Delivery,Premium +ORD-00401,2024-05-14,Running Shoes,Clothing,1,75.99,0.05,75.99,East,Credit Card,Premium +ORD-00496,2024-05-14,USB-C Hub,Electronics,8,47.49,0.05,379.92,South,Bank Transfer,VIP +ORD-00680,2024-05-14,Dumbbell Set,Sports,3,62.99,0.1,188.97,South,Bank Transfer,Premium +ORD-00706,2024-05-14,Winter Jacket,Clothing,7,129.99,0,909.93,North,Cash on Delivery,VIP +ORD-00727,2024-05-14,Resistance Bands Set,Sports,8,19.99,0,159.92,Central,Bank Transfer,Premium +ORD-00006,2024-05-15,USB-C Hub,Electronics,6,44.99,0.1,269.94,North,Cash on Delivery,VIP +ORD-00046,2024-05-15,Dumbbell Set,Sports,2,69.99,0,139.98,North,Credit Card,VIP +ORD-00272,2024-05-15,Laptop Pro 15,Electronics,7,1299.99,0,9099.93,East,Cash on Delivery,Premium +ORD-00304,2024-05-16,Air Purifier,Home & Kitchen,8,159.99,0.2,1279.92,East,Bank Transfer,VIP +ORD-00758,2024-05-16,Resistance Bands Set,Sports,4,19.99,0,79.96,West,Bank Transfer,VIP +ORD-00332,2024-05-17,Monitor 27-inch,Electronics,4,399.99,0,1599.96,West,Credit Card,Standard +ORD-00399,2024-05-17,Yoga Mat,Sports,10,29.99,0,299.9,Central,Bank Transfer,VIP +ORD-00011,2024-05-18,Denim Jeans,Clothing,8,47.49,0.05,379.92,Central,PayPal,VIP +ORD-00361,2024-05-18,Machine Learning Guide,Books,6,43.99,0.2,263.94,North,PayPal,Standard +ORD-00152,2024-05-19,Dumbbell Set,Sports,9,62.99,0.1,566.91,South,Bank Transfer,Premium +ORD-00558,2024-05-19,Mechanical Keyboard,Electronics,7,85.49,0.05,598.43,West,Cash on Delivery,Premium +ORD-00289,2024-05-20,Smart Lamp,Home & Kitchen,9,27.99,0.2,251.91,East,Credit Card,VIP +ORD-00675,2024-05-20,Dumbbell Set,Sports,8,62.99,0.1,503.92,West,PayPal,VIP +ORD-00806,2024-05-20,Air Purifier,Home & Kitchen,2,169.99,0.15,339.98,East,Cash on Delivery,VIP +ORD-00829,2024-05-20,USB-C Hub,Electronics,3,49.99,0,149.97,North,Credit Card,VIP +ORD-00405,2024-05-21,Wireless Mouse,Electronics,7,29.99,0,209.93,South,PayPal,Standard +ORD-00525,2024-05-21,Mechanical Keyboard,Electronics,2,89.99,0,179.98,East,Bank Transfer,Premium +ORD-00719,2024-05-21,Smart Lamp,Home & Kitchen,1,34.99,0,34.99,Central,Credit Card,Premium +ORD-00247,2024-05-22,Machine Learning Guide,Books,6,46.74,0.15,280.44,South,Credit Card,Standard +ORD-00773,2024-05-23,Machine Learning Guide,Books,1,49.49,0.1,49.49,South,PayPal,Premium +ORD-00860,2024-05-23,Wireless Mouse,Electronics,9,23.99,0.2,215.91,West,Credit Card,VIP +ORD-00443,2024-05-24,Mechanical Keyboard,Electronics,2,85.49,0.05,170.98,East,Bank Transfer,Standard +ORD-00730,2024-05-24,Desk Organizer,Home & Kitchen,3,20.69,0.1,62.07,East,Cash on Delivery,VIP +ORD-00189,2024-05-25,USB-C Hub,Electronics,7,49.99,0,349.93,Central,Cash on Delivery,VIP +ORD-00947,2024-05-25,Python Programming,Books,3,39.99,0,119.97,West,PayPal,VIP +ORD-00204,2024-05-26,Laptop Pro 15,Electronics,6,1039.99,0.2,6239.94,North,Credit Card,VIP +ORD-00346,2024-05-26,Monitor 27-inch,Electronics,5,359.99,0.1,1799.95,North,Cash on Delivery,Standard +ORD-00789,2024-05-26,Machine Learning Guide,Books,7,54.99,0,384.93,West,PayPal,Premium +ORD-00125,2024-05-27,Running Shoes,Clothing,9,79.99,0,719.91,West,Cash on Delivery,Standard +ORD-00288,2024-05-27,Dumbbell Set,Sports,4,69.99,0,279.96,South,Cash on Delivery,Premium +ORD-00874,2024-05-27,Wool Scarf,Clothing,7,24.99,0,174.93,West,Cash on Delivery,VIP +ORD-00941,2024-05-27,Laptop Pro 15,Electronics,7,1299.99,0,9099.93,North,Credit Card,Standard +ORD-00298,2024-05-28,Mechanical Keyboard,Electronics,8,89.99,0,719.92,North,Credit Card,Premium +ORD-00754,2024-05-28,Yoga Mat,Sports,4,28.49,0.05,113.96,West,PayPal,Standard +ORD-00893,2024-05-28,Python Programming,Books,8,31.99,0.2,255.92,South,PayPal,VIP +ORD-00953,2024-05-28,Dumbbell Set,Sports,5,66.49,0.05,332.45,Central,PayPal,VIP +ORD-00373,2024-05-29,Air Purifier,Home & Kitchen,8,159.99,0.2,1279.92,North,Cash on Delivery,VIP +ORD-00404,2024-05-29,Data Science Handbook,Books,6,38.24,0.15,229.44,East,Bank Transfer,VIP +ORD-00768,2024-05-29,AI Revolution,Books,2,29.99,0,59.98,West,PayPal,Premium +ORD-00007,2024-05-30,Mechanical Keyboard,Electronics,2,80.99,0.1,161.98,Central,Bank Transfer,VIP +ORD-00037,2024-05-31,Jump Rope,Sports,3,12.99,0,38.97,South,Credit Card,VIP +ORD-00140,2024-06-02,Mechanical Keyboard,Electronics,3,76.49,0.15,229.47,North,Credit Card,Premium +ORD-00326,2024-06-02,Wool Scarf,Clothing,4,23.74,0.05,94.96,East,Credit Card,VIP +ORD-00499,2024-06-02,Desk Organizer,Home & Kitchen,4,21.84,0.05,87.36,North,Cash on Delivery,VIP +ORD-00157,2024-06-03,Cotton T-Shirt,Clothing,8,16.99,0.15,135.92,West,Credit Card,Standard +ORD-00821,2024-06-03,Air Purifier,Home & Kitchen,6,189.99,0.05,1139.94,South,Bank Transfer,Standard +ORD-00965,2024-06-03,Machine Learning Guide,Books,1,43.99,0.2,43.99,Central,Bank Transfer,Premium +ORD-00982,2024-06-03,USB-C Hub,Electronics,1,49.99,0,49.99,North,Credit Card,Standard +ORD-00779,2024-06-04,USB-C Hub,Electronics,7,47.49,0.05,332.43,West,Bank Transfer,Standard +ORD-00864,2024-06-04,Webcam HD,Electronics,10,50.99,0.15,509.9,Central,Bank Transfer,Standard +ORD-00962,2024-06-04,Yoga Mat,Sports,3,29.99,0,89.97,West,Bank Transfer,Standard +ORD-00930,2024-06-05,Laptop Pro 15,Electronics,8,1104.99,0.15,8839.92,South,Cash on Delivery,VIP +ORD-00022,2024-06-06,Laptop Pro 15,Electronics,6,1039.99,0.2,6239.94,South,Credit Card,Standard +ORD-00115,2024-06-06,Cotton T-Shirt,Clothing,1,19.99,0,19.99,Central,Bank Transfer,Premium +ORD-00197,2024-06-07,Desk Organizer,Home & Kitchen,2,21.84,0.05,43.68,East,PayPal,Premium +ORD-00254,2024-06-07,Wool Scarf,Clothing,2,24.99,0,49.98,West,Cash on Delivery,VIP +ORD-00775,2024-06-08,Wool Scarf,Clothing,3,23.74,0.05,71.22,Central,Bank Transfer,Premium +ORD-00062,2024-06-09,Coffee Maker,Home & Kitchen,6,80.99,0.1,485.94,North,Bank Transfer,VIP +ORD-00265,2024-06-09,Running Shoes,Clothing,10,79.99,0,799.9,East,Bank Transfer,Premium +ORD-00286,2024-06-09,Machine Learning Guide,Books,9,52.24,0.05,470.16,South,Cash on Delivery,VIP +ORD-00562,2024-06-09,Laptop Pro 15,Electronics,5,1104.99,0.15,5524.95,North,PayPal,VIP +ORD-00013,2024-06-10,Wireless Mouse,Electronics,10,25.49,0.15,254.9,South,Cash on Delivery,Premium +ORD-00353,2024-06-10,Yoga Mat,Sports,3,23.99,0.2,71.97,North,PayPal,VIP +ORD-00650,2024-06-10,Mechanical Keyboard,Electronics,4,76.49,0.15,305.96,West,Bank Transfer,Standard +ORD-00685,2024-06-10,Wool Scarf,Clothing,9,21.24,0.15,191.16,West,Bank Transfer,Premium +ORD-00973,2024-06-10,Wool Scarf,Clothing,6,19.99,0.2,119.94,East,Bank Transfer,VIP +ORD-00072,2024-06-11,Desk Organizer,Home & Kitchen,9,20.69,0.1,186.21,West,Cash on Delivery,VIP +ORD-00472,2024-06-11,Denim Jeans,Clothing,4,44.99,0.1,179.96,East,Credit Card,VIP +ORD-00808,2024-06-11,Dumbbell Set,Sports,7,62.99,0.1,440.93,East,Bank Transfer,Standard +ORD-00278,2024-06-12,Resistance Bands Set,Sports,6,17.99,0.1,107.94,South,Cash on Delivery,VIP +ORD-00337,2024-06-12,Webcam HD,Electronics,8,47.99,0.2,383.92,South,Bank Transfer,Premium +ORD-00441,2024-06-12,Data Science Handbook,Books,7,38.24,0.15,267.68,East,Credit Card,VIP +ORD-00487,2024-06-12,Laptop Pro 15,Electronics,5,1299.99,0,6499.95,East,Credit Card,Standard +ORD-00952,2024-06-12,Water Bottle,Home & Kitchen,3,14.24,0.05,42.72,South,Credit Card,VIP +ORD-00970,2024-06-12,Wool Scarf,Clothing,1,24.99,0,24.99,West,PayPal,Premium +ORD-00468,2024-06-13,Smart Lamp,Home & Kitchen,10,31.49,0.1,314.9,West,Bank Transfer,Standard +ORD-00117,2024-06-14,Monitor 27-inch,Electronics,1,379.99,0.05,379.99,Central,Bank Transfer,Standard +ORD-00303,2024-06-14,Wireless Mouse,Electronics,3,28.49,0.05,85.47,South,Credit Card,Premium +ORD-00186,2024-06-15,Running Shoes,Clothing,5,67.99,0.15,339.95,Central,Credit Card,Standard +ORD-00837,2024-06-15,Air Purifier,Home & Kitchen,6,159.99,0.2,959.94,West,Cash on Delivery,Standard +ORD-00241,2024-06-16,AI Revolution,Books,2,29.99,0,59.98,East,Bank Transfer,Premium +ORD-00302,2024-06-16,Jump Rope,Sports,7,11.69,0.1,81.83,North,Cash on Delivery,VIP +ORD-00456,2024-06-16,Python Programming,Books,4,31.99,0.2,127.96,South,Credit Card,VIP +ORD-00057,2024-06-17,Cotton T-Shirt,Clothing,4,18.99,0.05,75.96,East,Credit Card,Premium +ORD-00889,2024-06-17,Smart Lamp,Home & Kitchen,3,29.74,0.15,89.22,West,Credit Card,VIP +ORD-00294,2024-06-18,Cotton T-Shirt,Clothing,2,18.99,0.05,37.98,West,Credit Card,VIP +ORD-00593,2024-06-18,Wool Scarf,Clothing,5,24.99,0,124.95,West,PayPal,Standard +ORD-00597,2024-06-18,Data Science Handbook,Books,4,38.24,0.15,152.96,East,Bank Transfer,Premium +ORD-00636,2024-06-18,Yoga Mat,Sports,4,28.49,0.05,113.96,North,Cash on Delivery,VIP +ORD-00815,2024-06-18,Yoga Mat,Sports,3,29.99,0,89.97,East,Cash on Delivery,Premium +ORD-00164,2024-06-19,Yoga Mat,Sports,1,23.99,0.2,23.99,South,PayPal,VIP +ORD-00536,2024-06-19,Data Science Handbook,Books,2,42.74,0.05,85.48,South,Bank Transfer,VIP +ORD-00687,2024-06-20,Machine Learning Guide,Books,2,52.24,0.05,104.48,East,Bank Transfer,Premium +ORD-00904,2024-06-20,Cotton T-Shirt,Clothing,10,19.99,0,199.9,North,Credit Card,Premium +ORD-00921,2024-06-20,Running Shoes,Clothing,5,79.99,0,399.95,South,Credit Card,VIP +ORD-00005,2024-06-21,Data Science Handbook,Books,4,35.99,0.2,143.96,North,Credit Card,Premium +ORD-00301,2024-06-21,Smart Lamp,Home & Kitchen,3,34.99,0,104.97,East,PayPal,Standard +ORD-00877,2024-06-21,Wool Scarf,Clothing,4,22.49,0.1,89.96,South,Bank Transfer,VIP +ORD-00209,2024-06-22,Machine Learning Guide,Books,7,43.99,0.2,307.93,Central,Credit Card,VIP +ORD-00667,2024-06-22,Jump Rope,Sports,4,10.39,0.2,41.56,West,Cash on Delivery,VIP +ORD-00995,2024-06-22,Cotton T-Shirt,Clothing,6,19.99,0,119.94,South,Cash on Delivery,Standard +ORD-00090,2024-06-23,Python Programming,Books,9,33.99,0.15,305.91,North,Cash on Delivery,Premium +ORD-00214,2024-06-23,AI Revolution,Books,3,29.99,0,89.97,West,PayPal,Standard +ORD-00383,2024-06-24,Python Programming,Books,2,39.99,0,79.98,North,PayPal,VIP +ORD-00535,2024-06-24,Wireless Mouse,Electronics,3,29.99,0,89.97,West,Cash on Delivery,Standard +ORD-00712,2024-06-24,Cotton T-Shirt,Clothing,8,19.99,0,159.92,West,Bank Transfer,Premium +ORD-00290,2024-06-25,Jump Rope,Sports,7,10.39,0.2,72.73,South,Bank Transfer,Standard +ORD-00613,2024-06-25,Yoga Mat,Sports,5,29.99,0,149.95,South,Cash on Delivery,VIP +ORD-00871,2024-06-25,Air Purifier,Home & Kitchen,4,179.99,0.1,719.96,North,Bank Transfer,Standard +ORD-00482,2024-06-26,Running Shoes,Clothing,2,79.99,0,159.98,East,PayPal,VIP +ORD-00943,2024-06-26,Desk Organizer,Home & Kitchen,3,21.84,0.05,65.52,West,Cash on Delivery,VIP +ORD-00522,2024-06-27,Machine Learning Guide,Books,7,43.99,0.2,307.93,West,Bank Transfer,Standard +ORD-00830,2024-06-28,Yoga Mat,Sports,4,26.99,0.1,107.96,Central,Bank Transfer,Premium +ORD-00341,2024-06-29,Water Bottle,Home & Kitchen,3,12.74,0.15,38.22,South,PayPal,VIP +ORD-00863,2024-06-29,Desk Organizer,Home & Kitchen,6,21.84,0.05,131.04,East,Bank Transfer,Standard +ORD-00974,2024-06-29,Python Programming,Books,9,35.99,0.1,323.91,South,Credit Card,Standard +ORD-00984,2024-06-29,Running Shoes,Clothing,10,79.99,0,799.9,South,PayPal,VIP +ORD-00463,2024-06-30,Mechanical Keyboard,Electronics,7,89.99,0,629.93,South,PayPal,Premium +ORD-00529,2024-07-02,Wool Scarf,Clothing,8,21.24,0.15,169.92,Central,Bank Transfer,Standard +ORD-00305,2024-07-03,Yoga Mat,Sports,5,25.49,0.15,127.45,South,Credit Card,VIP +ORD-00360,2024-07-03,Air Purifier,Home & Kitchen,7,199.99,0,1399.93,North,Credit Card,Standard +ORD-00630,2024-07-03,Webcam HD,Electronics,4,53.99,0.1,215.96,West,Cash on Delivery,Premium +ORD-00015,2024-07-04,Jump Rope,Sports,10,12.34,0.05,123.4,South,PayPal,VIP +ORD-00794,2024-07-04,Webcam HD,Electronics,8,50.99,0.15,407.92,North,Cash on Delivery,VIP +ORD-00570,2024-07-05,Resistance Bands Set,Sports,8,19.99,0,159.92,North,PayPal,Premium +ORD-00619,2024-07-05,Data Science Handbook,Books,10,40.49,0.1,404.9,East,Cash on Delivery,Standard +ORD-00934,2024-07-05,Python Programming,Books,9,33.99,0.15,305.91,North,Credit Card,Premium +ORD-00427,2024-07-06,Winter Jacket,Clothing,10,123.49,0.05,1234.9,Central,PayPal,Premium +ORD-00935,2024-07-06,Machine Learning Guide,Books,10,54.99,0,549.9,East,PayPal,VIP +ORD-00859,2024-07-07,Monitor 27-inch,Electronics,8,339.99,0.15,2719.92,North,Cash on Delivery,VIP +ORD-00940,2024-07-07,Dumbbell Set,Sports,8,62.99,0.1,503.92,North,PayPal,Premium +ORD-00051,2024-07-08,Wool Scarf,Clothing,1,19.99,0.2,19.99,Central,PayPal,Premium +ORD-00905,2024-07-08,Winter Jacket,Clothing,3,110.49,0.15,331.47,Central,PayPal,Premium +ORD-00221,2024-07-09,Webcam HD,Electronics,8,47.99,0.2,383.92,Central,Credit Card,VIP +ORD-00959,2024-07-09,Winter Jacket,Clothing,6,123.49,0.05,740.94,North,Bank Transfer,Premium +ORD-00920,2024-07-10,Jump Rope,Sports,3,12.34,0.05,37.02,South,Cash on Delivery,VIP +ORD-00185,2024-07-11,Laptop Pro 15,Electronics,9,1299.99,0,11699.91,West,Cash on Delivery,Premium +ORD-00371,2024-07-11,Desk Organizer,Home & Kitchen,4,22.99,0,91.96,East,Cash on Delivery,Premium +ORD-00743,2024-07-11,Wool Scarf,Clothing,9,24.99,0,224.91,Central,Bank Transfer,Premium +ORD-00632,2024-07-12,Data Science Handbook,Books,7,38.24,0.15,267.68,East,Credit Card,VIP +ORD-00320,2024-07-13,Desk Organizer,Home & Kitchen,7,20.69,0.1,144.83,South,PayPal,Premium +ORD-00786,2024-07-13,Water Bottle,Home & Kitchen,1,13.49,0.1,13.49,East,Cash on Delivery,VIP +ORD-00795,2024-07-13,Resistance Bands Set,Sports,7,17.99,0.1,125.93,North,PayPal,Premium +ORD-00017,2024-07-14,Desk Organizer,Home & Kitchen,2,21.84,0.05,43.68,Central,Cash on Delivery,VIP +ORD-00317,2024-07-14,Denim Jeans,Clothing,7,49.99,0,349.93,West,Cash on Delivery,Premium +ORD-00495,2024-07-14,Jump Rope,Sports,3,12.99,0,38.97,East,PayPal,Premium +ORD-00774,2024-07-14,Air Purifier,Home & Kitchen,2,169.99,0.15,339.98,Central,PayPal,Premium +ORD-00133,2024-07-15,Data Science Handbook,Books,4,40.49,0.1,161.96,South,Cash on Delivery,Standard +ORD-00245,2024-07-15,Wireless Mouse,Electronics,6,29.99,0,179.94,Central,Credit Card,VIP +ORD-00422,2024-07-15,Wireless Mouse,Electronics,2,26.99,0.1,53.98,East,Cash on Delivery,Premium +ORD-00541,2024-07-15,AI Revolution,Books,3,29.99,0,89.97,Central,Bank Transfer,Standard +ORD-00643,2024-07-16,Yoga Mat,Sports,4,28.49,0.05,113.96,South,Credit Card,Standard +ORD-00389,2024-07-17,Desk Organizer,Home & Kitchen,6,20.69,0.1,124.14,Central,Cash on Delivery,VIP +ORD-00392,2024-07-17,Running Shoes,Clothing,3,71.99,0.1,215.97,West,PayPal,VIP +ORD-00498,2024-07-17,Wool Scarf,Clothing,1,19.99,0.2,19.99,Central,Cash on Delivery,VIP +ORD-00788,2024-07-17,Jump Rope,Sports,1,11.69,0.1,11.69,Central,Credit Card,Standard +ORD-00884,2024-07-17,USB-C Hub,Electronics,1,47.49,0.05,47.49,East,Credit Card,Premium +ORD-00078,2024-07-18,USB-C Hub,Electronics,5,39.99,0.2,199.95,West,PayPal,Premium +ORD-00607,2024-07-18,Water Bottle,Home & Kitchen,6,14.99,0,89.94,North,Credit Card,VIP +ORD-00697,2024-07-18,Data Science Handbook,Books,2,38.24,0.15,76.48,East,PayPal,Standard +ORD-00847,2024-07-18,Winter Jacket,Clothing,9,129.99,0,1169.91,Central,Cash on Delivery,Standard +ORD-00833,2024-07-19,Coffee Maker,Home & Kitchen,2,85.49,0.05,170.98,South,Cash on Delivery,VIP +ORD-00275,2024-07-20,Yoga Mat,Sports,3,29.99,0,89.97,East,Bank Transfer,VIP +ORD-00574,2024-07-20,Resistance Bands Set,Sports,4,19.99,0,79.96,South,Cash on Delivery,Standard +ORD-00771,2024-07-20,Dumbbell Set,Sports,6,66.49,0.05,398.94,South,Credit Card,Premium +ORD-00042,2024-07-21,Running Shoes,Clothing,4,79.99,0,319.96,South,Bank Transfer,Premium +ORD-00154,2024-07-21,Desk Organizer,Home & Kitchen,6,22.99,0,137.94,North,Bank Transfer,VIP +ORD-00368,2024-07-21,Wireless Mouse,Electronics,5,29.99,0,149.95,East,Cash on Delivery,Premium +ORD-00994,2024-07-21,Yoga Mat,Sports,2,29.99,0,59.98,West,Credit Card,VIP +ORD-00137,2024-07-22,AI Revolution,Books,10,25.49,0.15,254.9,North,Bank Transfer,VIP +ORD-00503,2024-07-22,Laptop Pro 15,Electronics,7,1169.99,0.1,8189.93,Central,Bank Transfer,Premium +ORD-00670,2024-07-23,Data Science Handbook,Books,5,44.99,0,224.95,East,Credit Card,VIP +ORD-00035,2024-07-24,Water Bottle,Home & Kitchen,8,14.99,0,119.92,North,PayPal,Premium +ORD-00171,2024-07-24,Laptop Pro 15,Electronics,8,1039.99,0.2,8319.92,West,Credit Card,Premium +ORD-00840,2024-07-24,USB-C Hub,Electronics,9,47.49,0.05,427.41,Central,Cash on Delivery,Standard +ORD-00955,2024-07-24,Yoga Mat,Sports,5,29.99,0,149.95,Central,PayPal,Premium +ORD-00116,2024-07-25,Mechanical Keyboard,Electronics,5,76.49,0.15,382.45,East,Cash on Delivery,Premium +ORD-00098,2024-07-26,Laptop Pro 15,Electronics,5,1299.99,0,6499.95,South,Bank Transfer,VIP +ORD-00306,2024-07-26,Mechanical Keyboard,Electronics,5,89.99,0,449.95,Central,Bank Transfer,VIP +ORD-00348,2024-07-26,Machine Learning Guide,Books,2,54.99,0,109.98,West,PayPal,VIP +ORD-00624,2024-07-26,Yoga Mat,Sports,10,26.99,0.1,269.9,Central,PayPal,Standard +ORD-00633,2024-07-26,Machine Learning Guide,Books,4,49.49,0.1,197.96,East,Bank Transfer,Premium +ORD-00839,2024-07-26,Data Science Handbook,Books,9,44.99,0,404.91,North,Bank Transfer,VIP +ORD-00076,2024-07-27,Denim Jeans,Clothing,2,47.49,0.05,94.98,East,Cash on Delivery,Premium +ORD-00295,2024-07-27,Cotton T-Shirt,Clothing,9,19.99,0,179.91,West,Cash on Delivery,VIP +ORD-00606,2024-07-27,Monitor 27-inch,Electronics,4,399.99,0,1599.96,West,Cash on Delivery,VIP +ORD-00120,2024-07-28,Coffee Maker,Home & Kitchen,8,89.99,0,719.92,East,Bank Transfer,VIP +ORD-00446,2024-07-28,Resistance Bands Set,Sports,3,19.99,0,59.97,North,PayPal,VIP +ORD-00963,2024-07-28,Jump Rope,Sports,2,12.99,0,25.98,East,Cash on Delivery,Standard +ORD-00217,2024-07-29,Resistance Bands Set,Sports,8,19.99,0,159.92,East,PayPal,Premium +ORD-00688,2024-07-29,Dumbbell Set,Sports,6,69.99,0,419.94,North,Bank Transfer,VIP +ORD-00858,2024-07-29,Air Purifier,Home & Kitchen,9,199.99,0,1799.91,Central,Bank Transfer,Standard +ORD-00330,2024-07-30,Winter Jacket,Clothing,1,116.99,0.1,116.99,West,PayPal,Premium +ORD-00580,2024-07-30,Machine Learning Guide,Books,3,54.99,0,164.97,Central,Bank Transfer,VIP +ORD-00759,2024-07-30,Yoga Mat,Sports,2,25.49,0.15,50.98,West,Bank Transfer,Premium +ORD-00918,2024-07-30,Data Science Handbook,Books,2,44.99,0,89.98,South,Cash on Delivery,Standard +ORD-00130,2024-07-31,Resistance Bands Set,Sports,2,19.99,0,39.98,East,Bank Transfer,Standard +ORD-00151,2024-07-31,Cotton T-Shirt,Clothing,5,19.99,0,99.95,South,PayPal,Standard +ORD-00188,2024-07-31,Water Bottle,Home & Kitchen,10,14.24,0.05,142.4,East,PayPal,VIP +ORD-00380,2024-07-31,Mechanical Keyboard,Electronics,2,89.99,0,179.98,North,Credit Card,VIP +ORD-00596,2024-07-31,Yoga Mat,Sports,9,29.99,0,269.91,Central,Cash on Delivery,Standard +ORD-00328,2024-08-01,Desk Organizer,Home & Kitchen,6,19.54,0.15,117.24,West,PayPal,Premium +ORD-00511,2024-08-01,Machine Learning Guide,Books,6,54.99,0,329.94,Central,Credit Card,Standard +ORD-00041,2024-08-02,Yoga Mat,Sports,10,29.99,0,299.9,Central,Bank Transfer,Premium +ORD-00644,2024-08-02,Wool Scarf,Clothing,9,21.24,0.15,191.16,South,Credit Card,Standard +ORD-00971,2024-08-02,Cotton T-Shirt,Clothing,5,19.99,0,99.95,East,Credit Card,Premium +ORD-00938,2024-08-04,AI Revolution,Books,9,25.49,0.15,229.41,East,Credit Card,Standard +ORD-00980,2024-08-05,Denim Jeans,Clothing,3,49.99,0,149.97,North,Credit Card,Standard +ORD-00988,2024-08-05,Wireless Mouse,Electronics,6,28.49,0.05,170.94,South,PayPal,Premium +ORD-00883,2024-08-06,Webcam HD,Electronics,8,59.99,0,479.92,East,Bank Transfer,Standard +ORD-00081,2024-08-07,Yoga Mat,Sports,2,25.49,0.15,50.98,South,Cash on Delivery,Standard +ORD-00230,2024-08-07,Resistance Bands Set,Sports,3,19.99,0,59.97,North,Bank Transfer,Standard +ORD-00386,2024-08-07,Resistance Bands Set,Sports,8,17.99,0.1,143.92,West,Credit Card,VIP +ORD-00592,2024-08-07,Yoga Mat,Sports,7,29.99,0,209.93,East,Bank Transfer,Standard +ORD-00925,2024-08-07,Water Bottle,Home & Kitchen,5,14.99,0,74.95,South,PayPal,Premium +ORD-00992,2024-08-07,Jump Rope,Sports,5,10.39,0.2,51.95,South,Credit Card,Premium +ORD-00065,2024-08-08,USB-C Hub,Electronics,5,49.99,0,249.95,Central,Bank Transfer,Premium +ORD-00141,2024-08-08,USB-C Hub,Electronics,6,49.99,0,299.94,South,PayPal,VIP +ORD-00240,2024-08-08,Python Programming,Books,7,39.99,0,279.93,Central,PayPal,VIP +ORD-00704,2024-08-08,Water Bottle,Home & Kitchen,5,14.99,0,74.95,East,PayPal,Standard +ORD-00886,2024-08-08,Coffee Maker,Home & Kitchen,3,80.99,0.1,242.97,South,Credit Card,Premium +ORD-00366,2024-08-10,Air Purifier,Home & Kitchen,2,199.99,0,399.98,East,Cash on Delivery,Premium +ORD-00842,2024-08-10,Wool Scarf,Clothing,7,19.99,0.2,139.93,Central,Bank Transfer,VIP +ORD-00852,2024-08-10,Machine Learning Guide,Books,7,52.24,0.05,365.68,Central,PayPal,VIP +ORD-00432,2024-08-11,Yoga Mat,Sports,5,25.49,0.15,127.45,South,Credit Card,Standard +ORD-00478,2024-08-11,AI Revolution,Books,3,29.99,0,89.97,West,Bank Transfer,Standard +ORD-00560,2024-08-12,Jump Rope,Sports,10,11.04,0.15,110.4,South,Cash on Delivery,Premium +ORD-00711,2024-08-12,AI Revolution,Books,3,25.49,0.15,76.47,North,PayPal,Standard +ORD-00895,2024-08-12,Machine Learning Guide,Books,2,46.74,0.15,93.48,South,PayPal,Standard +ORD-00150,2024-08-13,Cotton T-Shirt,Clothing,1,19.99,0,19.99,North,Bank Transfer,VIP +ORD-00264,2024-08-13,Webcam HD,Electronics,1,59.99,0,59.99,West,Cash on Delivery,Premium +ORD-00761,2024-08-13,Machine Learning Guide,Books,10,54.99,0,549.9,North,PayPal,Standard +ORD-00715,2024-08-14,Laptop Pro 15,Electronics,4,1234.99,0.05,4939.96,South,Credit Card,Standard +ORD-00741,2024-08-14,Data Science Handbook,Books,9,40.49,0.1,364.41,East,Bank Transfer,VIP +ORD-00978,2024-08-14,AI Revolution,Books,3,23.99,0.2,71.97,Central,Bank Transfer,Standard +ORD-00161,2024-08-15,Webcam HD,Electronics,6,59.99,0,359.94,West,PayPal,Premium +ORD-00242,2024-08-15,Jump Rope,Sports,3,11.04,0.15,33.12,East,Cash on Delivery,Standard +ORD-00246,2024-08-15,Winter Jacket,Clothing,6,129.99,0,779.94,North,PayPal,VIP +ORD-00621,2024-08-16,Running Shoes,Clothing,8,75.99,0.05,607.92,East,Bank Transfer,Premium +ORD-00690,2024-08-16,AI Revolution,Books,9,28.49,0.05,256.41,North,Cash on Delivery,VIP +ORD-00868,2024-08-16,Wool Scarf,Clothing,3,21.24,0.15,63.72,East,PayPal,Premium +ORD-00957,2024-08-16,Cotton T-Shirt,Clothing,2,17.99,0.1,35.98,North,Cash on Delivery,Standard +ORD-00169,2024-08-17,Webcam HD,Electronics,2,56.99,0.05,113.98,East,Credit Card,Premium +ORD-00109,2024-08-18,Resistance Bands Set,Sports,9,18.99,0.05,170.91,West,Bank Transfer,VIP +ORD-00223,2024-08-18,Winter Jacket,Clothing,2,129.99,0,259.98,East,Credit Card,Premium +ORD-00274,2024-08-18,Python Programming,Books,7,35.99,0.1,251.93,Central,Bank Transfer,VIP +ORD-00357,2024-08-18,Webcam HD,Electronics,5,47.99,0.2,239.95,West,PayPal,Premium +ORD-00682,2024-08-18,AI Revolution,Books,7,29.99,0,209.93,South,PayPal,VIP +ORD-00763,2024-08-18,USB-C Hub,Electronics,2,49.99,0,99.98,West,Credit Card,Premium +ORD-00897,2024-08-18,Machine Learning Guide,Books,9,43.99,0.2,395.91,North,Credit Card,Premium +ORD-00407,2024-08-19,Dumbbell Set,Sports,7,66.49,0.05,465.43,North,Credit Card,Premium +ORD-00452,2024-08-19,Machine Learning Guide,Books,6,54.99,0,329.94,East,PayPal,Standard +ORD-00571,2024-08-19,Machine Learning Guide,Books,6,46.74,0.15,280.44,North,Credit Card,VIP +ORD-00709,2024-08-19,Webcam HD,Electronics,8,53.99,0.1,431.92,East,Cash on Delivery,Standard +ORD-00787,2024-08-19,Yoga Mat,Sports,9,29.99,0,269.91,West,Credit Card,Standard +ORD-00036,2024-08-20,Mechanical Keyboard,Electronics,5,71.99,0.2,359.95,East,Cash on Delivery,VIP +ORD-00100,2024-08-20,Dumbbell Set,Sports,6,59.49,0.15,356.94,East,Bank Transfer,Premium +ORD-00113,2024-08-20,Running Shoes,Clothing,5,67.99,0.15,339.95,North,PayPal,Premium +ORD-00163,2024-08-20,Data Science Handbook,Books,2,35.99,0.2,71.98,North,PayPal,VIP +ORD-00175,2024-08-20,Air Purifier,Home & Kitchen,7,199.99,0,1399.93,West,Cash on Delivery,VIP +ORD-00299,2024-08-20,Yoga Mat,Sports,6,29.99,0,179.94,Central,Cash on Delivery,Standard +ORD-00664,2024-08-20,Data Science Handbook,Books,10,44.99,0,449.9,West,Credit Card,Premium +ORD-00856,2024-08-20,Jump Rope,Sports,10,12.99,0,129.9,Central,Cash on Delivery,VIP +ORD-00344,2024-08-21,Cotton T-Shirt,Clothing,4,17.99,0.1,71.96,East,Credit Card,VIP +ORD-00381,2024-08-21,Running Shoes,Clothing,5,79.99,0,399.95,South,Cash on Delivery,Premium +ORD-00542,2024-08-21,Laptop Pro 15,Electronics,5,1299.99,0,6499.95,Central,Bank Transfer,Premium +ORD-00700,2024-08-21,Resistance Bands Set,Sports,10,17.99,0.1,179.9,Central,Cash on Delivery,VIP +ORD-00388,2024-08-22,Running Shoes,Clothing,6,79.99,0,479.94,North,Bank Transfer,VIP +ORD-00390,2024-08-22,USB-C Hub,Electronics,6,39.99,0.2,239.94,Central,PayPal,VIP +ORD-00398,2024-08-22,Wool Scarf,Clothing,5,24.99,0,124.95,Central,Cash on Delivery,VIP +ORD-00056,2024-08-23,Air Purifier,Home & Kitchen,4,159.99,0.2,639.96,East,Bank Transfer,Standard +ORD-00091,2024-08-23,Mechanical Keyboard,Electronics,6,76.49,0.15,458.94,South,Cash on Delivery,Standard +ORD-00412,2024-08-23,Laptop Pro 15,Electronics,2,1104.99,0.15,2209.98,North,Credit Card,Standard +ORD-00746,2024-08-23,Data Science Handbook,Books,2,44.99,0,89.98,East,PayPal,Standard +ORD-00219,2024-08-24,Running Shoes,Clothing,10,75.99,0.05,759.9,East,Cash on Delivery,VIP +ORD-00554,2024-08-25,Winter Jacket,Clothing,5,129.99,0,649.95,North,Credit Card,Standard +ORD-00626,2024-08-25,Wool Scarf,Clothing,5,24.99,0,124.95,Central,Credit Card,Premium +ORD-00790,2024-08-25,Python Programming,Books,9,39.99,0,359.91,West,Credit Card,VIP +ORD-00726,2024-08-27,Wool Scarf,Clothing,6,24.99,0,149.94,East,PayPal,Standard +ORD-00202,2024-08-28,Data Science Handbook,Books,8,44.99,0,359.92,West,Bank Transfer,Premium +ORD-00232,2024-08-28,Yoga Mat,Sports,7,23.99,0.2,167.93,North,Cash on Delivery,Premium +ORD-00703,2024-08-28,Resistance Bands Set,Sports,6,19.99,0,119.94,Central,Credit Card,VIP +ORD-00316,2024-08-29,Monitor 27-inch,Electronics,1,399.99,0,399.99,North,Bank Transfer,Premium +ORD-00530,2024-08-29,Resistance Bands Set,Sports,5,19.99,0,99.95,South,Bank Transfer,VIP +ORD-00625,2024-08-29,Dumbbell Set,Sports,9,62.99,0.1,566.91,South,Bank Transfer,Premium +ORD-00066,2024-08-30,Water Bottle,Home & Kitchen,4,13.49,0.1,53.96,South,Credit Card,Premium +ORD-00277,2024-08-30,Wireless Mouse,Electronics,6,23.99,0.2,143.94,Central,Cash on Delivery,Standard +ORD-00507,2024-08-30,Water Bottle,Home & Kitchen,10,13.49,0.1,134.9,North,PayPal,Premium +ORD-00517,2024-08-30,Jump Rope,Sports,3,10.39,0.2,31.17,Central,Bank Transfer,Premium +ORD-00913,2024-08-30,Cotton T-Shirt,Clothing,3,16.99,0.15,50.97,West,PayPal,Standard +ORD-00068,2024-08-31,Cotton T-Shirt,Clothing,1,19.99,0,19.99,Central,Credit Card,Premium +ORD-00509,2024-08-31,Data Science Handbook,Books,1,38.24,0.15,38.24,East,Bank Transfer,Standard +ORD-00872,2024-08-31,Wireless Mouse,Electronics,8,28.49,0.05,227.92,East,Cash on Delivery,VIP +ORD-00908,2024-08-31,Dumbbell Set,Sports,7,59.49,0.15,416.43,South,PayPal,Standard +ORD-00282,2024-09-02,Resistance Bands Set,Sports,1,17.99,0.1,17.99,South,Cash on Delivery,Standard +ORD-00333,2024-09-02,Wool Scarf,Clothing,9,24.99,0,224.91,South,Bank Transfer,Premium +ORD-00465,2024-09-02,Machine Learning Guide,Books,7,49.49,0.1,346.43,North,Credit Card,Premium +ORD-00838,2024-09-02,Mechanical Keyboard,Electronics,2,89.99,0,179.98,West,PayPal,Premium +ORD-00954,2024-09-02,Smart Lamp,Home & Kitchen,2,29.74,0.15,59.48,North,PayPal,VIP +ORD-00841,2024-09-03,Resistance Bands Set,Sports,9,19.99,0,179.91,West,Credit Card,Premium +ORD-00146,2024-09-04,Data Science Handbook,Books,7,35.99,0.2,251.93,North,PayPal,Premium +ORD-00287,2024-09-04,AI Revolution,Books,5,26.99,0.1,134.95,South,PayPal,Premium +ORD-00023,2024-09-05,Yoga Mat,Sports,2,25.49,0.15,50.98,North,PayPal,Standard +ORD-00512,2024-09-05,Air Purifier,Home & Kitchen,7,169.99,0.15,1189.93,Central,Bank Transfer,Standard +ORD-00618,2024-09-05,Yoga Mat,Sports,4,29.99,0,119.96,West,Bank Transfer,VIP +ORD-00732,2024-09-05,Cotton T-Shirt,Clothing,4,19.99,0,79.96,South,Credit Card,Standard +ORD-00975,2024-09-05,Dumbbell Set,Sports,6,55.99,0.2,335.94,West,Bank Transfer,Standard +ORD-00138,2024-09-06,Jump Rope,Sports,6,11.04,0.15,66.24,Central,Credit Card,VIP +ORD-00408,2024-09-06,Dumbbell Set,Sports,6,66.49,0.05,398.94,East,Credit Card,VIP +ORD-00561,2024-09-06,Yoga Mat,Sports,3,28.49,0.05,85.47,North,Cash on Delivery,Premium +ORD-00160,2024-09-07,Winter Jacket,Clothing,2,103.99,0.2,207.98,Central,Cash on Delivery,Premium +ORD-00494,2024-09-07,USB-C Hub,Electronics,7,47.49,0.05,332.43,East,Credit Card,VIP +ORD-00896,2024-09-07,Cotton T-Shirt,Clothing,10,19.99,0,199.9,West,Credit Card,VIP +ORD-00396,2024-09-08,Jump Rope,Sports,7,11.04,0.15,77.28,West,Cash on Delivery,Premium +ORD-00448,2024-09-08,Wool Scarf,Clothing,4,23.74,0.05,94.96,North,PayPal,VIP +ORD-00510,2024-09-08,AI Revolution,Books,5,29.99,0,149.95,North,PayPal,Standard +ORD-00565,2024-09-08,Water Bottle,Home & Kitchen,3,14.99,0,44.97,East,PayPal,Premium +ORD-00885,2024-09-08,Data Science Handbook,Books,8,44.99,0,359.92,South,Cash on Delivery,Standard +ORD-00993,2024-09-08,Data Science Handbook,Books,1,38.24,0.15,38.24,North,Cash on Delivery,Premium +ORD-00473,2024-09-09,Data Science Handbook,Books,10,44.99,0,449.9,Central,Bank Transfer,Standard +ORD-00485,2024-09-09,Water Bottle,Home & Kitchen,3,14.24,0.05,42.72,West,Bank Transfer,VIP +ORD-00799,2024-09-09,Desk Organizer,Home & Kitchen,1,20.69,0.1,20.69,South,PayPal,VIP +ORD-00327,2024-09-10,Resistance Bands Set,Sports,7,17.99,0.1,125.93,North,Bank Transfer,VIP +ORD-00870,2024-09-10,Wool Scarf,Clothing,3,19.99,0.2,59.97,West,Cash on Delivery,Standard +ORD-00928,2024-09-10,Water Bottle,Home & Kitchen,2,14.24,0.05,28.48,South,Credit Card,VIP +ORD-00239,2024-09-11,Water Bottle,Home & Kitchen,6,14.99,0,89.94,West,Bank Transfer,VIP +ORD-00527,2024-09-11,Data Science Handbook,Books,1,38.24,0.15,38.24,East,Bank Transfer,Premium +ORD-00229,2024-09-12,Jump Rope,Sports,2,12.99,0,25.98,Central,Credit Card,VIP +ORD-00269,2024-09-12,Winter Jacket,Clothing,9,123.49,0.05,1111.41,East,Cash on Delivery,Standard +ORD-00437,2024-09-12,Desk Organizer,Home & Kitchen,6,22.99,0,137.94,Central,PayPal,Premium +ORD-00576,2024-09-12,Desk Organizer,Home & Kitchen,9,22.99,0,206.91,North,Credit Card,VIP +ORD-00681,2024-09-12,Dumbbell Set,Sports,6,69.99,0,419.94,West,PayPal,Premium +ORD-00206,2024-09-13,Running Shoes,Clothing,10,75.99,0.05,759.9,South,Credit Card,Premium +ORD-00772,2024-09-13,AI Revolution,Books,5,29.99,0,149.95,East,Credit Card,VIP +ORD-00939,2024-09-13,AI Revolution,Books,8,28.49,0.05,227.92,Central,Credit Card,Premium +ORD-00324,2024-09-14,Resistance Bands Set,Sports,1,19.99,0,19.99,East,PayPal,VIP +ORD-00677,2024-09-14,Water Bottle,Home & Kitchen,9,14.99,0,134.91,East,Cash on Delivery,VIP +ORD-00927,2024-09-15,Laptop Pro 15,Electronics,8,1104.99,0.15,8839.92,North,Cash on Delivery,VIP +ORD-00276,2024-09-17,Data Science Handbook,Books,4,40.49,0.1,161.96,West,Credit Card,Standard +ORD-00279,2024-09-17,AI Revolution,Books,9,23.99,0.2,215.91,Central,Cash on Delivery,VIP +ORD-00601,2024-09-17,Dumbbell Set,Sports,9,55.99,0.2,503.91,West,Cash on Delivery,Standard +ORD-00553,2024-09-18,Air Purifier,Home & Kitchen,2,159.99,0.2,319.98,North,Credit Card,Premium +ORD-00958,2024-09-18,Smart Lamp,Home & Kitchen,10,31.49,0.1,314.9,North,Bank Transfer,Premium +ORD-00028,2024-09-20,Laptop Pro 15,Electronics,6,1299.99,0,7799.94,South,Bank Transfer,VIP +ORD-00096,2024-09-21,AI Revolution,Books,9,29.99,0,269.91,South,Credit Card,Standard +ORD-00669,2024-09-21,Running Shoes,Clothing,7,79.99,0,559.93,Central,Credit Card,VIP +ORD-00424,2024-09-22,Webcam HD,Electronics,5,59.99,0,299.95,Central,PayPal,VIP +ORD-00678,2024-09-22,Winter Jacket,Clothing,2,110.49,0.15,220.98,West,Bank Transfer,VIP +ORD-00737,2024-09-22,USB-C Hub,Electronics,2,49.99,0,99.98,North,Credit Card,VIP +ORD-00061,2024-09-23,Webcam HD,Electronics,7,59.99,0,419.93,Central,PayPal,Premium +ORD-00449,2024-09-23,Python Programming,Books,1,39.99,0,39.99,West,Bank Transfer,Premium +ORD-00658,2024-09-23,Water Bottle,Home & Kitchen,10,12.74,0.15,127.4,East,Credit Card,Standard +ORD-00825,2024-09-24,Mechanical Keyboard,Electronics,7,76.49,0.15,535.43,North,Credit Card,Premium +ORD-00924,2024-09-24,Resistance Bands Set,Sports,2,18.99,0.05,37.98,West,PayPal,VIP +ORD-00698,2024-09-26,Machine Learning Guide,Books,3,54.99,0,164.97,South,Credit Card,Standard +ORD-00024,2024-09-27,Desk Organizer,Home & Kitchen,3,22.99,0,68.97,Central,Cash on Delivery,Standard +ORD-00310,2024-09-27,Yoga Mat,Sports,9,28.49,0.05,256.41,East,Credit Card,VIP +ORD-00426,2024-09-27,Winter Jacket,Clothing,10,129.99,0,1299.9,North,Bank Transfer,Premium +ORD-00869,2024-09-27,Winter Jacket,Clothing,3,103.99,0.2,311.97,North,Bank Transfer,Standard +ORD-00021,2024-09-28,Data Science Handbook,Books,9,35.99,0.2,323.91,North,Bank Transfer,Premium +ORD-00099,2024-09-28,Dumbbell Set,Sports,8,62.99,0.1,503.92,East,Cash on Delivery,VIP +ORD-00173,2024-09-28,AI Revolution,Books,7,26.99,0.1,188.93,East,Cash on Delivery,VIP +ORD-00645,2024-09-29,Desk Organizer,Home & Kitchen,10,22.99,0,229.9,Central,Credit Card,Standard +ORD-00828,2024-09-29,Denim Jeans,Clothing,5,49.99,0,249.95,East,Cash on Delivery,VIP +ORD-00218,2024-09-30,Dumbbell Set,Sports,8,69.99,0,559.92,South,Bank Transfer,Premium +ORD-00539,2024-09-30,Water Bottle,Home & Kitchen,1,14.99,0,14.99,South,Bank Transfer,Standard +ORD-00610,2024-09-30,Coffee Maker,Home & Kitchen,8,89.99,0,719.92,South,PayPal,VIP +ORD-00710,2024-09-30,Python Programming,Books,3,39.99,0,119.97,North,PayPal,Standard +ORD-00951,2024-09-30,Machine Learning Guide,Books,9,54.99,0,494.91,East,Credit Card,Standard +ORD-00018,2024-10-01,Python Programming,Books,2,33.99,0.15,67.98,East,Bank Transfer,Standard +ORD-00454,2024-10-01,Smart Lamp,Home & Kitchen,7,34.99,0,244.93,East,PayPal,Premium +ORD-00506,2024-10-01,Yoga Mat,Sports,5,23.99,0.2,119.95,East,Cash on Delivery,Premium +ORD-00891,2024-10-01,Water Bottle,Home & Kitchen,2,13.49,0.1,26.98,East,Bank Transfer,VIP +ORD-00092,2024-10-02,Dumbbell Set,Sports,10,55.99,0.2,559.9,West,Cash on Delivery,Premium +ORD-00729,2024-10-02,Running Shoes,Clothing,2,67.99,0.15,135.98,West,PayPal,VIP +ORD-00986,2024-10-02,Running Shoes,Clothing,3,63.99,0.2,191.97,North,Credit Card,Standard +ORD-00699,2024-10-03,AI Revolution,Books,8,28.49,0.05,227.92,East,Cash on Delivery,Standard +ORD-00701,2024-10-03,Dumbbell Set,Sports,6,66.49,0.05,398.94,South,Credit Card,Premium +ORD-00251,2024-10-04,AI Revolution,Books,2,26.99,0.1,53.98,East,Bank Transfer,Standard +ORD-00674,2024-10-04,Desk Organizer,Home & Kitchen,1,18.39,0.2,18.39,North,Cash on Delivery,Premium +ORD-00045,2024-10-05,AI Revolution,Books,3,28.49,0.05,85.47,East,Credit Card,VIP +ORD-00114,2024-10-05,Resistance Bands Set,Sports,7,19.99,0,139.93,South,PayPal,Premium +ORD-00372,2024-10-06,Monitor 27-inch,Electronics,4,359.99,0.1,1439.96,South,Credit Card,VIP +ORD-00764,2024-10-06,Wool Scarf,Clothing,4,24.99,0,99.96,Central,Bank Transfer,VIP +ORD-00458,2024-10-08,Mechanical Keyboard,Electronics,8,80.99,0.1,647.92,South,Credit Card,Premium +ORD-00566,2024-10-08,Python Programming,Books,9,39.99,0,359.91,West,Cash on Delivery,Standard +ORD-00655,2024-10-08,Jump Rope,Sports,6,10.39,0.2,62.34,Central,Credit Card,Standard +ORD-00132,2024-10-09,Jump Rope,Sports,6,12.99,0,77.94,Central,Cash on Delivery,Premium +ORD-00256,2024-10-09,Desk Organizer,Home & Kitchen,6,22.99,0,137.94,Central,PayPal,Standard +ORD-00335,2024-10-09,Resistance Bands Set,Sports,7,19.99,0,139.93,East,Bank Transfer,Premium +ORD-00205,2024-10-10,Jump Rope,Sports,8,11.69,0.1,93.52,North,Cash on Delivery,VIP +ORD-00181,2024-10-11,Air Purifier,Home & Kitchen,2,199.99,0,399.98,East,Credit Card,Premium +ORD-00362,2024-10-11,Winter Jacket,Clothing,8,116.99,0.1,935.92,Central,Cash on Delivery,Standard +ORD-00853,2024-10-11,Winter Jacket,Clothing,3,129.99,0,389.97,West,Bank Transfer,Premium +ORD-00210,2024-10-12,Python Programming,Books,6,39.99,0,239.94,West,Bank Transfer,Premium +ORD-00801,2024-10-12,Dumbbell Set,Sports,10,59.49,0.15,594.9,South,Credit Card,Standard +ORD-00053,2024-10-13,Cotton T-Shirt,Clothing,6,15.99,0.2,95.94,West,PayPal,Standard +ORD-00663,2024-10-13,Laptop Pro 15,Electronics,9,1299.99,0,11699.91,Central,Bank Transfer,Premium +ORD-00665,2024-10-13,Winter Jacket,Clothing,9,129.99,0,1169.91,North,Bank Transfer,Premium +ORD-00003,2024-10-14,Wool Scarf,Clothing,10,24.99,0,249.9,South,Cash on Delivery,Standard +ORD-00182,2024-10-14,Resistance Bands Set,Sports,7,15.99,0.2,111.93,Central,Credit Card,VIP +ORD-00248,2024-10-14,Winter Jacket,Clothing,9,123.49,0.05,1111.41,North,Bank Transfer,Premium +ORD-00273,2024-10-14,Mechanical Keyboard,Electronics,1,80.99,0.1,80.99,South,Bank Transfer,VIP +ORD-00598,2024-10-14,Wool Scarf,Clothing,9,23.74,0.05,213.66,North,Credit Card,VIP +ORD-00705,2024-10-14,Dumbbell Set,Sports,4,66.49,0.05,265.96,West,Cash on Delivery,Standard +ORD-00960,2024-10-14,AI Revolution,Books,8,23.99,0.2,191.92,North,PayPal,VIP +ORD-00377,2024-10-15,Data Science Handbook,Books,10,44.99,0,449.9,West,Bank Transfer,Premium +ORD-00817,2024-10-16,Coffee Maker,Home & Kitchen,9,89.99,0,809.91,East,Credit Card,Standard +ORD-00064,2024-10-18,Water Bottle,Home & Kitchen,3,13.49,0.1,40.47,East,Cash on Delivery,VIP +ORD-00029,2024-10-19,Air Purifier,Home & Kitchen,9,199.99,0,1799.91,Central,Cash on Delivery,Standard +ORD-00500,2024-10-19,Monitor 27-inch,Electronics,10,359.99,0.1,3599.9,Central,PayPal,Standard +ORD-00194,2024-10-20,USB-C Hub,Electronics,10,47.49,0.05,474.9,West,Cash on Delivery,Premium +ORD-00253,2024-10-20,Coffee Maker,Home & Kitchen,6,80.99,0.1,485.94,East,Cash on Delivery,VIP +ORD-00462,2024-10-20,Mechanical Keyboard,Electronics,1,89.99,0,89.99,West,Cash on Delivery,Premium +ORD-00567,2024-10-20,Mechanical Keyboard,Electronics,8,71.99,0.2,575.92,West,Credit Card,Premium +ORD-00666,2024-10-21,Monitor 27-inch,Electronics,5,359.99,0.1,1799.95,East,Cash on Delivery,Premium +ORD-00933,2024-10-21,Smart Lamp,Home & Kitchen,10,31.49,0.1,314.9,South,Credit Card,VIP +ORD-00107,2024-10-22,Mechanical Keyboard,Electronics,8,89.99,0,719.92,North,PayPal,Standard +ORD-00180,2024-10-22,Dumbbell Set,Sports,6,69.99,0,419.94,Central,PayPal,Standard +ORD-00486,2024-10-22,Coffee Maker,Home & Kitchen,8,71.99,0.2,575.92,South,PayPal,Premium +ORD-00653,2024-10-22,Machine Learning Guide,Books,5,54.99,0,274.95,East,Cash on Delivery,VIP +ORD-00676,2024-10-22,Machine Learning Guide,Books,4,43.99,0.2,175.96,South,Credit Card,VIP +ORD-00827,2024-10-22,Monitor 27-inch,Electronics,5,399.99,0,1999.95,East,PayPal,Premium +ORD-00804,2024-10-23,Resistance Bands Set,Sports,8,16.99,0.15,135.92,East,Credit Card,Standard +ORD-00985,2024-10-23,Yoga Mat,Sports,2,23.99,0.2,47.98,North,PayPal,VIP +ORD-00679,2024-10-24,Laptop Pro 15,Electronics,4,1234.99,0.05,4939.96,North,Bank Transfer,Premium +ORD-00937,2024-10-24,Yoga Mat,Sports,8,25.49,0.15,203.92,South,PayPal,Premium +ORD-00211,2024-10-25,Resistance Bands Set,Sports,6,19.99,0,119.94,South,Bank Transfer,Premium +ORD-00375,2024-10-25,Mechanical Keyboard,Electronics,10,85.49,0.05,854.9,West,Cash on Delivery,VIP +ORD-00583,2024-10-25,USB-C Hub,Electronics,9,47.49,0.05,427.41,Central,Cash on Delivery,VIP +ORD-00077,2024-10-26,Wireless Mouse,Electronics,7,28.49,0.05,199.43,North,Cash on Delivery,Premium +ORD-00791,2024-10-26,Yoga Mat,Sports,3,23.99,0.2,71.97,Central,PayPal,Premium +ORD-00165,2024-10-27,Cotton T-Shirt,Clothing,9,19.99,0,179.91,South,PayPal,Premium +ORD-00178,2024-10-27,Data Science Handbook,Books,9,44.99,0,404.91,Central,Bank Transfer,VIP +ORD-00460,2024-10-27,Smart Lamp,Home & Kitchen,2,31.49,0.1,62.98,East,Credit Card,Standard +ORD-00662,2024-10-27,Smart Lamp,Home & Kitchen,7,33.24,0.05,232.68,South,Credit Card,VIP +ORD-00102,2024-10-28,Winter Jacket,Clothing,5,110.49,0.15,552.45,Central,Bank Transfer,Standard +ORD-00402,2024-10-28,Wireless Mouse,Electronics,4,23.99,0.2,95.96,East,Cash on Delivery,Standard +ORD-00479,2024-10-28,Dumbbell Set,Sports,4,59.49,0.15,237.96,South,Cash on Delivery,Premium +ORD-00505,2024-10-28,Air Purifier,Home & Kitchen,3,179.99,0.1,539.97,Central,Credit Card,Standard +ORD-00987,2024-10-28,Jump Rope,Sports,6,11.04,0.15,66.24,South,Cash on Delivery,VIP +ORD-00445,2024-10-29,Resistance Bands Set,Sports,8,19.99,0,159.92,West,PayPal,VIP +ORD-00416,2024-10-30,Winter Jacket,Clothing,2,129.99,0,259.98,South,Cash on Delivery,Standard +ORD-00945,2024-10-30,Dumbbell Set,Sports,6,66.49,0.05,398.94,Central,Cash on Delivery,Premium +ORD-00112,2024-10-31,Mechanical Keyboard,Electronics,8,76.49,0.15,611.92,West,Bank Transfer,Standard +ORD-00524,2024-10-31,Dumbbell Set,Sports,9,59.49,0.15,535.41,Central,Bank Transfer,Premium +ORD-00059,2024-11-01,Dumbbell Set,Sports,1,69.99,0,69.99,West,Bank Transfer,VIP +ORD-00769,2024-11-01,Yoga Mat,Sports,6,28.49,0.05,170.94,Central,Credit Card,Standard +ORD-00190,2024-11-02,Python Programming,Books,4,37.99,0.05,151.96,North,Credit Card,Standard +ORD-00968,2024-11-02,USB-C Hub,Electronics,8,49.99,0,399.92,East,Cash on Delivery,VIP +ORD-00578,2024-11-03,Cotton T-Shirt,Clothing,7,16.99,0.15,118.93,Central,Bank Transfer,VIP +ORD-00991,2024-11-03,Mechanical Keyboard,Electronics,5,85.49,0.05,427.45,East,Cash on Delivery,Standard +ORD-00997,2024-11-04,Smart Lamp,Home & Kitchen,8,29.74,0.15,237.92,West,Cash on Delivery,VIP +ORD-00047,2024-11-05,Resistance Bands Set,Sports,5,19.99,0,99.95,South,Bank Transfer,Standard +ORD-00782,2024-11-05,Air Purifier,Home & Kitchen,9,159.99,0.2,1439.91,West,Bank Transfer,Standard +ORD-00881,2024-11-05,Webcam HD,Electronics,2,59.99,0,119.98,South,Cash on Delivery,VIP +ORD-00923,2024-11-05,Running Shoes,Clothing,4,79.99,0,319.96,South,PayPal,VIP +ORD-00308,2024-11-06,Air Purifier,Home & Kitchen,8,169.99,0.15,1359.92,East,Cash on Delivery,VIP +ORD-00622,2024-11-06,Yoga Mat,Sports,3,25.49,0.15,76.47,North,Bank Transfer,Standard +ORD-00819,2024-11-06,Desk Organizer,Home & Kitchen,3,20.69,0.1,62.07,West,PayPal,VIP +ORD-00172,2024-11-07,Coffee Maker,Home & Kitchen,2,89.99,0,179.98,South,Credit Card,Standard +ORD-00244,2024-11-07,Resistance Bands Set,Sports,1,19.99,0,19.99,West,Credit Card,Standard +ORD-00550,2024-11-07,Laptop Pro 15,Electronics,8,1299.99,0,10399.92,South,Bank Transfer,VIP +ORD-00956,2024-11-07,Python Programming,Books,6,39.99,0,239.94,South,PayPal,Premium +ORD-00208,2024-11-08,Water Bottle,Home & Kitchen,9,13.49,0.1,121.41,South,Bank Transfer,Premium +ORD-00901,2024-11-08,USB-C Hub,Electronics,2,47.49,0.05,94.98,South,Credit Card,Standard +ORD-00433,2024-11-09,Mechanical Keyboard,Electronics,2,85.49,0.05,170.98,Central,Credit Card,Premium +ORD-00481,2024-11-09,Wireless Mouse,Electronics,10,29.99,0,299.9,South,Credit Card,Premium +ORD-00849,2024-11-09,Python Programming,Books,10,39.99,0,399.9,Central,Cash on Delivery,Standard +ORD-00307,2024-11-10,Webcam HD,Electronics,5,59.99,0,299.95,South,PayPal,Standard +ORD-00464,2024-11-10,Machine Learning Guide,Books,3,54.99,0,164.97,Central,PayPal,VIP +ORD-00475,2024-11-10,Jump Rope,Sports,1,12.99,0,12.99,South,PayPal,Premium +ORD-00599,2024-11-10,Machine Learning Guide,Books,4,46.74,0.15,186.96,Central,PayPal,Standard +ORD-00627,2024-11-10,Python Programming,Books,4,31.99,0.2,127.96,South,PayPal,Premium +ORD-00430,2024-11-11,Coffee Maker,Home & Kitchen,9,80.99,0.1,728.91,South,Bank Transfer,Standard +ORD-00587,2024-11-11,USB-C Hub,Electronics,8,49.99,0,399.92,Central,PayPal,VIP +ORD-00736,2024-11-11,Smart Lamp,Home & Kitchen,1,27.99,0.2,27.99,East,Credit Card,VIP +ORD-00811,2024-11-11,Webcam HD,Electronics,4,47.99,0.2,191.96,West,Cash on Delivery,Standard +ORD-00902,2024-11-11,Mechanical Keyboard,Electronics,10,71.99,0.2,719.9,South,Credit Card,Standard +ORD-00043,2024-11-14,Python Programming,Books,1,37.99,0.05,37.99,Central,Credit Card,Standard +ORD-00080,2024-11-14,Air Purifier,Home & Kitchen,8,199.99,0,1599.92,Central,Credit Card,Premium +ORD-00124,2024-11-14,Wireless Mouse,Electronics,9,29.99,0,269.91,West,Cash on Delivery,Standard +ORD-00523,2024-11-15,Smart Lamp,Home & Kitchen,9,27.99,0.2,251.91,West,Credit Card,Standard +ORD-00733,2024-11-15,Jump Rope,Sports,8,11.69,0.1,93.52,Central,Bank Transfer,Standard +ORD-00594,2024-11-16,Running Shoes,Clothing,10,79.99,0,799.9,Central,Bank Transfer,Premium +ORD-00623,2024-11-16,Resistance Bands Set,Sports,5,16.99,0.15,84.95,North,Credit Card,Standard +ORD-00797,2024-11-16,AI Revolution,Books,8,29.99,0,239.92,South,Credit Card,Standard +ORD-00912,2024-11-16,Data Science Handbook,Books,8,35.99,0.2,287.92,South,Bank Transfer,Standard +ORD-00200,2024-11-17,Resistance Bands Set,Sports,2,19.99,0,39.98,West,Cash on Delivery,VIP +ORD-00429,2024-11-17,Mechanical Keyboard,Electronics,9,89.99,0,809.91,Central,Cash on Delivery,Premium +ORD-00502,2024-11-17,Webcam HD,Electronics,7,59.99,0,419.93,South,Bank Transfer,Premium +ORD-00910,2024-11-17,Winter Jacket,Clothing,10,129.99,0,1299.9,East,Bank Transfer,Premium +ORD-00972,2024-11-17,Python Programming,Books,9,39.99,0,359.91,South,Credit Card,VIP +ORD-00343,2024-11-18,Laptop Pro 15,Electronics,7,1299.99,0,9099.93,South,Credit Card,VIP +ORD-00861,2024-11-18,Yoga Mat,Sports,4,28.49,0.05,113.96,North,Bank Transfer,VIP +ORD-00809,2024-11-19,Jump Rope,Sports,9,11.04,0.15,99.36,West,Cash on Delivery,Standard +ORD-00533,2024-11-20,Mechanical Keyboard,Electronics,1,71.99,0.2,71.99,Central,Credit Card,Premium +ORD-00538,2024-11-20,Wireless Mouse,Electronics,10,28.49,0.05,284.9,West,Cash on Delivery,Premium +ORD-00572,2024-11-20,Cotton T-Shirt,Clothing,6,19.99,0,119.94,East,Bank Transfer,VIP +ORD-00575,2024-11-20,Python Programming,Books,7,39.99,0,279.93,Central,Credit Card,Premium +ORD-00742,2024-11-20,AI Revolution,Books,7,23.99,0.2,167.93,West,Bank Transfer,VIP +ORD-00009,2024-11-21,Mechanical Keyboard,Electronics,5,85.49,0.05,427.45,East,PayPal,Premium +ORD-00149,2024-11-21,Wool Scarf,Clothing,6,24.99,0,149.94,South,Bank Transfer,VIP +ORD-00201,2024-11-21,Jump Rope,Sports,10,11.04,0.15,110.4,Central,Bank Transfer,VIP +ORD-00393,2024-11-21,Water Bottle,Home & Kitchen,3,14.24,0.05,42.72,Central,PayPal,VIP +ORD-00376,2024-11-22,USB-C Hub,Electronics,9,44.99,0.1,404.91,North,Credit Card,Standard +ORD-00493,2024-11-22,Laptop Pro 15,Electronics,4,1299.99,0,5199.96,East,Credit Card,Premium +ORD-00812,2024-11-22,AI Revolution,Books,9,23.99,0.2,215.91,South,Credit Card,VIP +ORD-00875,2024-11-22,Python Programming,Books,2,35.99,0.1,71.98,East,Cash on Delivery,VIP +ORD-00020,2024-11-23,Webcam HD,Electronics,5,47.99,0.2,239.95,Central,PayPal,Standard +ORD-00145,2024-11-23,Smart Lamp,Home & Kitchen,10,34.99,0,349.9,West,Bank Transfer,Premium +ORD-00262,2024-11-23,Wool Scarf,Clothing,7,22.49,0.1,157.43,North,Cash on Delivery,VIP +ORD-00385,2024-11-23,Winter Jacket,Clothing,3,129.99,0,389.97,West,Cash on Delivery,Standard +ORD-00735,2024-11-23,Coffee Maker,Home & Kitchen,8,71.99,0.2,575.92,North,Bank Transfer,VIP +ORD-00888,2024-11-23,Mechanical Keyboard,Electronics,3,76.49,0.15,229.47,North,Credit Card,VIP +ORD-00261,2024-11-24,Laptop Pro 15,Electronics,2,1299.99,0,2599.98,Central,Bank Transfer,VIP +ORD-00342,2024-11-24,Jump Rope,Sports,3,11.69,0.1,35.07,North,Credit Card,Standard +ORD-00749,2024-11-24,Desk Organizer,Home & Kitchen,2,20.69,0.1,41.38,East,Credit Card,VIP +ORD-00466,2024-11-25,Air Purifier,Home & Kitchen,7,199.99,0,1399.93,Central,PayPal,VIP +ORD-00162,2024-11-26,Webcam HD,Electronics,6,53.99,0.1,323.94,East,Credit Card,Premium +ORD-00207,2024-11-26,USB-C Hub,Electronics,2,44.99,0.1,89.98,South,Credit Card,Standard +ORD-00213,2024-11-26,Data Science Handbook,Books,9,44.99,0,404.91,West,Bank Transfer,Standard +ORD-00296,2024-11-26,Running Shoes,Clothing,6,79.99,0,479.94,North,Credit Card,VIP +ORD-00693,2024-11-26,AI Revolution,Books,5,26.99,0.1,134.95,West,Bank Transfer,Premium +ORD-00723,2024-11-26,Air Purifier,Home & Kitchen,6,179.99,0.1,1079.94,East,Cash on Delivery,VIP +ORD-00331,2024-11-27,Running Shoes,Clothing,4,79.99,0,319.96,North,Cash on Delivery,Premium +ORD-00892,2024-11-27,Machine Learning Guide,Books,1,46.74,0.15,46.74,North,Bank Transfer,VIP +ORD-00084,2024-11-28,Yoga Mat,Sports,6,29.99,0,179.94,North,Credit Card,Standard +ORD-00349,2024-11-28,Running Shoes,Clothing,2,67.99,0.15,135.98,North,Credit Card,Premium +ORD-00582,2024-11-28,AI Revolution,Books,3,25.49,0.15,76.47,South,Bank Transfer,VIP +ORD-00689,2024-11-28,Resistance Bands Set,Sports,10,17.99,0.1,179.9,Central,PayPal,Premium +ORD-00793,2024-11-28,USB-C Hub,Electronics,6,42.49,0.15,254.94,South,Credit Card,Standard +ORD-00369,2024-11-29,Smart Lamp,Home & Kitchen,7,27.99,0.2,195.93,Central,PayPal,Premium +ORD-00757,2024-11-29,Resistance Bands Set,Sports,8,18.99,0.05,151.92,Central,PayPal,VIP +ORD-00031,2024-11-30,Water Bottle,Home & Kitchen,1,12.74,0.15,12.74,North,Credit Card,Premium +ORD-00271,2024-11-30,USB-C Hub,Electronics,8,44.99,0.1,359.92,West,Bank Transfer,VIP +ORD-00318,2024-11-30,Webcam HD,Electronics,9,59.99,0,539.91,East,Credit Card,Standard +ORD-00489,2024-11-30,Jump Rope,Sports,1,12.34,0.05,12.34,South,Cash on Delivery,Standard +ORD-00568,2024-11-30,Smart Lamp,Home & Kitchen,2,33.24,0.05,66.48,North,Credit Card,Premium +ORD-00751,2024-11-30,Coffee Maker,Home & Kitchen,4,76.49,0.15,305.96,Central,Credit Card,VIP +ORD-00136,2024-12-01,Coffee Maker,Home & Kitchen,3,85.49,0.05,256.47,South,Credit Card,Premium +ORD-00291,2024-12-01,Python Programming,Books,6,37.99,0.05,227.94,East,Cash on Delivery,Standard +ORD-00340,2024-12-01,Resistance Bands Set,Sports,8,17.99,0.1,143.92,East,Bank Transfer,Premium +ORD-00415,2024-12-01,Smart Lamp,Home & Kitchen,4,33.24,0.05,132.96,North,Credit Card,Standard +ORD-00760,2024-12-01,Running Shoes,Clothing,6,71.99,0.1,431.94,North,Bank Transfer,Standard +ORD-00767,2024-12-01,Dumbbell Set,Sports,1,66.49,0.05,66.49,North,Credit Card,Premium +ORD-00184,2024-12-02,Wireless Mouse,Electronics,3,26.99,0.1,80.97,West,Credit Card,Standard +ORD-00447,2024-12-02,Python Programming,Books,6,39.99,0,239.94,West,Bank Transfer,Standard +ORD-00659,2024-12-02,Water Bottle,Home & Kitchen,1,14.99,0,14.99,Central,Bank Transfer,Standard +ORD-00131,2024-12-03,AI Revolution,Books,10,26.99,0.1,269.9,West,PayPal,Premium +ORD-00170,2024-12-03,Resistance Bands Set,Sports,10,19.99,0,199.9,Central,Bank Transfer,Premium +ORD-00628,2024-12-03,Denim Jeans,Clothing,10,44.99,0.1,449.9,South,Cash on Delivery,Premium +ORD-00731,2024-12-03,Python Programming,Books,2,35.99,0.1,71.98,North,Credit Card,Standard +ORD-00134,2024-12-05,Machine Learning Guide,Books,7,52.24,0.05,365.68,South,Cash on Delivery,Standard +ORD-00176,2024-12-06,USB-C Hub,Electronics,7,49.99,0,349.93,East,Bank Transfer,Standard +ORD-00179,2024-12-06,Mechanical Keyboard,Electronics,6,71.99,0.2,431.94,West,Credit Card,Premium +ORD-00309,2024-12-06,Jump Rope,Sports,9,10.39,0.2,93.51,South,PayPal,VIP +ORD-00409,2024-12-06,Webcam HD,Electronics,8,50.99,0.15,407.92,East,Credit Card,VIP +ORD-00094,2024-12-08,Winter Jacket,Clothing,10,116.99,0.1,1169.9,West,Bank Transfer,Standard +ORD-00231,2024-12-08,Jump Rope,Sports,3,11.04,0.15,33.12,North,Bank Transfer,Standard +ORD-00293,2024-12-08,Resistance Bands Set,Sports,4,19.99,0,79.96,Central,PayPal,VIP +ORD-00497,2024-12-08,Wireless Mouse,Electronics,2,29.99,0,59.98,East,Cash on Delivery,VIP +ORD-00025,2024-12-09,Resistance Bands Set,Sports,5,18.99,0.05,94.95,East,Cash on Delivery,VIP +ORD-00159,2024-12-09,Winter Jacket,Clothing,8,129.99,0,1039.92,Central,Cash on Delivery,VIP +ORD-00603,2024-12-09,Yoga Mat,Sports,2,29.99,0,59.98,South,Cash on Delivery,Standard +ORD-00155,2024-12-10,Mechanical Keyboard,Electronics,6,76.49,0.15,458.94,East,Cash on Delivery,VIP +ORD-00314,2024-12-10,Yoga Mat,Sports,9,28.49,0.05,256.41,East,Credit Card,Premium +ORD-00325,2024-12-10,Dumbbell Set,Sports,10,62.99,0.1,629.9,East,PayPal,Premium +ORD-00753,2024-12-10,Resistance Bands Set,Sports,3,19.99,0,59.97,West,Cash on Delivery,VIP +ORD-00187,2024-12-11,Denim Jeans,Clothing,10,49.99,0,499.9,North,Bank Transfer,Premium +ORD-00438,2024-12-11,Running Shoes,Clothing,2,71.99,0.1,143.98,North,PayPal,VIP +ORD-00144,2024-12-12,Python Programming,Books,8,39.99,0,319.92,Central,PayPal,Standard +ORD-00406,2024-12-12,Running Shoes,Clothing,4,63.99,0.2,255.96,West,Cash on Delivery,Standard +ORD-00555,2024-12-12,USB-C Hub,Electronics,6,39.99,0.2,239.94,East,Cash on Delivery,VIP +ORD-00917,2024-12-12,Dumbbell Set,Sports,7,69.99,0,489.93,East,PayPal,Standard +ORD-00379,2024-12-13,Machine Learning Guide,Books,8,43.99,0.2,351.92,Central,PayPal,Premium +ORD-00848,2024-12-13,Running Shoes,Clothing,5,71.99,0.1,359.95,East,Cash on Delivery,VIP +ORD-01000,2024-12-13,Wireless Mouse,Electronics,7,26.99,0.1,188.93,East,PayPal,Premium +ORD-00358,2024-12-14,Jump Rope,Sports,7,12.99,0,90.93,Central,Cash on Delivery,Standard +ORD-00508,2024-12-14,Air Purifier,Home & Kitchen,10,179.99,0.1,1799.9,West,Credit Card,Premium +ORD-00534,2024-12-14,Wool Scarf,Clothing,2,19.99,0.2,39.98,West,Bank Transfer,Premium +ORD-00010,2024-12-15,Data Science Handbook,Books,5,38.24,0.15,191.2,North,PayPal,VIP +ORD-00520,2024-12-15,Desk Organizer,Home & Kitchen,1,18.39,0.2,18.39,North,Cash on Delivery,Standard +ORD-00591,2024-12-15,Running Shoes,Clothing,8,79.99,0,639.92,North,PayPal,Standard +ORD-00845,2024-12-16,Coffee Maker,Home & Kitchen,10,89.99,0,899.9,Central,Cash on Delivery,Premium +ORD-00148,2024-12-17,Smart Lamp,Home & Kitchen,1,31.49,0.1,31.49,North,Cash on Delivery,Premium +ORD-00421,2024-12-17,Denim Jeans,Clothing,7,44.99,0.1,314.93,West,PayPal,Standard +ORD-00642,2024-12-17,Dumbbell Set,Sports,3,59.49,0.15,178.47,East,Cash on Delivery,VIP +ORD-00814,2024-12-17,Dumbbell Set,Sports,1,69.99,0,69.99,East,Bank Transfer,Premium +ORD-00281,2024-12-18,Mechanical Keyboard,Electronics,10,89.99,0,899.9,North,Cash on Delivery,Premium +ORD-00785,2024-12-18,Winter Jacket,Clothing,10,123.49,0.05,1234.9,East,Cash on Delivery,Standard +ORD-00284,2024-12-19,Machine Learning Guide,Books,3,52.24,0.05,156.72,South,Cash on Delivery,VIP +ORD-00686,2024-12-19,Yoga Mat,Sports,7,29.99,0,209.93,South,Credit Card,Standard +ORD-00720,2024-12-19,Cotton T-Shirt,Clothing,1,17.99,0.1,17.99,West,PayPal,Premium +ORD-00932,2024-12-19,Coffee Maker,Home & Kitchen,2,85.49,0.05,170.98,East,Credit Card,Premium +ORD-00121,2024-12-21,Wireless Mouse,Electronics,6,28.49,0.05,170.94,West,Bank Transfer,VIP +ORD-00249,2024-12-21,Air Purifier,Home & Kitchen,10,199.99,0,1999.9,West,PayPal,Standard +ORD-00707,2024-12-21,Wool Scarf,Clothing,10,19.99,0.2,199.9,East,Bank Transfer,VIP +ORD-00063,2024-12-22,Machine Learning Guide,Books,6,52.24,0.05,313.44,East,PayPal,Standard +ORD-00069,2024-12-22,Desk Organizer,Home & Kitchen,4,19.54,0.15,78.16,West,Cash on Delivery,Premium +ORD-00292,2024-12-22,Wool Scarf,Clothing,5,22.49,0.1,112.45,East,PayPal,Standard +ORD-00382,2024-12-22,Coffee Maker,Home & Kitchen,8,76.49,0.15,611.92,West,Bank Transfer,Standard +ORD-00588,2024-12-22,Jump Rope,Sports,4,11.69,0.1,46.76,East,Credit Card,Premium +ORD-00725,2024-12-22,Desk Organizer,Home & Kitchen,5,20.69,0.1,103.45,West,PayPal,VIP +ORD-00894,2024-12-22,Mechanical Keyboard,Electronics,4,89.99,0,359.96,West,Credit Card,VIP +ORD-00660,2024-12-23,Monitor 27-inch,Electronics,9,359.99,0.1,3239.91,South,PayPal,VIP +ORD-00756,2024-12-23,Mechanical Keyboard,Electronics,9,85.49,0.05,769.41,South,PayPal,Premium +ORD-00765,2024-12-23,Desk Organizer,Home & Kitchen,3,19.54,0.15,58.62,North,Cash on Delivery,VIP +ORD-00835,2024-12-23,Resistance Bands Set,Sports,2,15.99,0.2,31.98,West,Credit Card,Premium +ORD-00086,2024-12-24,Monitor 27-inch,Electronics,4,379.99,0.05,1519.96,East,Bank Transfer,Standard +ORD-00147,2024-12-24,Dumbbell Set,Sports,10,69.99,0,699.9,East,Credit Card,VIP +ORD-00504,2024-12-24,Jump Rope,Sports,1,10.39,0.2,10.39,Central,Cash on Delivery,Standard +ORD-00608,2024-12-24,Yoga Mat,Sports,10,29.99,0,299.9,North,Bank Transfer,VIP +ORD-00654,2024-12-24,Cotton T-Shirt,Clothing,4,18.99,0.05,75.96,South,Credit Card,Standard +ORD-00055,2024-12-25,Running Shoes,Clothing,3,63.99,0.2,191.97,North,Cash on Delivery,Standard +ORD-00174,2024-12-25,Jump Rope,Sports,2,10.39,0.2,20.78,North,PayPal,Premium +ORD-00183,2024-12-25,Python Programming,Books,3,39.99,0,119.97,East,Bank Transfer,Premium +ORD-00851,2024-12-25,Python Programming,Books,4,37.99,0.05,151.96,Central,Cash on Delivery,VIP +ORD-00387,2024-12-26,Denim Jeans,Clothing,5,39.99,0.2,199.95,North,Credit Card,VIP +ORD-00477,2024-12-26,Dumbbell Set,Sports,2,69.99,0,139.98,West,Credit Card,Premium +ORD-00590,2024-12-26,Jump Rope,Sports,4,11.04,0.15,44.16,East,PayPal,VIP +ORD-00631,2024-12-26,Laptop Pro 15,Electronics,6,1299.99,0,7799.94,Central,Bank Transfer,VIP +ORD-00128,2024-12-27,Winter Jacket,Clothing,5,116.99,0.1,584.95,East,Cash on Delivery,Premium +ORD-00873,2024-12-27,Resistance Bands Set,Sports,7,16.99,0.15,118.93,West,Cash on Delivery,Premium +ORD-00528,2024-12-28,Water Bottle,Home & Kitchen,1,13.49,0.1,13.49,Central,PayPal,Premium +ORD-00595,2024-12-28,Dumbbell Set,Sports,2,55.99,0.2,111.98,East,PayPal,Standard +ORD-00513,2024-12-29,Resistance Bands Set,Sports,2,19.99,0,39.98,Central,Cash on Delivery,Standard +ORD-00770,2024-12-30,Wool Scarf,Clothing,8,22.49,0.1,179.92,North,Credit Card,Standard +ORD-00843,2024-12-30,Denim Jeans,Clothing,2,42.49,0.15,84.98,Central,Cash on Delivery,Standard +ORD-00225,2024-12-31,Cotton T-Shirt,Clothing,6,19.99,0,119.94,East,PayPal,Standard +ORD-00250,2024-12-31,Cotton T-Shirt,Clothing,8,19.99,0,159.92,West,Credit Card,VIP +ORD-00467,2024-12-31,Yoga Mat,Sports,7,23.99,0.2,167.93,East,Cash on Delivery,VIP +ORD-00996,2024-12-31,Winter Jacket,Clothing,8,129.99,0,1039.92,Central,Bank Transfer,VIP From ca60af6f323e72dbde82ab2b1227a956d66907ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9F=B3=E4=BD=9A?= Date: Mon, 20 Jul 2026 17:53:01 +0800 Subject: [PATCH 2/3] docs: address tutorial review feedback --- tutorials/01_hello_agentscope/README.md | 2 +- tutorials/02_message_and_event/README.md | 36 +++++++++++++++++++++++- tutorials/06_skills/README.md | 2 +- tutorials/13_agent_service/README.md | 2 +- tutorials/14_scheduling/README.md | 2 +- tutorials/16_complete_datamuse/README.md | 2 +- tutorials/README.md | 8 ++++-- 7 files changed, 45 insertions(+), 9 deletions(-) diff --git a/tutorials/01_hello_agentscope/README.md b/tutorials/01_hello_agentscope/README.md index db11b02..6732649 100644 --- a/tutorials/01_hello_agentscope/README.md +++ b/tutorials/01_hello_agentscope/README.md @@ -12,7 +12,7 @@ ## 前置要求 - Python 3.11+ -- 安装 AgentScope:`pip install agentscope` +- 安装 AgentScope:`pip install agentscope==2.0.4` - 至少一个 LLM API Key(DashScope / OpenAI / Ollama 等) ## 核心概念 diff --git a/tutorials/02_message_and_event/README.md b/tutorials/02_message_and_event/README.md index 1cd03dd..7f89ec0 100644 --- a/tutorials/02_message_and_event/README.md +++ b/tutorials/02_message_and_event/README.md @@ -59,7 +59,8 @@ Event 是消息的流式视图。Agent 执行过程中会产生一系列事件 **核心原则**:一次 `reply` 调用 = 一条助手消息 = 一个事件流 -事件遵循 **start → delta → end** 的生命周期模式: +多数流式内容块遵循 **start → delta → end** 的生命周期模式; +`HintBlockEvent` 是一次性事件,不拆分为 start / delta / end: ``` ReplyStartEvent @@ -71,6 +72,8 @@ ReplyStartEvent │ ├── ToolResultStartEvent → ToolResultTextDeltaEvent... → ToolResultEndEvent │ + ├── HintBlockEvent(一次性提示) + │ └── (下一轮推理-执行循环...) ReplyEndEvent ``` @@ -86,6 +89,37 @@ async for event in agent.reply_stream(user_msg): # msg 现在包含完整的助手回复 ``` +浏览器或 Node.js 客户端可以使用 TypeScript 包 +`@agentscope-ai/agentscope` 完成同样的重建: + +```bash +npm install @agentscope-ai/agentscope@0.0.13 +``` + +```typescript +import { EventType } from "@agentscope-ai/agentscope/event"; +import { + appendEvent, + AssistantMsg, +} from "@agentscope-ai/agentscope/message"; + +let reply; + +for await (const event of eventStream) { + if (event.type === EventType.REPLY_START) { + reply = AssistantMsg({ + id: event.reply_id, + name: event.name, + content: [], + }); + } + + if (reply) { + appendEvent(reply, event); + } +} +``` + ## 示例:探索 DataMuse 的消息和事件 本期示例让 DataMuse 回答数据分析问题,我们在客户端侧: diff --git a/tutorials/06_skills/README.md b/tutorials/06_skills/README.md index a1c3117..6c6107f 100644 --- a/tutorials/06_skills/README.md +++ b/tutorials/06_skills/README.md @@ -1,6 +1,6 @@ # Tutorial 06: Skill — 用 Markdown 扩展 Agent 能力 -> **什么时候需要这个?** 某个任务需要"按一套套路组合多个工具"(比如:先采样数据 → 决定图表类型 → matplotlib 画图 → 保存)。你想把这套套路用 Markdown 沉淀下来,让 Agent 按需加载,而不是把它塞进 system prompt 让模型每次重新摸索。 +> **什么时候需要这个?** 某个任务需要按照固定规则、顺序或 SOP 组合多个工具(比如:先采样数据 → 决定图表类型 → matplotlib 画图 → 保存)。你想把这套流程用 Markdown 沉淀下来,让 Agent 按需加载,而不是把它塞进 system prompt 让模型每次重新摸索。 ## 本章基于前序章节 diff --git a/tutorials/13_agent_service/README.md b/tutorials/13_agent_service/README.md index 94f9f25..05a1512 100644 --- a/tutorials/13_agent_service/README.md +++ b/tutorials/13_agent_service/README.md @@ -26,7 +26,7 @@ ## 前置要求 - 完成 Tutorial 12 -- 安装服务依赖:`pip install "agentscope[service]" fakeredis httpx` +- 安装服务依赖:`pip install "agentscope[service]==2.0.4" fakeredis httpx` - Redis 服务可选;本教程默认用 `fakeredis` 跑内存模式 - 如需体验 Web UI:Node.js 20+ 与 `pnpm` diff --git a/tutorials/14_scheduling/README.md b/tutorials/14_scheduling/README.md index 698ac2d..6a90fc0 100644 --- a/tutorials/14_scheduling/README.md +++ b/tutorials/14_scheduling/README.md @@ -21,7 +21,7 @@ - 完成 Tutorial 13 - Agent Service 正常运行 -- `pip install "agentscope[service]" fakeredis httpx` +- `pip install "agentscope[service]==2.0.4" fakeredis httpx` ## 核心概念 diff --git a/tutorials/16_complete_datamuse/README.md b/tutorials/16_complete_datamuse/README.md index 37a766d..cfc94aa 100644 --- a/tutorials/16_complete_datamuse/README.md +++ b/tutorials/16_complete_datamuse/README.md @@ -31,7 +31,7 @@ T13-T15 是同一业务案例的另外两条扩展路径,不是本章必须嵌 - 建议完成 Tutorial 01-12 - T13-T15 可选:用于理解服务化、调度和团队化扩展 - Python 3.12 -- 安装 AgentScope:`pip install agentscope` +- 安装 AgentScope:`pip install agentscope==2.0.4` - 准备好 `tutorials/data/sales_data.csv` - 设置 `DASHSCOPE_API_KEY` 或 `OPENAI_API_KEY` diff --git a/tutorials/README.md b/tutorials/README.md index f2880d4..6d99d20 100644 --- a/tutorials/README.md +++ b/tutorials/README.md @@ -23,9 +23,11 @@ ## 前置要求 - Python 3.12 -- `pip install agentscope` +- AgentScope 2.0.4(`pip install agentscope==2.0.4`) - 至少一个 LLM API Key(DashScope / OpenAI / Ollama) +本教程按 AgentScope 2.0.4 编写;使用其他版本时,API 可能存在差异。 + ## 教程列表 ### Phase 1: 基础篇 @@ -90,7 +92,7 @@ python generate_sales_data.py # 准备环境 conda create -n agentscope-tutorial-py312 python=3.12 -y conda activate agentscope-tutorial-py312 -pip install agentscope +pip install agentscope==2.0.4 # 设置 API Key export DASHSCOPE_API_KEY="your-key" @@ -108,7 +110,7 @@ python main.py # 如果还没有准备环境,先执行: conda create -n agentscope-tutorial-py312 python=3.12 -y conda activate agentscope-tutorial-py312 -pip install agentscope +pip install agentscope==2.0.4 export DASHSCOPE_API_KEY="your-key" cd tutorials/16_complete_datamuse From 984052a72f8bea3584eab8a98ecece7454566304 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9F=B3=E4=BD=9A?= Date: Mon, 20 Jul 2026 19:40:04 +0800 Subject: [PATCH 3/3] docs: refine module guide examples --- tutorials/MODULE_GUIDE.md | 96 +++++++++++++++++++++++++-------------- 1 file changed, 61 insertions(+), 35 deletions(-) diff --git a/tutorials/MODULE_GUIDE.md b/tutorials/MODULE_GUIDE.md index 0a3ea17..2148622 100644 --- a/tutorials/MODULE_GUIDE.md +++ b/tutorials/MODULE_GUIDE.md @@ -7,10 +7,16 @@ DataMuse 销售分析应用。 ## 环境准备 -在本仓库中运行示例时使用 Python 3.12 环境: +本文固定使用以下环境,避免 Python 或 AgentScope 版本差异影响示例: + +- Python 3.12 +- AgentScope 2.0.4 +- Node.js 20+(通过 `npx` 启动示例中的 MCP Server) ```bash +conda create -n agentscope-tutorial-py312 python=3.12 -y conda activate agentscope-tutorial-py312 +pip install "agentscope[service]==2.0.4" export DASHSCOPE_API_KEY="your-api-key" ``` @@ -52,14 +58,14 @@ flowchart LR |---|---|---|---| | Credential | `agentscope.credential` | 保存并校验模型凭证 | 接入任意外部模型或语音服务时 | | Model | `agentscope.model` | 调用 LLM,返回统一响应 | 所有 Agent 应用 | -| Formatter | `agentscope.formatter` | 在 AgentScope `Msg` 与供应商消息格式间转换 | 自定义模型协议或多 Agent 消息格式时 | +| Formatter | `agentscope.formatter` | 在 AgentScope `Msg` 与供应商消息格式间转换 | 一个对话中存在多个实体(Agent 或用户)时 | | Message | `agentscope.message` | 表示用户、助手、系统消息及多模态内容块 | 所有输入、上下文和最终输出 | | Event | `agentscope.event` | 暴露推理、文本、工具、审批等增量事件 | 流式 UI、服务端 SSE、HITL | | Agent | `agentscope.agent` | 执行 reasoning-acting 循环 | 所有 Agent 应用 | | State | `agentscope.state` | 保存会话、上下文、权限、任务和 middleware 状态 | 多轮对话、恢复执行、持久化 | | Tool | `agentscope.tool` | 把 Python 或系统能力暴露给 Agent | Agent 需要读取、计算或执行操作时 | -| MCP | `agentscope.mcp` | 连接标准化外部工具服务器 | 能力由独立服务提供或需要跨框架复用时 | -| Skill | `agentscope.skill` | 按需加载可复用的操作指南 | 流程复杂但不需要新增可执行接口时 | +| MCP | `agentscope.mcp` | 连接标准化外部工具服务器 | 需要接入 MCP Server 提供的工具或服务时 | +| Skill | `agentscope.skill` | 按需加载可复用的操作指南 | 需要按 Skill 中的规则、顺序或 SOP 组合工具时 | | Permission | `agentscope.permission` | 对每次工具调用做 ALLOW、ASK 或 DENY 决策 | Agent 能产生真实副作用时 | | Middleware | `agentscope.middleware` | 横切扩展 Agent 生命周期 | tracing、RAG、记忆、预算、TTS、审计 | | Workspace | `agentscope.workspace` | 提供隔离工作目录、工具、MCP、Skill 和 Offloader | 文件操作、沙箱执行、服务化隔离 | @@ -368,8 +374,8 @@ toolkit = Toolkit( ### 是什么 -MCP 把外部工具服务器转换成 AgentScope Tool。它适合连接文件系统、数据库、 -浏览器或其他独立服务,并保持能力协议与 Agent 实现解耦。 +MCP 把外部工具服务器转换成 AgentScope Tool。它适合连接数据库、浏览器、 +知识图谱或其他独立服务,并保持能力协议与 Agent 实现解耦。 ### 什么时候用 @@ -386,27 +392,34 @@ from agentscope.mcp import MCPClient, StdioMCPConfig from agentscope.tool import Toolkit -filesystem = MCPClient( - name="filesystem", +memory = MCPClient( + name="memory", is_stateful=True, mcp_config=StdioMCPConfig( command="npx", args=[ "-y", - "@modelcontextprotocol/server-filesystem", - "/absolute/path/to/data", + "@modelcontextprotocol/server-memory", ], ), - enable_tools=["list_directory", "read_file"], + enable_tools=[ + "create_entities", + "add_observations", + "search_nodes", + ], ) -await filesystem.connect() +await memory.connect() try: - toolkit = Toolkit(mcps=[filesystem]) + toolkit = Toolkit(mcps=[memory]) finally: - await filesystem.close() + await memory.close() ``` +这里使用 Memory MCP 来保存和检索结构化信息。在后面的 Workspace 场景中, +`basic` 工具已经提供 `Read`、`Write`、`Edit`、`Glob` 和 `Grep`,因此不再额外 +接入功能重复的 filesystem MCP。 + HTTP MCP 使用 `HttpMCPConfig(url=..., headers=...)`。MCP 工具名会被命名空间化为 `mcp__{server_name}__{tool_name}`,避免不同 Server 的同名工具冲突。 @@ -699,7 +712,7 @@ from agentscope.workspace import LocalWorkspace async with LocalWorkspace( workdir="./workspace", - default_mcps=[filesystem_mcp], + default_mcps=[memory_mcp], skill_paths=["./skills/report_writer"], ) as workspace: workspace_tools = await workspace.list_tools() @@ -879,7 +892,7 @@ app = create_app( message_bus=RedisMessageBus(host="localhost", port=6379), workspace_manager=LocalWorkspaceManager( basedir="./workspaces", - default_mcps=[filesystem_mcp], + default_mcps=[memory_mcp], skill_paths=["./skills/report_writer"], ), extra_agent_tools=tool_factory, @@ -1091,20 +1104,25 @@ SALES_CSV = DATA_DIR / "sales_data.csv" SKILL_DIR = ROOT / "skills" / "report_writer" WORKSPACE_DIR = ROOT / "workspace" REPORTS_DIR = WORKSPACE_DIR / "reports" +MEMORY_FILE = WORKSPACE_DIR / "memory.jsonl" -filesystem_mcp = MCPClient( - name="filesystem", +memory_mcp = MCPClient( + name="memory", is_stateful=True, mcp_config=StdioMCPConfig( command="npx", args=[ "-y", - "@modelcontextprotocol/server-filesystem", - str(DATA_DIR), + "@modelcontextprotocol/server-memory", ], + env={"MEMORY_FILE_PATH": str(MEMORY_FILE)}, ), - enable_tools=["list_directory", "read_file"], + enable_tools=[ + "create_entities", + "add_observations", + "search_nodes", + ], ) @@ -1267,7 +1285,7 @@ async def main() -> None: async with LocalWorkspace( workdir=str(WORKSPACE_DIR), - default_mcps=[filesystem_mcp], + default_mcps=[memory_mcp], skill_paths=[str(SKILL_DIR)], ) as workspace: workspace_tools = await workspace.list_tools() @@ -1279,7 +1297,9 @@ async def main() -> None: name="DataMuse", system_prompt=( "You are DataMuse, a careful sales analyst. " - "First inspect the data directory with filesystem MCP. " + "Search the memory MCP for reporting preferences before " + "analysis, and store any new preference the user asks you " + "to remember. " "Use SalesSummary for every numeric claim. Before writing, " "load report_writer with the Skill tool, then call " "WriteReport. Mention the saved path in the final answer.\n" @@ -1313,8 +1333,9 @@ async def main() -> None: task = UserMsg( name="user", content=( - "Inspect the data directory, compare revenue by category " - "and region, then write datamuse_report.md." + "Remember that my reports should lead with category " + "performance. Compare revenue by category and region, " + "then write datamuse_report.md." ), ) await process_events(agent, agent.reply_stream(task)) @@ -1332,9 +1353,9 @@ cd datamuse_demo python main.py ``` -首次运行 filesystem MCP 时,`npx` 可能需要下载对应 Server 包。运行过程中 -`SalesSummary` 会直接执行;`WriteReport` 会产生确认事件,批准后才会在 -`workspace/reports/` 写入 Markdown 文件。 +首次运行 Memory MCP 时,`npx` 可能需要下载对应 Server 包。运行过程中, +DataMuse 会通过 MCP 保存报告偏好,`SalesSummary` 会计算指标;`WriteReport` +会产生确认事件,批准后才会在 `workspace/reports/` 写入 Markdown 文件。 ### 将同一组能力装入 Agent Service @@ -1352,23 +1373,28 @@ from agentscope.app.storage import RedisStorage from agentscope.app.workspace_manager import LocalWorkspaceManager from agentscope.mcp import MCPClient, StdioMCPConfig -from main import DATA_DIR, SKILL_DIR, SalesSummary, WriteReport +from main import SKILL_DIR, SalesSummary, WriteReport SERVICE_WORKSPACES = Path("./service_workspaces").resolve() +SERVICE_MEMORY_FILE = SERVICE_WORKSPACES / "memory.jsonl" -filesystem_mcp = MCPClient( - name="filesystem", +memory_mcp = MCPClient( + name="memory", is_stateful=True, mcp_config=StdioMCPConfig( command="npx", args=[ "-y", - "@modelcontextprotocol/server-filesystem", - str(DATA_DIR), + "@modelcontextprotocol/server-memory", ], + env={"MEMORY_FILE_PATH": str(SERVICE_MEMORY_FILE)}, ), - enable_tools=["list_directory", "read_file"], + enable_tools=[ + "create_entities", + "add_observations", + "search_nodes", + ], ) @@ -1383,7 +1409,7 @@ app = create_app( message_bus=RedisMessageBus(host="localhost", port=6379), workspace_manager=LocalWorkspaceManager( basedir=str(SERVICE_WORKSPACES), - default_mcps=[filesystem_mcp], + default_mcps=[memory_mcp], skill_paths=[str(SKILL_DIR)], ), extra_agent_tools=datamuse_tools,