commit c3bf08ac8d6726e0e38fc0f6083f2ae178278082 Author: wehub-resource-sync Date: Mon Jul 13 12:39:27 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.gemini/styleguide.md b/.gemini/styleguide.md new file mode 100644 index 0000000..5462e0f --- /dev/null +++ b/.gemini/styleguide.md @@ -0,0 +1,96 @@ +# AgentScope Code Review Guide + +You should conduct a strict code review. Each requirement is labeled with priority: +- **[MUST]** must be satisfied or PR will be rejected +- **[SHOULD]** strongly recommended +- **[MAY]** optional suggestion + +## 1. Code Quality + +### [MUST] Lazy Loading +- Third-party library dependencies should be imported at the point of use, avoid centralized imports at file top + - The `Third-party library` refers to libraries not included in the `dependencies` variable in `pyproject.toml`. +- For base class imports, use factory pattern: +```python +def get_xxx_cls() -> "MyClass": + from xxx import BaseClass + class MyClass(BaseClass): ... + return MyClass +``` + +### [SHOULD] Code Conciseness +After understanding the code intent, check if it can be optimized: +- Avoid unnecessary temporary variables +- Merge duplicate code blocks +- Prioritize reusing existing utility functions + +### [MUST] Encapsulation Standards +- All Python files under `src/agentscope` should be named with `_` prefix, and exposure controlled through `__init__.py` +- Classes and functions used internally by the framework that don't need to be exposed to users must be named with `_` prefix + +## 2. [MUST] Code Security +- Prohibit hardcoding API keys/tokens/passwords +- Use environment variables or configuration files for management +- Check for debug information and temporary credentials +- Check for injection attack risks (SQL/command/code injection, etc.) + +## 3. [MUST] Testing & Dependencies +- New features must include unit tests +- New dependencies need to be added to the corresponding section in `pyproject.toml` +- Dependencies for non-core scenarios should not be added to the minimal dependency list + +## 4. Code Standards + +### [MUST] Comment Standards +- **Use English** +- All classes/methods must have complete docstrings, strictly following the template: +```python +def func(a: str, b: int | None = None) -> str: + """{description} + + Args: + a (`str`): + The argument a + b (`int | None`, optional): + The argument b + + Returns: + `str`: + The return str + """ +``` +- Use reStructuredText syntax for special content: +```python +class MyClass: + """xxx + + `Example link `_ + + .. note:: Example note + + .. tip:: Example tip + + .. important:: Example important info + + .. code-block:: python + + def hello_world(): + print("Hello world!") + + """ +``` + +### [MUST] Pre-commit Checks +- **Strict review**: In most cases, code should be modified rather than skipping checks +- **File-level check skipping is prohibited** +- Only allowed skip: agent class system prompt parameters (to avoid `\n` formatting issues) + +--- + +## 5. Git Standards + +### [MUST] PR Title +- Follow Conventional Commits +- Must use prefixes: `feat/fix/docs/ci/refactor/test`, etc. +- Format: `feat(scope): description` +- Example: `feat(memory): add redis cache support` \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..bd69010 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,67 @@ +name: Bug Report +description: Report a bug to help us improve AgentScope. +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + **AgentScope is an open-source project. To involve a broader community, we recommend asking your questions in English.** + + - type: checkboxes + id: prerequisites + attributes: + label: Prerequisites + description: Please confirm the following before submitting. + options: + - label: I have searched the existing [issues](https://github.com/agentscope-ai/agentscope/issues) and [discussions](https://github.com/agentscope-ai/agentscope/discussions), and this is not a duplicate. + required: true + - label: This is a bug, not a usage question. (For questions, please use [Discussions](https://github.com/agentscope-ai/agentscope/discussions/new?category=general) instead.) + required: true + + - type: textarea + id: description + attributes: + label: Background / Description + description: A clear and concise description of the bug, including what you were trying to do and the expected behavior. + placeholder: What were you trying to do, what did you expect, and what actually happened? + validations: + required: true + + - type: textarea + id: error + attributes: + label: Error Messages + description: Detailed error messages, stack traces, or logs. + render: shell + validations: + required: true + + - type: textarea + id: reproduce + attributes: + label: Steps to Reproduce + description: Provide a minimal reproducible example (code + commands) so we can reproduce the bug. + placeholder: | + 1. Code: + + ```python + ... + ``` + + 2. Run: `python ...` + 3. See error + validations: + required: true + + - type: textarea + id: environment + attributes: + label: Environment + description: Run `import agentscope; print(agentscope.__version__)` to get the AgentScope version. + value: | + - AgentScope Version: + - Python Version: + - OS: + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..0ec688e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Feature Request / Idea + url: https://github.com/agentscope-ai/agentscope/discussions/new?category=ideas + about: Suggest a new feature or share an idea. Maintainers will turn accepted proposals into roadmap issues. + - name: Question / Usage Help + url: https://github.com/agentscope-ai/agentscope/discussions/new?category=general + about: Ask a usage question or start a general discussion. Please do NOT open an issue for questions. \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..b74281d --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,25 @@ +## PR Title Format + +Please ensure your PR title follows the Conventional Commits format: +- Format: `(): ` +- Example: `feat(memory): add redis cache support` +- Allowed types: `feat`, `fix`, `docs`, `ci`, `refactor`, `test`, `chore`, `perf`, `style`, `build`, `revert` +- Description should start with a lowercase letter + +## AgentScope Version + +[The version of AgentScope you are working on, e.g. `import agentscope; print(agentscope.__version__)`] + +## Description + +[Please describe the background, purpose, changes made, and how to test this PR] + +## Checklist + +Please check the following items before code is ready to be reviewed. + +- [ ] An issue has been created for this PR +- [ ] I have read the [CONTRIBUTING.md](https://github.com/agentscope-ai/agentscope/blob/main/CONTRIBUTING.md) +- [ ] Docstrings are in Google style +- [ ] Related documentation has been updated (e.g. links, examples, etc.) in [documentation repository](https://github.com/agentscope-ai/docs) +- [ ] Code is ready for review \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..5a8062e --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,97 @@ +# AgentScope Code Review Guide + +You should conduct a strict code review. Each requirement is labeled with priority: +- **[MUST]** must be satisfied or PR will be rejected +- **[SHOULD]** strongly recommended +- **[MAY]** optional suggestion + +## 1. Code Quality + +### [MUST] Lazy Loading +- **Third-party library** dependencies should be imported at the point of use, avoid centralized imports at file top + - The `Third-party library` refers to libraries not included in the `dependencies` variable in `pyproject.toml`. +- For base class imports, use factory pattern: +```python +def get_xxx_cls() -> "MyClass": + from xxx import BaseClass + class MyClass(BaseClass): ... + return MyClass +``` + +### [SHOULD] Code Conciseness +After understanding the code intent, check if it can be optimized: +- Avoid unnecessary temporary variables +- Merge duplicate code blocks +- Prioritize reusing existing utility functions + +### [MUST] Encapsulation Standards +- All Python files under `src/agentscope` should be named with `_` prefix, and exposure controlled through `__init__.py` +- Classes and functions used internally by the framework that don't need to be exposed to users must be named with `_` prefix + +## 2. [MUST] Code Security +- Prohibit hardcoding API keys/tokens/passwords +- Use environment variables or configuration files for management +- Check for debug information and temporary credentials +- Check for injection attack risks (SQL/command/code injection, etc.) + +## 3. [MUST] Testing & Dependencies +- New features must include unit tests +- New dependencies need to be added to the corresponding section in `pyproject.toml` +- Dependencies for non-core scenarios should not be added to the minimal dependency list +- Test assertions MUST compare the entire data structure rather than asserting individual fields, so reviewers can quickly understand the test intent. For random or non-deterministic fields, use `AnyString` and `AnyValue` from `tests/utils.py` for matching + +## 4. Code Standards + +### [MUST] Comment Standards +- **Use English** +- All classes/methods must have complete docstrings, strictly following the template: +```python +def func(a: str, b: int | None = None) -> str: + """{description} + + Args: + a (`str`): + The argument a + b (`int | None`, optional): + The argument b + + Returns: + `str`: + The return str + """ +``` +- Use reStructuredText syntax for special content: +```python +class MyClass: + """xxx + + `Example link `_ + + .. note:: Example note + + .. tip:: Example tip + + .. important:: Example important info + + .. code-block:: python + + def hello_world(): + print("Hello world!") + + """ +``` + +### [MUST] Pre-commit Checks +- **Strict review**: In most cases, code should be modified rather than skipping checks +- **File-level check skipping is prohibited** +- Only allowed skip: agent class system prompt parameters (to avoid `\n` formatting issues) + +--- + +## 5. Git Standards + +### [MUST] PR Title +- Follow Conventional Commits +- Must use prefixes: `feat/fix/docs/ci/refactor/test`, etc. +- Format: `feat(scope): description` +- Example: `feat(memory): add redis cache support` \ No newline at end of file diff --git a/.github/scripts/update_news.py b/.github/scripts/update_news.py new file mode 100755 index 0000000..9dccc0e --- /dev/null +++ b/.github/scripts/update_news.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Script to automatically update NEWS section in README files. +Reads the first 10 news items from docs/NEWS.md and updates README.md and +README_zh.md. +""" + +from pathlib import Path + + +def read_news_items(news_file: Path, max_items: int = 10) -> list[str]: + """ + Read news items from NEWS.md file. + + Args: + news_file (`Path`): + Path to the NEWS.md file + max_items (`int`, optional): + Maximum number of items to read + + Returns: + `list[str]`: + List of news items + """ + with open(news_file, "r", encoding="utf-8") as f: + content = f.read() + + # Split by lines that start with "- **[" + lines = content.strip().split("\n") + news_items = [] + + for line in lines: + if line.strip().startswith("- **["): + news_items.append(line) + if len(news_items) >= max_items: + break + + return news_items + + +def update_readme( + readme_file: Path, + news_items: list[str], +) -> None: + """ + Update the NEWS section in README file using HTML comment markers. + + Args: + readme_file (`Path`): + Path to the README file + news_items (`list[str]`): + List of news items to insert + """ + with open(readme_file, "r", encoding="utf-8") as f: + content = f.read() + + # Use HTML comment markers to identify the NEWS section + begin_marker = "" + end_marker = "" + + if begin_marker not in content or end_marker not in content: + print(f"⚠️ NEWS markers not found in {readme_file.name}") + print( + f" Please add '{begin_marker}' and '{end_marker}' to mark the " + f"NEWS section", + ) + return + + # Find positions of markers + begin_pos = content.find(begin_marker) + end_pos = content.find(end_marker) + + if begin_pos == -1 or end_pos == -1 or begin_pos >= end_pos: + print(f"❌ Invalid NEWS markers in {readme_file.name}") + return + + # Create new NEWS content + news_content = "\n".join(news_items) + + # Replace content between markers + new_content = ( + content[: begin_pos + len(begin_marker)] + + "\n" + + news_content + + "\n" + + content[end_pos:] + ) + + with open(readme_file, "w", encoding="utf-8") as f: + f.write(new_content) + + print(f"✅ Updated {readme_file.name}") + + +def main() -> None: + """Main function to update NEWS in README files.""" + # Define paths + repo_root = Path(__file__).parent.parent.parent + news_file_en = repo_root / "docs" / "NEWS.md" + news_file_zh = repo_root / "docs" / "NEWS_zh.md" + readme_en = repo_root / "README.md" + readme_zh = repo_root / "README_zh.md" + + # Update English README from NEWS.md + if news_file_en.exists(): + print(f"📖 Reading news items from {news_file_en}") + news_items_en = read_news_items(news_file_en, max_items=10) + print(f"📰 Found {len(news_items_en)} English news items") + + if news_items_en and readme_en.exists(): + print(f"📝 Updating {readme_en.name}...") + update_readme(readme_en, news_items_en) + elif not news_items_en: + print("⚠️ No English news items found") + else: + print(f"⚠️ {readme_en} not found") + else: + print(f"❌ NEWS.md not found at {news_file_en}") + + # Update Chinese README from NEWS_zh.md + if news_file_zh.exists() and news_file_zh.stat().st_size > 0: + print(f"📖 Reading news items from {news_file_zh}") + news_items_zh = read_news_items(news_file_zh, max_items=10) + print(f"📰 Found {len(news_items_zh)} Chinese news items") + + if news_items_zh and readme_zh.exists(): + print(f"📝 Updating {readme_zh.name}...") + update_readme(readme_zh, news_items_zh) + elif not news_items_zh: + print("⚠️ No Chinese news items found") + else: + print(f"⚠️ {readme_zh} not found") + else: + print( + f"⚠️ NEWS_zh.md not found or empty at {news_file_zh}, " + f"using English news for Chinese README", + ) + # Fallback: use English news for Chinese README if NEWS_zh.md + # doesn't exist + if news_file_en.exists() and readme_zh.exists(): + print(f"📖 Reading news items from {news_file_en} (fallback)") + news_items = read_news_items(news_file_en, max_items=10) + if news_items: + print(f"📝 Updating {readme_zh.name} with English news...") + update_readme(readme_zh, news_items) + + print("✨ All done!") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/github-monitor.yml b/.github/workflows/github-monitor.yml new file mode 100644 index 0000000..777c721 --- /dev/null +++ b/.github/workflows/github-monitor.yml @@ -0,0 +1,63 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Template: copy this file to the MONITORED repo at +# .github/workflows/github-monitor.yml +# +# Required secrets in the monitored repo: +# - STORAGE_REPO_TOKEN Fine-grained PAT with Contents: Read & Write on +# the storage repo (e.g. DavdGao/agentscope-monitor). +# - DINGTALK_WEBHOOK Dingtalk bot webhook URL. +# - DINGTALK_SECRET (optional) Dingtalk bot signing secret. +# ───────────────────────────────────────────────────────────────────────────── +name: GitHub Monitor + +on: + issues: + types: [opened, closed, reopened] + issue_comment: + types: [created] + pull_request_target: + # synchronize is silent (stored only) — see SILENT_REALTIME_EVENTS in scripts/config.py. + # It's needed for the daily "PRs with new commits to re-review" section. + types: [opened, closed, reopened, synchronize, review_requested] + pull_request_review: + types: [submitted] + pull_request_review_comment: + types: [created] + discussion: + types: [created, answered] + discussion_comment: + types: [created] + +permissions: + contents: read + +jobs: + notify: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout storage repo (notification scripts) + uses: actions/checkout@v4 + with: + repository: DavdGao/agentscope-monitor + token: ${{ secrets.STORAGE_REPO_TOKEN }} + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: pip + + - run: pip install -r requirements.txt + + - name: Send notification + env: + EVENT_NAME: ${{ github.event_name }} + EVENT_PAYLOAD: ${{ toJson(github.event) }} + REPO_NAME: ${{ github.repository }} + RUN_ID: ${{ github.run_id }} + RUN_ATTEMPT: ${{ github.run_attempt }} + STORAGE_REPO: DavdGao/agentscope-monitor + STORAGE_REPO_TOKEN: ${{ secrets.STORAGE_REPO_TOKEN }} + DINGTALK_WEBHOOK: ${{ secrets.DINGTALK_WEBHOOK }} + DINGTALK_SECRET: ${{ secrets.DINGTALK_SECRET }} + run: python -m scripts.notify diff --git a/.github/workflows/k8s-workspace.yml b/.github/workflows/k8s-workspace.yml new file mode 100644 index 0000000..ec091a5 --- /dev/null +++ b/.github/workflows/k8s-workspace.yml @@ -0,0 +1,81 @@ +name: K8s Workspace Integration Tests + +# Runs the K8sWorkspace integration suite against a kind cluster. +# The regular `unittest.yml` job also imports this test file, but every +# test inside is guarded by `skipUnless(_KIND_AVAILABLE, ...)`, so it is +# a no-op there. This workflow is where the tests actually execute. + +on: + push: + paths: + - "src/agentscope/workspace/**" + - "tests/workspace_k8s_test.py" + - "tests/docker/k8s_workspace_test.Dockerfile" + - ".github/workflows/k8s-workspace.yml" + pull_request: + paths: + - "src/agentscope/workspace/**" + - "tests/workspace_k8s_test.py" + - "tests/docker/k8s_workspace_test.Dockerfile" + - ".github/workflows/k8s-workspace.yml" + +jobs: + k8s-workspace-tests: + if: false == contains(github.event.pull_request.title, 'WIP') + # Ubuntu only: kind requires linux + docker; the test module's + # skipUnless would skip everything on Windows/macOS anyway. + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: '3.11' + + - name: Create kind cluster + uses: helm/kind-action@v1 + with: + cluster_name: agentscope-test + wait: 120s + + - name: Build K8s workspace test image + run: | + docker build \ + -f tests/docker/k8s_workspace_test.Dockerfile \ + -t agentscope-k8s-test:ci . + + - name: Load image into kind + run: kind load docker-image agentscope-k8s-test:ci --name agentscope-test + + - name: Create test namespace + run: kubectl create namespace agentscope + + - name: Install project + run: | + uv pip install -q -e .[dev] + uv pip install pytest + + - name: Run K8s workspace tests + env: + K8S_TEST_IMAGE: agentscope-k8s-test:ci + K8S_TEST_NAMESPACE: agentscope + run: pytest tests/workspace_k8s_test.py -v + + - name: Dump cluster diagnostics on failure + if: failure() + run: | + echo "── pods ──" + kubectl get pods -A -o wide || true + echo "── pvcs ──" + kubectl get pvc -A || true + echo "── events ──" + kubectl get events -A --sort-by=.lastTimestamp | tail -n 50 || true + echo "── describe agentscope pods ──" + for p in $(kubectl -n agentscope get pods -o name 2>/dev/null); do + echo "── $p ──" + kubectl -n agentscope describe "$p" || true + kubectl -n agentscope logs "$p" --all-containers=true --tail=200 || true + done + diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml new file mode 100644 index 0000000..e0f7f40 --- /dev/null +++ b/.github/workflows/pr-title-check.yml @@ -0,0 +1,49 @@ +name: PR Title Check + +on: + pull_request: + branches: + - main + types: [opened, edited, synchronize, reopened] + +jobs: + check-pr-title: + runs-on: ubuntu-latest + steps: + - name: Check PR Title Format + uses: amannn/action-semantic-pull-request@v6.1.1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + # Configure allowed types based on your requirements + types: | + feat + fix + docs + ci + refactor + test + chore + perf + style + build + revert + # Require a scope (the part in parentheses) + requireScope: false + # Scope pattern: only lowercase letters, numbers, hyphens, and underscores allowed + scopePattern: ^[a-z0-9_-]+$ + scopePatternError: | + The scope (text in parentheses) must contain only lowercase letters, numbers, hyphens, and underscores. + Example: "feat(memory): add redis cache support" + Invalid: "feat(Memory): ..." or "feat(MEMORY): ..." + # Subject (description) must not be empty and must be lowercase + subjectPattern: ^(?![A-Z]).+$ + subjectPatternError: | + The subject (description after colon) must start with a lowercase letter. + Example: "feat(memory): add redis cache support" + # Validate the entire PR title against the Conventional Commits spec + validateSingleCommit: false + # Ignore merge commits + ignoreLabels: | + ignore-semantic-pull-request + diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml new file mode 100644 index 0000000..475e7f3 --- /dev/null +++ b/.github/workflows/pre-commit.yml @@ -0,0 +1,35 @@ +name: Pre-commit + +on: [push, pull_request] + +jobs: + run: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: True + matrix: + os: [ubuntu-latest] + env: + OS: ${{ matrix.os }} + PYTHON: '3.11' + steps: + - uses: actions/checkout@master + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: '3.11' + - name: Install AgentScope + run: | + uv pip install -q -e .[dev] + - name: Install pre-commit + run: | + pre-commit install + - name: Pre-commit starts + run: | + pre-commit run --all-files > pre-commit.log 2>&1 || true + cat pre-commit.log + if grep -q Failed pre-commit.log; then + echo -e "\e[41m [**FAIL**] Please install pre-commit and format your code first. \e[0m" + exit 1 + fi + echo -e "\e[46m ********************************Passed******************************** \e[0m" diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml new file mode 100644 index 0000000..58f4f08 --- /dev/null +++ b/.github/workflows/publish-pypi.yml @@ -0,0 +1,43 @@ +# This workflow will upload a Python Package using Twine when a release is created +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Publish PyPi Package + +on: + workflow_dispatch: + release: + types: [published] + +permissions: + contents: read + +jobs: + deploy: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: '3.11' + - name: Install dependencies + run: | + uv pip install build + - name: Build package + run: python -m build + - name: Test installation + run: | + uv pip install dist/*.whl + python -c "import agentscope; print(agentscope.__version__)" + - name: Publish package + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..f9fe159 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,35 @@ +# This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time. +# +# You can adjust the behavior by modifying this file. +# For more information, see: +# https://github.com/actions/stale +name: Mark stale issues and pull requests + +on: + schedule: + - cron: '30 9 * * *' + +jobs: + stale: + + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + + steps: + - uses: actions/stale@v10 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + stale-issue-message: 'This issue is marked as stale because there has been no activity for 60 days. Remove stale label or add new comments or this issue will be closed in 90 day.' + close-issue-message: 'Close this stale issue.' + stale-issue-label: 'stale-issue' + exempt-issue-labels: 'RoadMap,Roadmap' + days-before-stale: 60 + days-before-close: 30 + + stale-pr-message: 'This PR is marked as stale because there has been no activity for 60 days. Remove stale label or add new comments or this PR will be closed in 30 days.' + close-pr-message: 'Close this stale PR.' + stale-pr-label: 'stale-pr' + days-before-pr-stale: 60 + days-before-pr-close: 30 diff --git a/.github/workflows/unittest.yml b/.github/workflows/unittest.yml new file mode 100644 index 0000000..3b9fd77 --- /dev/null +++ b/.github/workflows/unittest.yml @@ -0,0 +1,32 @@ +name: Python Unittest Coverage + +on: [push, pull_request] + +jobs: + test: + if: false == contains(github.event.pull_request.title, 'WIP') + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-15] + python-version: ['3.11'] + env: + OS: ${{ matrix.os }} + steps: + - uses: actions/checkout@master + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install Dev Dependencies + run: | + uv pip install -q -e .[dev] + uv pip install coverage pytest + - name: Run tests with coverage + env: + GRPC_VERBOSITY: ERROR + run: | + coverage run -m pytest tests + - name: Generate coverage report + run: | + coverage report -m \ No newline at end of file diff --git a/.github/workflows/update_news.yml b/.github/workflows/update_news.yml new file mode 100644 index 0000000..4f609b4 --- /dev/null +++ b/.github/workflows/update_news.yml @@ -0,0 +1,39 @@ +name: Update NEWS in README +on: + push: + paths: + - 'docs/NEWS.md' + - 'docs/NEWS_zh.md' + branches-ignore: + - 'main' + +# Prevent concurrent runs that modify README files +concurrency: + group: readme-updates-${{ github.ref }} + cancel-in-progress: false + +jobs: + updateNews: + name: NEWS Updater + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.10' + + - name: Update NEWS in README files + run: python .github/scripts/update_news.py + + - name: Commit changes + run: | + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + git add README.md README_zh.md + git diff --staged --quiet || git commit -m "docs: auto-sync NEWS section to README files" + git push + diff --git a/.github/workflows/web-ui.yml b/.github/workflows/web-ui.yml new file mode 100644 index 0000000..7572e80 --- /dev/null +++ b/.github/workflows/web-ui.yml @@ -0,0 +1,46 @@ +name: Web UI + +on: + push: + branches: + - main + paths: + - ".github/workflows/web-ui.yml" + - "examples/web_ui/**" + pull_request: + branches: + - main + paths: + - ".github/workflows/web-ui.yml" + - "examples/web_ui/**" + +jobs: + check: + if: github.event_name != 'pull_request' || false == contains(github.event.pull_request.title, 'WIP') + runs-on: ubuntu-latest + defaults: + run: + working-directory: examples/web_ui + steps: + - uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + cache-dependency-path: examples/web_ui/pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --no-frozen-lockfile + + - name: Check formatting + run: pnpm format:check + + - name: Build + run: pnpm build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..730e6c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,166 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +# JS/Node frontend uses lib/ as a source directory (shadcn convention). +# Re-include it so newly added files under examples/web_ui/frontend/src/lib/ +# aren't silently swallowed by the Python-oriented lib/ rule above. +!examples/web_ui/frontend/src/lib/ +!examples/web_ui/frontend/src/lib/** +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +.idea/ + +# macOS +.DS_Store + +# docs +docs/tutorial/en/build/ +docs/tutorial/zh_CN/build/ + +# Sphinx build artifacts +docs/tutorial/**/doctrees/ +docs/tutorial/**/.doctrees/ +*.buildinfo +*.pickle + +node_modules/ +package-lock.json +*.tsbuildinfo +.wireit/ +.angular/ +uv.lock + +# JS/Node frontend (examples/web_ui) +*.local +.eslintcache +.vite/ +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..fa20411 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,116 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.3.0 + hooks: + - id: check-ast + - id: sort-simple-yaml + - id: check-yaml + exclude: | + (?x)^( + meta.yaml + )$ + - id: check-xml + - id: check-toml + - id: check-docstring-first + - id: check-json + exclude: | + (?x)^( + .*tsconfig(\.[^.]+)?\.json + )$ + - id: fix-encoding-pragma + - id: detect-private-key + - id: trailing-whitespace + - repo: https://github.com/asottile/add-trailing-comma + rev: v3.1.0 + hooks: + - id: add-trailing-comma + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.7.0 + hooks: + - id: mypy + exclude: + (?x)( + pb2\.py$ + | grpc\.py$ + | ^docs + | \.html$ + ) + args: [ --disallow-untyped-defs, + --disallow-incomplete-defs, + --ignore-missing-imports, + --disable-error-code=var-annotated, + --disable-error-code=union-attr, + --disable-error-code=assignment, + --disable-error-code=attr-defined, + --disable-error-code=import-untyped, + --disable-error-code=truthy-function, + --disable-error-code=typeddict-item, + --follow-imports=skip, + --explicit-package-bases, + ] + # - repo: https://github.com/numpy/numpydoc + # rev: v1.6.0 + # hooks: + # - id: numpydoc-validation + - repo: https://github.com/psf/black + rev: 23.3.0 + hooks: + - id: black + args: [--line-length=79] + - repo: https://github.com/PyCQA/flake8 + rev: 6.1.0 + hooks: + - id: flake8 + args: ["--extend-ignore=E203"] + exclude: ^docs + - repo: https://github.com/pylint-dev/pylint + rev: v3.0.2 + hooks: + - id: pylint + exclude: + (?x)( + ^docs + | pb2\.py$ + | grpc\.py$ + | \.demo$ + | \.md$ + | \.html$ + | ^examples/paper_llm_based_algorithm/ + ) + args: [ + --disable=W0511, + --disable=W0718, + --disable=W0122, + --disable=C0103, + --disable=R0913, + --disable=E0401, + --disable=E1101, + --disable=C0415, + --disable=W0603, + --disable=R1705, + --disable=R0914, + --disable=E0601, + --disable=W0602, + --disable=W0604, + --disable=R0801, + --disable=R0902, + --disable=R0903, + --disable=C0123, + --disable=W0231, + --disable=W1113, + --disable=W0221, + --disable=R0401, + --disable=W0632, + --disable=W0123, + --disable=C3001, + --max-branches=30, + --max-nested-blocks=7, + --max-statements=100, + --max-returns=10, + --max-module-lines=3000, + ] + - repo: https://github.com/regebro/pyroma + rev: "5.0" + hooks: + - id: pyroma + args: [--min=10, .] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f03ed62 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,420 @@ +# Contributing to AgentScope + + +Thank you for your interest in contributing to AgentScope! + +As an open-source project, we warmly welcome and encourage +contributions from the community. Whether you're fixing bugs, adding new features, improving documentation, or sharing +ideas, your contributions help make AgentScope better for everyone. + +## 1. Development Roadmap and How to Get Involved + +To support the long-term, healthy growth of AgentScope and its open-source +community, we keep our development plan transparent and openly tracked. + +**Our roadmap is public.** The AgentScope development plan is published and +continuously updated on our [GitHub Projects page](https://github.com/orgs/agentscope-ai/projects/2). +The roadmap reflects the technical direction set by the core team, who are +responsible for AgentScope's overall design and quality. + +**Tasks open to the community.** Items labeled `help wanted` on the Projects +page/issues are contribution opportunities open to everyone. If one of these +interests you: + +- Comment on the related issue to let us know you'd like to take it on +- This helps us avoid duplicate efforts and coordinate with you early + +**If you'd like to join the core development.** We warmly welcome contributors +who want to go deeper and help shape AgentScope itself. Over time, we plan to +gradually invite committed contributors into the core development circle. +Before reaching out, we'd like to share a few honest expectations so you can +decide whether it's a good fit right now: + +- Core development involves frequent design discussions, code reviews, and + iterative revisions — it asks for a sustained investment of time and energy +- To keep AgentScope cohesive and reliable, the core team retains + responsibility for the project's technical direction and quality bar; core + contributors work within this collaborative process + +If this fits your situation, please reach out to the core developers — we'd +love to talk. + +**Proposing something new.** If you have an idea that isn't on the roadmap +yet, please open a new issue describing your proposal. The core team will +respond and discuss it with you so we can find the best path forward together. + +## 2. Responsible Use of AI in Contributions + +AgentScope welcomes contributors who use AI coding assistants — Claude Code, +Cursor, Codex, Copilot, and others. We just ask that they be used +**responsibly**. AgentScope is sustained by reviewer time and community +trust, and AI-assisted contributions need to honor both. + +A few expectations when AI is involved in your work: + +- **You — not the AI — are the author.** Read the diff line by line, run it, + and make sure you understand *what* changed and *why* before you push. + "Claude Code / Cursor / Codex told me to do it" is not an acceptable + answer in code review, and is not the kind of behavior that builds a + healthy open-source community. PRs whose authors cannot explain their own + changes will be closed. + +- **Review your AI-generated code before opening a PR.** Reviewer time is + the most precious resource in this project. Don't outsource your own + review to the maintainers by dumping unreviewed AI output into a PR. + +- **Keep PRs atomic.** Do not submit a 10K+-line PR produced by an AI in a + single shot. Such PRs are unreviewable and will be rejected. Break the + work into focused, single-purpose PRs the same way a human contributor + would. + +- **AI-assisted code follows the same rules.** All of AgentScope's + development principles — modularity, lazy imports, conventional commits, + test coverage, no surprise API breaks — apply identically to code written + with AI assistance. AI is not an excuse for skipping conventions. + +The goal is simple: AI helps you move faster, but the responsibility for +what lands in AgentScope still rests with you as a human contributor. + +## 3. Contribution Workflow + +End-to-end, contributing a change to AgentScope looks like this. + +### Step 1. Claim or create an issue + +Before writing code, find or open the issue that frames your work. + +- **Working on an existing item?** Browse [Projects](https://github.com/orgs/agentscope-ai/projects/2) + and [Issues](https://github.com/agentscope-ai/agentscope/issues) for items + labeled `help wanted` (see [§1](#1-development-roadmap-and-how-to-get-involved)). + Comment on the issue to claim it before starting. +- **Proposing something new?** Open a new issue describing the problem, + your proposed solution, and any design alternatives. Wait for feedback + from the core team before starting a non-trivial implementation — this + avoids wasted rewrites. + +### Step 2. Fork the repo and create a development branch + +1. Fork [agentscope-ai/agentscope](https://github.com/agentscope-ai/agentscope) on GitHub. +2. Clone your fork and add the upstream remote: + ```bash + git clone https://github.com//agentscope.git + cd agentscope + git remote add upstream https://github.com/agentscope-ai/agentscope.git + ``` +3. Create a topic branch off the latest `main`: + ```bash + git checkout main + git pull upstream main + git checkout -b feat/ + ``` + Use a branch name aligned with the change type, e.g., `feat/redis-memory`, + `fix/react-agent-leak`, `docs/contributing-update`. + +### Step 3. Set up your local environment + +AgentScope requires **Python 3.11+** (see `pyproject.toml`). + +```bash +# Create an isolated environment (uv shown; virtualenv / conda also fine) +uv venv +source .venv/bin/activate + +# Install AgentScope in editable mode with the dev extras +pip install -e ".[dev]" +# or, equivalently, with uv: +uv pip install -e ".[dev]" + +# Enable the git pre-commit hooks +pre-commit install +``` + +The `dev` extra pulls in `pre-commit`, `pytest`, the documentation +toolchain, and the `full` extra (which itself includes `models`, `service`, +and `storage`). A single installation gives you everything needed to develop +and run the complete test suite. + +### Step 4. Develop + +A few conventions to follow while writing code: + +- **Lazy imports for optional dependencies.** Any dependency **not listed in + `[project.dependencies]` of `pyproject.toml`** — i.e., anything coming + from the optional groups (`gemini`, `ollama`, `xai`, `service`, `storage`, + etc.) — **must be lazy-imported** at point of use rather than at module + top level: + ```python + def some_function(): + import google.genai # from the `gemini` extra — lazy-imported + # ... use google.genai here + ``` + This keeps `import agentscope` lightweight, and `ImportError` surfaces + only when a feature actually relying on the extra is invoked. If your + change requires a brand-new dependency, decide first whether it belongs + in the base `[project.dependencies]` (always required, kept small) or in + one of the optional extras — and discuss it in the issue before merging. + +- **Follow the project's code style.** Pre-commit handles formatting and + most lint rules automatically. Don't fight the formatter. + +- **Write unit tests alongside features.** Tests live under `tests/` and + follow the existing structure. Tests that rely on an optional extra + (e.g., Redis, Ollama) should skip cleanly when that extra isn't + installed. + +### Step 5. Run pre-commit, tests, and update documentation + +Before opening the PR, run the same checks CI will run: + +```bash +# Auto-format and lint +pre-commit run --all-files + +# Run the unit tests +pytest tests +``` + +If a pre-commit hook fails, fix the issue (most fixes are applied +automatically) and re-stage the files. Don't bypass hooks with +`--no-verify`. + +**Update documentation alongside the code change.** + +- AgentScope's user-facing documentation lives in a separate repository: + **[agentscope-ai/docs](https://github.com/agentscope-ai/docs)**. If your + change affects user-facing behavior — new modules, new public APIs, + behavior changes, tutorials — please open a companion PR there. +- Update inline docstrings and example snippets for any new public APIs. +- Update `README.md` if your change affects how users get started or what + AgentScope advertises. + +### Step 6. Commit and open a pull request + +**Commit message format.** We follow the [Conventional Commits](https://www.conventionalcommits.org/) +specification. This keeps commit history readable and enables automatic +changelog generation. + +``` +(): +``` + +**Types:** +- `feat:` A new feature +- `fix:` A bug fix +- `docs:` Documentation only changes +- `style:` Changes that do not affect the meaning of the code (whitespace, formatting, etc.) +- `refactor:` A code change that neither fixes a bug nor adds a feature +- `perf:` A code change that improves performance +- `ci:` Adding missing tests or correcting existing tests +- `chore:` Changes to the build process or auxiliary tools and libraries + +**Examples:** +```bash +feat(models): add support for Claude-3 model +fix(agent): resolve memory leak in ReActAgent +docs(readme): update installation instructions +refactor(formatter): simplify message formatting logic +ci(models): add unit tests for OpenAI integration +``` + +**Pull request title format.** PR titles follow the same Conventional +Commits format and are validated automatically by GitHub Actions on PRs +against `main`. PRs with invalid titles will be blocked until corrected. + +``` +(): +``` + +**Requirements:** +- Title must start with one of: `feat`, `fix`, `docs`, `ci`, `refactor`, `test`, `chore`, `perf`, `style`, `build`, `revert` +- Scope is optional but recommended +- **Scope must be lowercase** — only lowercase letters, numbers, hyphens (`-`), and underscores (`_`) are allowed +- Description should start with a lowercase letter +- Keep the title concise and descriptive + +**Examples:** +``` +✅ Valid: +feat(memory): add redis cache support +fix(agent): resolve memory leak in ReActAgent +docs(tutorial): update installation guide +ci(workflow): add PR title validation +refactor(my-feature): simplify logic + +❌ Invalid: +feat(Memory): add cache # Scope must be lowercase +feat(MEMORY): add cache # Scope must be lowercase +feat(MyFeature): add feature # Scope must be lowercase +``` + +**Open the PR.** Push your branch to your fork and open a pull request +against `agentscope-ai/agentscope:main`. In the PR description: + +- Link the issue you claimed (`Fixes #123` or `Refs #123`) +- Summarize what changed and why +- Note any breaking changes, deprecations, or migration steps +- Link the companion docs PR in [agentscope-ai/docs](https://github.com/agentscope-ai/docs) + if you opened one + +## 4. Important Notices + +A few cross-cutting constraints worth knowing before you start a +contribution. Module-specific notices live in the corresponding module +guide below. + +- **Open an issue before non-trivial work.** Surprise PRs that touch many + files, change public APIs, or introduce a new module are difficult to + review and likely to be rejected. Discuss the design in an issue first. +- **Keep PRs focused and atomic.** One PR, one purpose. Don't bundle a + refactor with a feature, or a feature with an unrelated bug fix. +- **Don't break public APIs without notice.** Maintain backward + compatibility when you can. If a breaking change is unavoidable, call it + out clearly in the PR description and update the affected examples and + docs in the same PR. +- **Don't bypass the lazy import principle.** Optional dependencies must be + imported at point of use, not at module top level. +- **Don't add dependencies casually.** Every new dependency is a long-term + maintenance commitment. If a dependency is needed by only one module, + prefer a lazy import inside that module. +- **Don't ignore CI failures.** Pre-commit, type checks, and tests must + pass before a PR is ready for review. Don't push the burden of fixing + them onto the reviewer. +- **Be respectful.** Follow our Code of Conduct. AgentScope's review + culture is direct but kind, and we expect the same from contributors. + +## 5. Module-Specific Contribution Guides + +The notes below cover the modules most commonly extended by community +contributors. For other modules, please open an issue first so we can +coordinate. + +### Chat Model + +A chat model in AgentScope is more than a single class — to be usable +inside an `Agent`, it needs a small set of upstream/downstream pieces. +A complete chat-model contribution includes **all** of the following: + +1. **Credential class** — under `agentscope.credential`, subclassing + `CredentialBase`. Carries the API key, endpoint, and other auth fields + your SDK needs. + _Reference: `agentscope/credential/_anthropic.py`_ + +2. **Chat model class** — under `agentscope.model./`, subclassing + `ChatModelBase`. The implementation needs to cover: + - Both streaming and non-streaming modes + - Tools API integration (function/tool calling) + - The `tool_choice` argument + - Reasoning models, where applicable + + _Reference: `agentscope/model/_anthropic/`_ + +3. **Model card YAML(s)** — under + `agentscope.model.._models/`, one YAML per supported model. + Required fields: `name`, `label`, `status`, `input_types`, + `output_types`, `context_size`, `output_size`. Optional: + `parameter_overrides`, `deprecated_at`. + + Example (`claude-sonnet-4-6.yaml`): + ```yaml + name: claude-sonnet-4-6 + label: Claude Sonnet 4.6 + status: active + input_types: + - text/plain + - image/jpeg + output_types: + - text/plain + context_size: 1000000 + output_size: 65536 + parameter_overrides: + max_tokens: {"maximum": 65536} + ``` + +4. **Formatter classes** — under `agentscope.formatter`, both subclassing + `FormatterBase`. Two variants are required because some APIs treat + multi-agent conversations differently from single-user chat: + - `ChatFormatter` for single-user chat scenarios + - `MultiAgentFormatter` for multi-agent scenarios + + Each formatter converts `Msg` objects into the request format the + provider's API expects. + _Reference: `agentscope/formatter/_anthropic_formatter.py`_ + +> ⚠️ PRs that add only the model class without the matching credential, +> model card YAML, and both formatter variants will not be merged. + +### Agent + +AgentScope deliberately maintains a **single core agent class** — +`agentscope.agent.Agent` — that integrates all functionality of the +AgentScope library (memory, tools, MCP, formatters, models, etc.). + +For specialized or domain-specific agents, please contribute them as +[examples](#examples) rather than as new classes in `agentscope.agent`. + +If you believe a use case genuinely requires a new top-level agent class: + +1. **Open an issue first** describing the use case and explaining why + composing existing `Agent` capabilities is insufficient. +2. **Wait for design discussion** with the core team before starting any + implementation. +3. PRs that introduce a new agent class without prior discussion will be + rejected. + +### Workspace + +A Workspace provides the runtime context an agent operates in (skills, +scheduled tasks, etc.). Adding a new workspace backend requires two +classes plus documentation: + +1. **Workspace class** — under `agentscope.workspace`, subclassing + `WorkspaceBase`. Implements the storage and lifecycle semantics of + your backend. + _Reference: `agentscope/workspace/_local_workspace.py` (`LocalWorkspace`)_ + +2. **Workspace manager class** — alongside + `agentscope/app/_manager/_workspace_manager.py`, subclassing + `WorkspaceManagerBase`. Wires your workspace into the application + lifecycle. + _Reference: `LocalWorkspaceManager` in the same file._ + +3. **Documentation** — open a companion PR in + [agentscope-ai/docs](https://github.com/agentscope-ai/docs) describing + how to configure and use your workspace. + +### Examples + +We highly encourage contributions of new examples that showcase +AgentScope's capabilities. + +The `examples/` directory in the main repository focuses on +**demonstrating specific features and capabilities** — concise, +educational reference implementations. For more complete, production-style +applications, please contribute them to +**[agentscope-samples](https://github.com/agentscope-ai/agentscope-samples)** +instead. + +A new example should live in its own subdirectory: + +``` +examples/ +└── / + ├── main.py + ├── README.md # explain the example's purpose, how to run it, and expected output + └── ... +``` + +`examples/agent_service/` is a good starting reference. + +## Getting Help + +If you need assistance or have questions: + +- Open a [Discussion](https://github.com/agentscope-ai/agentscope/discussions) +- Report bugs via [Issues](https://github.com/agentscope-ai/agentscope/issues) +- Contact the maintainers at DingTalk or Discord (links in the README.md) + + +--- + +Thank you for contributing to AgentScope! Your efforts help build a better tool for the entire community. diff --git a/CONTRIBUTING_zh.md b/CONTRIBUTING_zh.md new file mode 100644 index 0000000..1244b60 --- /dev/null +++ b/CONTRIBUTING_zh.md @@ -0,0 +1,298 @@ +# 为 AgentScope 做贡献 + +感谢大家对 AgentScope 的关注! + +作为一个开源项目,我们欢迎并鼓励来自社区的贡献。无论是修复 bug、新增功能、完善文档,还是分享想法,每一份贡献都让 AgentScope 变得更好。 + +## 1. 开发路线图与参与方式 + +为了支持 AgentScope 开源社区的长期健康发展,我们将公开、透明地维护 AgentScope 的开发计划。 + +**路线图公开**。AgentScope 的开发计划会发布在 [GitHub Projects 页面](https://github.com/orgs/agentscope-ai/projects/2),并持续更新。路线图会反映 AgentScope 的技术发展方向,由核心开发团队对 AgentScope 的整体设计与质量负责。 + +**社区可认领的任务**。Projects 页面 / Issues 中标有 `help wanted` 的条目对所有人开放。如果你有兴趣参与某一项: + +- 请在对应 issue 下评论,告知准备认领 +- 这样可以避免重复劳动,也方便我们尽早协作 + +**成为核心开发者**。我们欢迎想要更深入参与、共同塑造 AgentScope 的开发者。我们会在合适的时机邀请投入度高的贡献者成为核心开发者。 +成为核心开发者也意味着更频繁的参与到 AgentScope 的开发工作中,包括: + +- 更加频繁的设计讨论、代码评审与多轮迭代,需要持续的时间和精力投入 +- 为保证 AgentScope 的整体一致性与可靠性,核心团队保留对项目技术方向与质量标准的把控 + +**提出新想法**。针对有路线图上还没有的想法,请新建 issue 描述提议。核心开发团队会尽可能地及时回复并一起讨论可行的推进路径。 + +## 2. 在贡献中负责任地使用 AI + +AgentScope 欢迎使用 AI 编码助手的贡献者——Claude Code、Cursor、Codex、Copilot 等等。我们只要求**负责任地使用**。AgentScope 依靠评审者的时间和社区信任运转,AI 辅助的贡献需要兼顾两者。 + +涉及 AI 时的几条要求: + +- **作者是人,不是 AI**。在 push 之前,请逐行阅读 diff,运行代码,确认理解了**改了什么**和**为什么改**。“Claude Code / Cursor / Codex 就是这么写的”并不是一个合适的理由,也不利于开源社区的健康发展。 + +- **创建 PR 前先自行评审 AI 生成的代码**。所有人的事件都是宝贵的资源,请不要将没有审阅过的 AI 代码/改动直接丢给维护者评审。 + +- **保持 PR 原子化**。不要提交 AI 一次性生成的 10K+ 行 PR,这种 PR 无法评审,会被拒绝。请把改动拆成若干个聚焦原子化功能的、具有单一目标的 PR。 + +- **AI 生成代码遵守同样的原则**。AgentScope 的所有开发原则——模块化、惰性导入、约定式提交、测试覆盖、不破坏 API——对 AI 辅助代码同等适用。 + +简而言之:AI 让我们的开发更快,但确保合入 AgentScope 的代码质量责任仍在贡献者本人。 + +## 3. 贡献流程 + +端到端的贡献流程如下。 + +### 第 1 步:认领或创建 issue + +在写代码之前,先找到或创建对应的 issue。 + +- **基于已有任务**:浏览 [Projects](https://github.com/orgs/agentscope-ai/projects/2) 与 [Issues](https://github.com/agentscope-ai/agentscope/issues) 中标有 `help wanted` 的条目(参见 [§1](#1-开发路线图与参与方式)),在 issue 下评论认领后再开始。 +- **提出新想法**:新建 issue 描述问题、方案与设计上的取舍。等待核心开发团队反馈后再开始实现,避免事后大规模返工。 + +### 第 2 步:Fork 仓库并创建开发分支 + +1. 在 GitHub 上 fork [agentscope-ai/agentscope](https://github.com/agentscope-ai/agentscope)。 +2. clone 自己的 fork 并添加 upstream 远端: + ```bash + git clone https://github.com//agentscope.git + cd agentscope + git remote add upstream https://github.com/agentscope-ai/agentscope.git + ``` +3. 基于最新的 `main` 创建主题分支: + ```bash + git checkout main + git pull upstream main + git checkout -b feat/ + ``` + +### 第 3 步:搭建本地环境 + +AgentScope 要求 **Python 3.11+**(详见 `pyproject.toml`)。 + +```bash +# 创建隔离环境(这里用 uv,也可用 virtualenv / conda) +uv venv +source .venv/bin/activate + +# 以可编辑模式安装 AgentScope,并带上 dev extras +pip install -e ".[dev]" +# 等价的 uv 写法: +uv pip install -e ".[dev]" + +# 启用 git pre-commit hooks +pre-commit install +``` + +`dev` extra 会拉入 `pre-commit`、`pytest`、文档工具链以及 `full` extra(包含 `models`、`service`、`storage`)。一次安装即可获得开发与运行完整测试套件所需的一切。 + +### 第 4 步:开发 + +写代码时遵守的几条约定: + +- **可选依赖必须惰性导入**。任何**未列在 `pyproject.toml` 的 `[project.dependencies]` 中**的依赖——也就是来自可选 extra(`gemini`、`ollama`、`xai`、`service`、`storage` 等)的——**必须在使用点惰性导入**,而不是放在模块顶部: + ```python + def some_function(): + import google.genai # 来自 `gemini` extra,惰性导入 + # ... 在这里使用 google.genai + ``` + 这样保持 `import agentscope` 轻量,`ImportError` 只在实际用到该 extra 的功能时才抛出。如果改动需要引入全新的依赖,先决定它属于基础 `[project.dependencies]`(始终需要、保持精简)还是某个可选 extra,并在 issue 中讨论后再合入。 + +- **遵守项目代码风格**。pre-commit 会自动处理格式与大部分 lint 规则,请在提交前运行 pre-commit 来修复问题。 + +- **功能要配套写单元测试**。测试位于 `tests/` 下,沿用现有结构。依赖可选 extra 的测试(如 Redis、Ollama)在该 extra 未安装时应能干净 skip。 + +### 第 5 步:跑 pre-commit、测试,并更新文档 + +创建 PR 之前,请在本地运行如下的命令检查代码格式与功能: + +```bash +# 自动格式化与 lint +pre-commit run --all-files + +# 单元测试 +pytest tests +``` + +如果 pre-commit hook 失败,请修复格式问题(多数会自动修复),然后重新 commit。 + +**改代码的同时请更新文档**。 + +- AgentScope 文档放在独立仓库:**[agentscope-ai/docs](https://github.com/agentscope-ai/docs)**。如果改动影响用户可见行为——新模块、新公开 API、行为变化、教程——请在该仓库同步开一个配套 PR。 +- 为新公开 API 更新 docstring 与示例片段。 +- 如果改动影响新手上手或 AgentScope 的对外宣传内容,更新 `README.md`。 + +### 第 6 步:提交与发起 PR + +**Commit 信息格式**。我们遵循 [Conventional Commits](https://www.conventionalcommits.org/) 规范,便于阅读历史与自动生成 changelog。 + +``` +(): +``` + +**Type 列表:** +- `feat:` 新功能 +- `fix:` bug 修复 +- `docs:` 仅文档变更 +- `style:` 不影响代码语义的改动(空白、格式等) +- `refactor:` 既不是修 bug 也不是加功能的代码改动 +- `perf:` 性能优化 +- `ci:` 增补或修正测试 +- `chore:` 构建流程或辅助工具/库的变更 + +**示例:** +```bash +feat(models): add support for Claude-3 model +fix(agent): resolve memory leak in ReActAgent +docs(readme): update installation instructions +refactor(formatter): simplify message formatting logic +ci(models): add unit tests for OpenAI integration +``` + +**PR 标题格式**。PR 标题同样遵循 Conventional Commits 格式,并由 GitHub Actions 在针对 `main` 的 PR 上自动校验。标题不合规的 PR 会被阻止合入,直到修正为止。 + +``` +(): +``` + +**要求:** +- 标题须以下列 type 之一开头:`feat`、`fix`、`docs`、`ci`、`refactor`、`test`、`chore`、`perf`、`style`、`build`、`revert` +- scope 可选,建议带上 +- **scope 必须小写**——只允许小写字母、数字、连字符(`-`)和下划线(`_`) +- description 以小写字母开头 +- 标题保持简洁、有信息量 + +**示例:** +``` +✅ 合规: +feat(memory): add redis cache support +fix(agent): resolve memory leak in ReActAgent +docs(tutorial): update installation guide +ci(workflow): add PR title validation +refactor(my-feature): simplify logic + +❌ 不合规: +feat(Memory): add cache # scope 必须小写 +feat(MEMORY): add cache # scope 必须小写 +feat(MyFeature): add feature # scope 必须小写 +``` + +**发起 PR**。把分支 push 到自己的 fork,对 `agentscope-ai/agentscope:main` 发起 pull request。在 PR 描述里: + +- 关联认领的 issue(`Fixes #123` 或 `Refs #123`) +- 概述改了什么、为什么改 +- 标注任何破坏性改动、废弃项或迁移步骤 +- 如果同时开了文档 PR,链接到 [agentscope-ai/docs](https://github.com/agentscope-ai/docs) 的对应 PR + +## 4. 重要事项 + +开始贡献前需要了解的几条横向约束。模块特定的事项见下文对应模块指南。 + +- **非平凡改动先开 issue**。突然提交涉及大量文件、改动公开 API 或引入新模块的 PR 难以评审,多半会被拒。先在 issue 中讨论设计。 +- **PR 保持聚焦、原子**。一个 PR 一个目的。不要把重构和功能、或功能和不相干的 bug 修复混在一起。 +- **不擅自破坏公开 API**。能保持向后兼容就保持。无法避免的破坏性改动,在 PR 描述中清楚说明,并在同一个 PR 里更新受影响的示例和文档。 +- **不绕过惰性导入原则**。可选依赖必须在使用点导入,不能放在模块顶部。 +- **不随意引入依赖**。每个新依赖都是长期维护负担。如果只有一个模块用到,优先在该模块内部惰性导入。 +- **不忽视 CI 失败**。pre-commit、类型检查、测试必须通过后再发起 review,不要把修复负担推给评审者。 +- **保持尊重**。遵守行为准则。AgentScope 的评审风格直接但友善,对贡献者也是同样期待。 + +## 5. 模块特定贡献指南 + +下文覆盖社区贡献者最常扩展的模块。其他模块请先开 issue 协调。 + +### Chat Model + +AgentScope 中的一个 chat model 不只是一个类——要在 `Agent` 中可用,需要一组上下游配套实现。一个完整的 chat model 贡献需包含**以下全部**: + +1. **Credential 类**——位于 `agentscope.credential`,继承 `CredentialBase`。承载 API key、endpoint 及 SDK 所需的其他鉴权字段。 + _参考:`agentscope/credential/_anthropic.py`_ + +2. **Chat model 类**——位于 `agentscope.model./`,继承 `ChatModelBase`。实现需覆盖: + - 流式与非流式两种模式 + - Tools API 集成(function/tool calling) + - `tool_choice` 参数 + - 适用时的 reasoning 模型支持 + + _参考:`agentscope/model/_anthropic/`_ + +3. **Model card YAML**——位于 `agentscope.model.._models/`,每个支持的模型一份 YAML。必填字段:`name`、`label`、`status`、`input_types`、`output_types`、`context_size`、`output_size`。可选字段:`parameter_overrides`、`deprecated_at`。 + + 示例(`claude-sonnet-4-6.yaml`): + ```yaml + name: claude-sonnet-4-6 + label: Claude Sonnet 4.6 + status: active + input_types: + - text/plain + - image/jpeg + output_types: + - text/plain + context_size: 1000000 + output_size: 65536 + parameter_overrides: + max_tokens: {"maximum": 65536} + ``` + +4. **Formatter 类**——位于 `agentscope.formatter`,均继承 `FormatterBase`。需要两种变体,因为部分 API 对多 agent 对话与单用户对话的处理方式不同: + - `ChatFormatter` 处理单用户对话场景 + - `MultiAgentFormatter` 处理多 agent 场景 + + 每个 formatter 把 `Msg` 对象转换成对应 provider API 期望的请求格式。 + _参考:`agentscope/formatter/_anthropic_formatter.py`_ + +> ⚠️ 只加 model 类、缺少配套 credential、model card YAML 与两种 formatter 变体的 PR 不会被合入。 + +### Agent + +AgentScope 目前只维护**一个核心 agent 类**——`agentscope.agent.Agent`——它整合了 AgentScope 库的全部功能(memory、tools、MCP、formatter、model 等)。 + +特定领域或专用 agent 请作为 [example](#examples) 贡献,而不是在 `agentscope.agent` 中新增类。 + +如果确信某个用例需要新的顶层 agent 类: + +1. **先开 issue**,描述用例并说明为什么组合现有 `Agent` 能力不够。 +2. **等核心团队的设计讨论**,再开始具体的代码实现。 +3. 未经事先讨论就引入新 agent 类的 PR 会被拒绝。 + +### Workspace + +Workspace 提供 agent 运行所需的运行时上下文(skills、scheduled tasks 等)。新增 workspace 后端需要两个类加配套文档: + +1. **Workspace 类**——位于 `agentscope.workspace`,继承 `WorkspaceBase`。实现该后端的存储与生命周期语义。 + _参考:`agentscope/workspace/_local_workspace.py`(`LocalWorkspace`)_ + +2. **Workspace manager 类**——位于 `agentscope/app/_manager/_workspace_manager.py`,继承 `WorkspaceManagerBase`。把 workspace 接入应用生命周期。 + _参考:同文件中的 `LocalWorkspaceManager`_ + +3. **文档**——在 [agentscope-ai/docs](https://github.com/agentscope-ai/docs) 配套发起 PR,说明该 workspace 的配置与使用方式。 + +### Examples + +我们非常欢迎新增展示 AgentScope 能力的 example。 + +主仓库 `examples/` 目录聚焦于**演示具体特性与能力**——简洁、教学性的参考实现。更完整、贴近生产形态的应用,请贡献到 **[agentscope-samples](https://github.com/agentscope-ai/agentscope-samples)**。 + +新 example 放在自己的子目录下: + +``` +examples/ +└── / + ├── main.py + ├── README.md # 说明 example 的目的、运行方式与预期输出 + └── ... +``` + +`examples/agent_service/` 是不错的参考起点。 + +## 获取帮助 + +需要协助或有问题,可以: + +- 发起 [Discussion](https://github.com/agentscope-ai/agentscope/discussions) +- 在 [Issues](https://github.com/agentscope-ai/agentscope/issues) 中报告 bug +- 通过钉钉或 Discord 联系维护者(链接见 README.md) + + +--- + +感谢您为 AgentScope 所做的贡献!每一份努力都在为社区构建更好的开源工具。 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2f2f2f2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,391 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 Alibaba + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + +-------------------------------------------------------------------------------- + + +Some codes of tests/run.py is modified from +https://github.com/alibaba/FederatedScope/blob/master/tests/run.py, which is +also licensed under the terms of the Apache 2.0. + + +-------------------------------------------------------------------------------- + +Code in src/agentscope/web/static/js/socket.io.js is adapted from +https://cdnjs.cloudflare.com/ajax/libs/socket.io/3.1.3/socket.io.js (MIT License) + +Copyright (c) 2014-2021 Guillermo Rauch + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +-------------------------------------------------------------------------------- + +Code in src/agentscope/web/static/js/jquery-3.3.1.min.js is adapted from +https://code.jquery.com/jquery-3.3.1.min.js (MIT License) + +Copyright (c) JS Foundation and other contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +-------------------------------------------------------------------------------- + +Code in src/agentscope/web/static/js/bootstrap.bundle.min.js is adapted from +https://cdn.jsdelivr.net/npm/bootstrap@4.3.1/dist/js/bootstrap.bundle.min.js + (MIT License) + +Copyright (c) 2011-2019 The Bootstrap Authors (https://github +.com/twbs/bootstrap/graphs/contributors) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +-------------------------------------------------------------------------------- + +Code in src/agentscope/web/static/js/bootstrap-table.min.js is adapted from +https://unpkg.com/bootstrap-table@1.18.0/dist/bootstrap-table.min.js (MIT +License) + +Copyright (c) wenzhixin (http://wenzhixin.net.cn/) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +-------------------------------------------------------------------------------- + +Code in src/agentscope/web/static/css/bootstrap.min.css is adapted from +https://cdn.jsdelivr.net/npm/bootstrap@4.3.1/dist/css/bootstrap.min.css (MIT +License) + +Copyright 2011-2019 The Bootstrap Authors +Copyright 2011-2019 Twitter, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +-------------------------------------------------------------------------------- + +Fonts in src/agentscope/web/static/fonts/KRYPTON.ttf is adapted from +https://github.com/githubnext/monaspace (SIL Open Font License 1.1). These +fonts are distributed with their original license. See https://github +.com/githubnext/monaspace/blob/main/LICENSE for the full text of the license. +The following font families are included: + +- Monaspace (with subfamilies: Krypton) + +Copyright (c) 2023, GitHub https://github.com/githubnext/monaspace +with Reserved Font Name "Monaspace", including subfamilies: "Argon", "Neon", +"Xenon", "Radon", and "Krypton" + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + +-------------------------------------------------------------------------------- + +Fonts in src/agentscope/web/static/fonts/OSWALD.ttf is adapted from +https://fonts.google.com/specimen/Oswald (SIL Open Font License 1.1). These +fonts are distributed with their original license. See https://github +.com/googlefonts/OswaldFont/blob/main/OFL.txt for the full text of the license. + +Copyright 2016 The Oswald Project Authors (https://github +.com/googlefonts/OswaldFont) + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + +-------------------------------------------------------------------------------- \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5c721dc --- /dev/null +++ b/README.md @@ -0,0 +1,277 @@ +

+ AgentScope Logo +

+ + + +[**中文主页**](https://github.com/agentscope-ai/agentscope/blob/main/README_zh.md) | [**Documentation**](https://docs.agentscope.io/) | [**Roadmap**](https://github.com/orgs/agentscope-ai/projects/2/views/1) + + + +

+ + arxiv + + + pypi + + + pypi + + + discord + + + docs + + + license + +

+ +

+agentscope-ai%2Fagentscope | Trendshift +

+ +## What is AgentScope 2.0? + +AgentScope 2.0 is a production-ready, easy-to-use agent framework with essential abstractions that work with rising model capability and built-in support for . + +- [**Event System** →](https://docs.agentscope.io/latest/en/building-blocks/message-and-event) A unified event bus to the frontend and human-in-the-loop support. +- [**Permission System** →](https://docs.agentscope.io/latest/en/building-blocks/permission-system) Fine-grained, configurable control over tools and resources. +- [**Multi-tenancy & Multi-session Service** →](https://docs.agentscope.io/latest/en/deploy/agent-service) Production-grade serving with isolation across tenants and sessions. +- [**Workspace / Sandbox Support** →](https://docs.agentscope.io/latest/en/building-blocks/workspace) Run tools and code in isolated environments, with built-in backends for local, Docker, E2B, and OpenSandbox. +- [**Extensible Middleware System** →](https://docs.agentscope.io/latest/en/building-blocks/middleware) Composable hooks to customize and extend the agent's reasoning-acting loop. + +We design for increasingly agentic LLMs. +Our approach leverages the models' reasoning and tool use abilities +rather than constraining them with strict prompts and opinionated orchestrations. + +agentscope + +## News + +- **[2026-07] `INTE`:** K8s, OpenSandbox-based workspace/sandbox supported. [Docs](https://docs.agentscope.io/latest/en/building-blocks/workspace) +- **[2026-07] `INTE`:** ReMe long-term memory supported. [Example](https://github.com/agentscope-ai/agentscope/tree/main/examples/long_term_memory/reme) | [Docs](https://docs.agentscope.io/latest/en/building-blocks/long-term-memory) +- **[2026-06] `FEAT`:** Agentic Memory supported. [Example](https://github.com/agentscope-ai/agentscope/tree/main/examples/long_term_memory/agentic_memory) | [Docs](https://docs.agentscope.io/latest/en/building-blocks/long-term-memory) +- **[2026-06] `FEAT`:** Distributed & Multi-Tenancy & Multi-Session RAG service supported. [Docs](https://docs.agentscope.io/latest/en/deploy/agent-team) +- **[2026-06] `FEAT`:** RAG supported. [Example](https://github.com/agentscope-ai/agentscope/tree/main/examples/rag) | [Docs](https://docs.agentscope.io/latest/en/building-blocks/rag) +- **[2026-06] `INTE`:** Mem0 supported. [Example](https://github.com/agentscope-ai/agentscope/tree/main/examples/long_term_memory) | [Docs](https://docs.agentscope.io/latest/en) +- **[2026-06] `FEAT`:** Agent Team supported. [Example](https://github.com/agentscope-ai/agentscope/tree/main/examples/agent_service) | [Docs](https://docs.agentscope.io/latest/en/deploy/agent-team) +- **[2026-05] `RELS`:** AgentScope 2.0 released! [Docs](https://docs.agentscope.io/) + + +[More news →](./docs/NEWS.md) + +## Community + +Welcome to join our community on + +| [Discord](https://discord.gg/eYMpfnkG8h) | DingTalk | +|----------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------| +| | | + +## Quickstart + +### Installation + +> AgentScope requires **Python 3.11** or higher. + +#### From PyPI + +```bash +uv pip install agentscope +# or +# pip install agentscope +``` + +#### From source + +```bash +# Pull the source code from GitHub +git clone -b main https://github.com/agentscope-ai/agentscope.git + +# Install the package in editable mode +cd agentscope + +uv pip install -e . +# or +# pip install -e . +``` + +## Hello AgentScope! + +Start your first agent with AgentScope 2.0: + +```python +from agentscope.agent import Agent +from agentscope.tool import Toolkit, Bash, Grep, Glob, Read, Write, Edit +from agentscope.credential import DashScopeCredential +from agentscope.model import DashScopeChatModel +from agentscope.message import UserMsg +from agentscope.event import EventType + +import os, asyncio + + +async def main() -> None: + agent = Agent( + name="Friday", + system_prompt="You're a helpful assistant named Friday.", + model=DashScopeChatModel( + credential=DashScopeCredential( + api_key=os.environ["DASHSCOPE_API_KEY"] + ), + model="qwen3.6-plus", + ), + toolkit=Toolkit( + tools=[ + Bash(), + Grep(), + Glob(), + Read(), + Write(), + Edit(), + ] + ), + ) + + async for evt in agent.reply_stream(UserMsg("Tony", "Hi, Friday!")): + # Handle the event stream, e.g., print the message, update UI, etc. + match evt.type: + case EventType.REPLY_START: + ... + case EventType.MODEL_CALL_START: + ... + case EventType.TEXT_BLOCK_START: + ... + case EventType.TEXT_BLOCK_DELTA: + ... + case EventType.TEXT_BLOCK_END: + ... + + # Handle other event types + +asyncio.run(main()) +``` + +## Hello Agent Service! + +An extensible FastAPI based **multi-tenancy**, **multi-session** agent service with pre-built Web UI in `examples/web_ui` + + + + + + + + + + + + + + +
+ Agent team +
+ Agent team — a leader agent spawns workers and coordinates them through the built-in team tools. +
+ Task planning +
+ Task planning — the agent breaks complex work into a tracked plan and updates it as it goes. +
+ Permission control in bypass mode +
+ Permission control in bypass mode — the agent runs end-to-end without pausing for tool-call confirmations. +
+ Background task offloading +
+ Background task offloading — a long-running tool moves to the background; its result later wakes the agent up and the conversation resumes. +
+ +Run the following commands to start the agent service backend and the web UI: + +```bash +git clone -b main https://github.com/agentscope-ai/agentscope.git +cd agentscope/examples/agent_service + +# start the agent service backend +python main.py +``` + +Then open another terminal to start the web UI: + +```bash +cd agentscope/examples/web_ui + +# start the webui +pnpm install +pnpm dev +``` + + +## Contributing + +We welcome contributions from the community! Please refer to our [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines +on how to contribute. + +## License + +AgentScope is released under Apache License 2.0. + +## Publications + +If you find our work helpful for your research or application, please cite our papers. + +- [AgentScope 1.0: A Developer-Centric Framework for Building Agentic Applications](https://arxiv.org/abs/2508.16279) + +- [AgentScope: A Flexible yet Robust Multi-Agent Platform](https://arxiv.org/abs/2402.14034) + +``` +@article{agentscope_v1, + author = {Dawei Gao, Zitao Li, Yuexiang Xie, Weirui Kuang, Liuyi Yao, Bingchen Qian, Zhijian Ma, Yue Cui, Haohao Luo, Shen Li, Lu Yi, Yi Yu, Shiqi He, Zhiling Luo, Wenmeng Zhou, Zhicheng Zhang, Xuguang He, Ziqian Chen, Weikai Liao, Farruh Isakulovich Kushnazarov, Yaliang Li, Bolin Ding, Jingren Zhou} + title = {AgentScope 1.0: A Developer-Centric Framework for Building Agentic Applications}, + journal = {CoRR}, + volume = {abs/2508.16279}, + year = {2025}, +} + +@article{agentscope, + author = {Dawei Gao, Zitao Li, Xuchen Pan, Weirui Kuang, Zhijian Ma, Bingchen Qian, Fei Wei, Wenhao Zhang, Yuexiang Xie, Daoyuan Chen, Liuyi Yao, Hongyi Peng, Zeyu Zhang, Lin Zhu, Chen Cheng, Hongzhu Shi, Yaliang Li, Bolin Ding, Jingren Zhou} + title = {AgentScope: A Flexible yet Robust Multi-Agent Platform}, + journal = {CoRR}, + volume = {abs/2402.14034}, + year = {2024}, +} +``` + +## Contributors + +All thanks to our contributors: + + + + diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..b955ac6 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`agentscope-ai/agentscope` +- 原始仓库:https://github.com/agentscope-ai/agentscope +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/README_zh.md b/README_zh.md new file mode 100644 index 0000000..081cac7 --- /dev/null +++ b/README_zh.md @@ -0,0 +1,300 @@ +

+ AgentScope Logo +

+ + + +[**English Homepage**](https://github.com/agentscope-ai/agentscope/blob/main/README.md) | [**文档**](https://docs.agentscope.io/) | [**路线图**](https://github.com/orgs/agentscope-ai/projects/2/views/1) + + + +

+ + arxiv + + + pypi + + + pypi + + + discord + + + docs + + + license + +

+ +

+agentscope-ai%2Fagentscope | Trendshift +

+ +## 什么是 AgentScope 2.0? + +AgentScope 2.0 是一款面向生产、易于使用的智能体框架,提供与不断进化的模型能力相匹配的核心抽象。 + +- [**事件系统** →](https://docs.agentscope.io/latest/zh/building-blocks/message-and-event) 统一的事件总线,服务于前端智能体应用与 human-in-the-loop 协作。 +- [**权限系统** →](https://docs.agentscope.io/latest/zh/building-blocks/permission-system) 对工具和资源进行细粒度、可配置的控制。 +- [**多租户与多会话服务** →](https://docs.agentscope.io/latest/zh/deploy/agent-service) 提供生产级服务,在租户与会话之间实现隔离。 +- [**工作区 / 沙箱支持** →](https://docs.agentscope.io/latest/zh/building-blocks/workspace) 在隔离环境中运行工具和代码,内置支持本地文件系统、Docker、E2B 和 OpenSandbox 后端。 +- [**可扩展中间件系统** →](https://docs.agentscope.io/latest/zh/building-blocks/middleware) 可组合的钩子系统,用于自定义和扩展智能体的推理-行动循环。 + +我们为日益自主的大语言模型而设计。 +我们的方法是充分发挥模型的推理与工具调用能力, +而不是用严格的提示词和固化的编排方式来束缚它们。 + +## 为什么选择 AgentScope? + +- **简单**:通过内置的 ReAct 智能体、工具、技能、人机协作干预、记忆、计划、实时语音、评估和模型微调,5 分钟即可开始构建你的智能体 +- **可扩展**:丰富的生态系统集成,覆盖工具、记忆和可观测性;内置 MCP 和 A2A 支持;通过消息中心(MsgHub)实现灵活的多智能体编排和工作流 +- **生产就绪**:支持本地部署、云端 Serverless 部署或 K8s 集群部署,并内置 OTel 支持 + +agentscope + +## 新闻 + +- **[2026-07] `集成`:** 集成 K8s,OpenSandbox 工作区/沙箱实现。 [文档](https://docs.agentscope.io/latest/en/building-blocks/workspace) +- **[2026-07] `集成`:** 集成 ReMe 长期记忆。 [样例](https://github.com/agentscope-ai/agentscope/tree/main/examples/long_term_memory/reme) | [文档](https://docs.agentscope.io/latest/zh/building-blocks/long-term-memory) +- **[2026-06] `功能`:** 支持 Agentic Memory。 [样例](https://github.com/agentscope-ai/agentscope/tree/main/examples/long_term_memory/agentic_memory) | [文档](https://docs.agentscope.io/latest/zh/building-blocks/long-term-memory) +- **[2026-06] `功能`:** 支持分布式 & 多租户 & 多会话 RAG 服务。 [文档](https://docs.agentscope.io/latest/en/deploy/agent-team) +- **[2026-06] `功能`:** 支持多模态 RAG。 [样例](https://github.com/agentscope-ai/agentscope/tree/main/examples/rag) | [文档](https://docs.agentscope.io/latest/en/building-blocks/rag) +- **[2026-06] `集成`:** 集成 Mem0 长期记忆。 [Example](https://github.com/agentscope-ai/agentscope/tree/main/examples/long_term_memory) | [Docs](https://docs.agentscope.io/latest/zh) +- **[2026-06] `功能`:** 支持 Agent Team。[样例](https://github.com/agentscope-ai/agentscope/tree/main/examples/agent_service) | [文档](https://docs.agentscope.io/latest/zh/deploy/agent-team) +- **[2026-05] `发布`:** AgentScope 2.0 已发布![文档](https://docs.agentscope.io/) + + +[更多新闻 →](./docs/NEWS_zh.md) + +## 社区 + +欢迎加入我们的社区 + +| [Discord](https://discord.gg/eYMpfnkG8h) | 钉钉 | +|----------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------| +| | | + + + +## 📑 Table of Contents + +- [快速开始](#%E5%BF%AB%E9%80%9F%E5%BC%80%E5%A7%8B) + - [安装](#%E5%AE%89%E8%A3%85) + - [从 PyPI 安装](#%E4%BB%8E-pypi-%E5%AE%89%E8%A3%85) + - [从源码安装](#%E4%BB%8E%E6%BA%90%E7%A0%81%E5%AE%89%E8%A3%85) +- [Hello AgentScope!](#hello-agentscope) +- [智能体服务](#%E6%99%BA%E8%83%BD%E4%BD%93%E6%9C%8D%E5%8A%A1) +- [贡献](#%E8%B4%A1%E7%8C%AE) +- [许可](#%E8%AE%B8%E5%8F%AF) +- [论文](#%E8%AE%BA%E6%96%87) +- [贡献者](#%E8%B4%A1%E7%8C%AE%E8%80%85) + + + +## 快速开始 + +### 安装 + +> AgentScope 需要 **Python 3.11** 或更高版本。 + +#### 从 PyPI 安装 + +```bash +uv pip install agentscope +# 或者 +# pip install agentscope +``` + +#### 从源码安装 + +```bash +# 从 GitHub 拉取源码 +git clone -b main https://github.com/agentscope-ai/agentscope.git + +# 以可编辑模式安装 +cd agentscope + +uv pip install -e . +# 或者 +# pip install -e . +``` + +## Hello AgentScope! + +使用 AgentScope 2.0,启动你的第一个智能体: + +```python +from agentscope.agent import Agent +from agentscope.tool import Toolkit, Bash, Grep, Glob, Read, Write, Edit +from agentscope.credential import DashScopeCredential +from agentscope.model import DashScopeChatModel +from agentscope.message import UserMsg +from agentscope.event import EventType + +import os, asyncio + + +async def main() -> None: + agent = Agent( + name="Friday", + system_prompt="You're a helpful assistant named Friday.", + model=DashScopeChatModel( + credential=DashScopeCredential( + api_key=os.environ["DASHSCOPE_API_KEY"] + ), + model="qwen3.6-plus", + ), + toolkit=Toolkit( + tools=[ + Bash(), + Grep(), + Glob(), + Read(), + Write(), + Edit(), + ] + ), + ) + + async for evt in agent.reply_stream(UserMsg("Tony", "Hi, Friday!")): + # 处理事件流,例如打印消息、更新 UI 等 + match evt.type: + case EventType.REPLY_START: + ... + case EventType.MODEL_CALL_START: + ... + case EventType.TEXT_BLOCK_START: + ... + case EventType.TEXT_BLOCK_DELTA: + ... + case EventType.TEXT_BLOCK_END: + ... + + # 处理其他事件类型 + +asyncio.run(main()) +``` + +## 智能体服务 + +一个基于 FastAPI 的可扩展**多租户**、**多会话**智能体服务,并在 `examples/web_ui` 中提供预构建的 Web UI + + + + + + + + + + + + + + +
+ 智能体团队 +
+ 智能体团队 —— leader 智能体派生 worker,并通过内置的团队工具进行协调。 +
+ 任务规划 +
+ 任务规划 —— 智能体将复杂工作拆解为可追踪的计划,并在执行过程中持续更新。 +
+ bypass 模式下的权限控制 +
+ bypass 模式下的权限控制 —— 智能体端到端运行,无需为工具调用确认而暂停。 +
+ 后台任务卸载 +
+ 工具后台执行 —— 长时间运行的工具被转入后台;其结果稍后唤醒智能体并恢复对话。 +
+ +运行以下命令启动智能体服务后端和 Web UI: + +```bash +git clone -b main https://github.com/agentscope-ai/agentscope.git +cd agentscope/examples/agent_service + +# 启动智能体服务后端 +python main.py +``` + +然后打开另一个终端启动 Web UI: + +```bash +cd agentscope/examples/web_ui + +# 启动 webui +pnpm install +pnpm dev +``` + + + +## 贡献 + +我们欢迎社区的贡献!请参阅我们的 [贡献指南](./CONTRIBUTING_zh.md) 了解如何贡献。 + +## 许可 + +AgentScope 基于 Apache License 2.0 发布。 + +## 论文 + +如果我们的工作对您的研究或应用有帮助,请引用我们的论文。 + +- [AgentScope 1.0: A Developer-Centric Framework for Building Agentic Applications](https://arxiv.org/abs/2508.16279) + +- [AgentScope: A Flexible yet Robust Multi-Agent Platform](https://arxiv.org/abs/2402.14034) + +``` +@article{agentscope_v1, + author = {Dawei Gao, Zitao Li, Yuexiang Xie, Weirui Kuang, Liuyi Yao, Bingchen Qian, Zhijian Ma, Yue Cui, Haohao Luo, Shen Li, Lu Yi, Yi Yu, Shiqi He, Zhiling Luo, Wenmeng Zhou, Zhicheng Zhang, Xuguang He, Ziqian Chen, Weikai Liao, Farruh Isakulovich Kushnazarov, Yaliang Li, Bolin Ding, Jingren Zhou} + title = {AgentScope 1.0: A Developer-Centric Framework for Building Agentic Applications}, + journal = {CoRR}, + volume = {abs/2508.16279}, + year = {2025}, +} + +@article{agentscope, + author = {Dawei Gao, Zitao Li, Xuchen Pan, Weirui Kuang, Zhijian Ma, Bingchen Qian, Fei Wei, Wenhao Zhang, Yuexiang Xie, Daoyuan Chen, Liuyi Yao, Hongyi Peng, Zeyu Zhang, Lin Zhu, Chen Cheng, Hongzhu Shi, Yaliang Li, Bolin Ding, Jingren Zhou} + title = {AgentScope: A Flexible yet Robust Multi-Agent Platform}, + journal = {CoRR}, + volume = {abs/2402.14034}, + year = {2024}, +} +``` + +## 贡献者 + +感谢所有贡献者: + + + + diff --git a/assets/images/agentscope.png b/assets/images/agentscope.png new file mode 100644 index 0000000..0c02c64 Binary files /dev/null and b/assets/images/agentscope.png differ diff --git a/assets/images/bg_tool.gif b/assets/images/bg_tool.gif new file mode 100644 index 0000000..2a6eaa3 Binary files /dev/null and b/assets/images/bg_tool.gif differ diff --git a/assets/images/dingtalk_qr_code.png b/assets/images/dingtalk_qr_code.png new file mode 100644 index 0000000..fbb5420 Binary files /dev/null and b/assets/images/dingtalk_qr_code.png differ diff --git a/assets/images/permission_bypass.gif b/assets/images/permission_bypass.gif new file mode 100644 index 0000000..4a15b83 Binary files /dev/null and b/assets/images/permission_bypass.gif differ diff --git a/assets/images/task.gif b/assets/images/task.gif new file mode 100644 index 0000000..d48729d Binary files /dev/null and b/assets/images/task.gif differ diff --git a/assets/images/team.gif b/assets/images/team.gif new file mode 100644 index 0000000..77a97f5 Binary files /dev/null and b/assets/images/team.gif differ diff --git a/docs/NEWS.md b/docs/NEWS.md new file mode 100644 index 0000000..fb72315 --- /dev/null +++ b/docs/NEWS.md @@ -0,0 +1,12 @@ + + + + +- **[2026-07] `INTE`:** K8s, OpenSandbox-based workspace/sandbox supported. [Docs](https://docs.agentscope.io/latest/en/building-blocks/workspace) +- **[2026-07] `INTE`:** ReMe long-term memory supported. [Example](https://github.com/agentscope-ai/agentscope/tree/main/examples/long_term_memory/reme) | [Docs](https://docs.agentscope.io/latest/en/building-blocks/long-term-memory) +- **[2026-06] `FEAT`:** Agentic Memory supported. [Example](https://github.com/agentscope-ai/agentscope/tree/main/examples/long_term_memory/agentic_memory) | [Docs](https://docs.agentscope.io/latest/en/building-blocks/long-term-memory) +- **[2026-06] `FEAT`:** Distributed & Multi-Tenancy & Multi-Session RAG service supported. [Docs](https://docs.agentscope.io/latest/en/deploy/agent-team) +- **[2026-06] `FEAT`:** RAG supported. [Example](https://github.com/agentscope-ai/agentscope/tree/main/examples/rag) | [Docs](https://docs.agentscope.io/latest/en/building-blocks/rag) +- **[2026-06] `INTE`:** Mem0 supported. [Example](https://github.com/agentscope-ai/agentscope/tree/main/examples/long_term_memory) | [Docs](https://docs.agentscope.io/latest/en) +- **[2026-06] `FEAT`:** Agent Team supported. [Example](https://github.com/agentscope-ai/agentscope/tree/main/examples/agent_service) | [Docs](https://docs.agentscope.io/latest/en/deploy/agent-team) +- **[2026-05] `RELS`:** AgentScope 2.0 released! [Docs](https://docs.agentscope.io/) \ No newline at end of file diff --git a/docs/NEWS_zh.md b/docs/NEWS_zh.md new file mode 100644 index 0000000..8ad67dc --- /dev/null +++ b/docs/NEWS_zh.md @@ -0,0 +1,12 @@ + + + + +- **[2026-07] `集成`:** 集成 K8s,OpenSandbox 工作区/沙箱实现。 [文档](https://docs.agentscope.io/latest/en/building-blocks/workspace) +- **[2026-07] `集成`:** 集成 ReMe 长期记忆。 [样例](https://github.com/agentscope-ai/agentscope/tree/main/examples/long_term_memory/reme) | [文档](https://docs.agentscope.io/latest/zh/building-blocks/long-term-memory) +- **[2026-06] `功能`:** 支持 Agentic Memory。 [样例](https://github.com/agentscope-ai/agentscope/tree/main/examples/long_term_memory/agentic_memory) | [文档](https://docs.agentscope.io/latest/zh/building-blocks/long-term-memory) +- **[2026-06] `功能`:** 支持分布式 & 多租户 & 多会话 RAG 服务。 [文档](https://docs.agentscope.io/latest/en/deploy/agent-team) +- **[2026-06] `功能`:** 支持多模态 RAG。 [样例](https://github.com/agentscope-ai/agentscope/tree/main/examples/rag) | [文档](https://docs.agentscope.io/latest/en/building-blocks/rag) +- **[2026-06] `集成`:** 集成 Mem0 长期记忆。 [Example](https://github.com/agentscope-ai/agentscope/tree/main/examples/long_term_memory) | [Docs](https://docs.agentscope.io/latest/zh) +- **[2026-06] `功能`:** 支持 Agent Team。[样例](https://github.com/agentscope-ai/agentscope/tree/main/examples/agent_service) | [文档](https://docs.agentscope.io/latest/zh/deploy/agent-team) +- **[2026-05] `发布`:** AgentScope 2.0 已发布![文档](https://docs.agentscope.io/) \ No newline at end of file diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..6b69714 --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,98 @@ +# CHANGELOG of v1.0.0 + +> ➡️ change; ✅ new feature; ❌ deprecate + +The overall changes from v0.x.x to v1.0.0 are summarized below. + +## Overview +- ✅ Support asynchronous execution throughout the library +- ✅ Support tools API thoroughly + + +## ✨Session +- ✅ Support automatic state management +- ✅ Support session/application-level state management + + +## ✨Tracing +- ✅ Support OpenTelemetry-based tracing +- ✅ Support third-party tracing platforms, e.g. Arize-Phoenix, Langfuse, etc. + + +## ✨MCP +- ✅ Support both client- and function-level control over MCP by a new MCP module +- ✅ Support both "pay-as-you-go" and persistent session management +- ✅ Support streamable HTTP, SSE and StdIO transport protocols + + +## ✨Memory +- ✅ Support long-term memory by providing a `LongTermMemoryBase` class +- ✅ Provide a Mem0-based long-term memory implementation +- ✅ Support both static- and agent-controlled long-term memory modes + + +## Formatter +- ✅ Support prompt construction/formatting with token count estimation +- ✅ Support tools API in multi-agent prompt formatting + + +## Model +- ❌ Deprecate model configuration, use explicit object instantiation instead +- ✅ Provide a new `ModelResponse` class for structured model responses +- ✅ Support asynchronous model invocation +- ✅ Support reasoning models +- ✅ Support any combination of streaming/non-streaming, reasoning/non-reasoning and tools API + + +## Agent +- ❌ Deprecate `DialogAgent`, `DictDialogAgent` and prompt-based ReAct agent class +- ➡️ Expose memory, formatter interfaces to the agent's constructor in ReActAgent +- ➡️ Unify the signature of pre- and post- agent hooks +- ✅ Support pre-/post-reasoning and pre-/post-acting hooks in ReActAgent class +- ✅ Support asynchronous agent execution +- ✅ Support interrupting agent's replying and customized interruption handling +- ✅ Support automatic state management +- ✅ Support parallel tool calls +- ✅ Support two-modes long-term memory in ReActAgent class + + +## Tool +- ✅ Provide a more powerful `Toolkit` class for tools management +- ✅ Provide a new `ToolResponse` class for structured and multimodal tool responses +- ✅ Support group-wise tool management +- ✅ Support agent to manage tools by itself +- ✅ Support post-processing of tool responses +- Tool function + - ✅ Support both async and sync functions + - ✅ Support both streaming and non-streaming return + + +## Evaluation +- ✅ Support ReAct agent-oriented evaluation +- ✅ Support Ray-based distributed and concurrent evaluation +- ✅ Support statistical analysis over evaluation results + + +## AgentScope Studio +- ✅ Support runtime tracing +- ✅ Provide a built-in copilot agent named Friday + + +## Logging +- ❌ Deprecate `loguru` and use Python native `logging` module instead + + +## Distribution +- ❌ Deprecate distribution functionality momentarily, a new distribution module is coming soon + + +## RAG +- ❌ Deprecate RAG functionality momentarily, a new RAG module is coming soon + + +## Parsers +- ❌ Deprecate parsers module + + +## WebBrowser +- ❌ Deprecate the `WebBrowser` class and shift to MCP-based web browsing diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..93d392d --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,121 @@ +# Roadmap + +## Long-term Goals + +Offering **agent-oriented programming (AOP)** as a new programming paradigm to organize the design and implementation of next-generation LLM-empowered applications. + +## Current Focus (January 2026 - ) + +### 🎙️ Voice Agent + +**Voice agents** are a domain we are highly focused on, and AgentScope will continue to invest in this direction. + +AgentScope aims to build **production-ready** voice agents rather than demonstration prototypes. This means our voice agents will: + +- Support **production-grade** deployment, including seamless frontend integration +- Support **tool invocation**, not just voice conversations +- Support **multi-agent** voice interactions + +#### Development Roadmap + +Our development strategy for voice agents consists of **three progressive milestones**: + +1. **TTS Models** → 2. **Multimodal Models** → 3. **Real-time Multimodal Models** + +--- + +#### Phase 1: TTS (Text-to-Speech) Models + +- **Build TTS model base class infrastructure** + - Design and implement a unified TTS model base class + - Establish standardized interfaces for TTS model integration + +- **Horizontal API expansion** + - Support mainstream TTS APIs (e.g., OpenAI TTS, Google TTS, Azure TTS, ElevenLabs, etc.) + - Ensure consistent behavior across different TTS providers + +--- + +#### Phase 2: Multimodal Models (Non-Realtime) + +- **Enable ReAct agents with multimodal support** + - Integrate multimodal models (e.g., qwen3-omni, gpt-audio) into existing ReAct agent framework + - Support audio input/output in non-realtime mode + +- **Advanced multimodal agent capabilities** + - Enable tool invocation within multimodal conversations + - Support multi-agent workflows with multimodal communication + +--- + +#### Phase 3: Real-time Multimodal Models + + +- **Beyond request-response**: Explore streaming, interrupt handling, and concurrent multimodal processing +- **New programming paradigms**: Design agent programming models specifically tailored for real-time interactions +- **Production readiness**: Ensure low-latency performance, stability, and scalability for production deployment + +### 🛠️ Agent Skill + +Provide **production-ready** agent skill integration solutions. + +### 🌐 Ecosystem Expansion + +- **A2UI (Agent-to-UI)**: Enable seamless agent-to-user interface interactions +- **A2A (Agent-to-Agent)**: Enhance agent-to-agent communication capabilities + +### 🚀 Agentic RL + +- Support using [Tinker](https://tinker-docs.thinkingmachines.ai/) backend to tune agent applications on devices without GPU. +- Support tuning agent applications based on their run history. +- Integrate with AgentScope Runtime to provide better environment abstraction. +- Add more tutorials and examples on how to build complex judge functions with the help of evaluation module. +- Add more tutorials and examples on data selection and augmentation. + +### 📈 Code Quality + +Continuous refinement and improvement of code quality and maintainability. + +# Completed Milestones + +### AgentScope V1.0.0 Roadmap + +We are deeply grateful for the continuous support from the open-source community that has witnessed AgentScope's +growth. Throughout our journey, we have maintained **developer-centric transparency** as our core principle, +which will continue to guide our future development. + +As the AI agent ecosystem rapidly evolves, we recognize the need to adapt AgentScope to meet emerging trends and +requirements. We are excited to announce the upcoming release of AgentScope v1.0.0, which marks a significant shift +towards deployment-focused and secondary development direction. This new version will provide comprehensive support for agent developers +with enhanced deployment capabilities and practical features. Specifically, the update will include: + +- ✨New Features + - 🛠️ Tool/MCP + - Support both sync/async tool functions + - Support streaming tool function + - Support parallel execution of tool functions + - Provide more flexible support for the MCP server + + - 💾 Memory + - Enhance the existing short-term memory + - Support long-term memory + + - 🤖 Agent + - Provide powerful ReAct-based out-of-the-box agents + +- 👨‍💻 Development + - Provide enhanced AgentScope Studio with visual components for developing, tracing and debugging + - Provide a built-in copilot for developing/drafting AgentScope applications + +- 🔍 Evaluation + - Provide built-in benchmarking and evaluation toolkit for agents + - Support result visualization + +- 🏗️ Deployment + - Support asynchronous agent execution + - Support session/state management + - Provide sandbox for tool execution + +Stay tuned for our detailed release notes and beta version, which will be available soon. Follow our GitHub +repository and official channels for the latest updates. We look forward to your valuable feedback and continued +support in shaping the future of AgentScope. \ No newline at end of file diff --git a/examples/agent_service/README.md b/examples/agent_service/README.md new file mode 100644 index 0000000..ada5455 --- /dev/null +++ b/examples/agent_service/README.md @@ -0,0 +1,78 @@ +# Agent Service + +Agent service is a FastAPI-based, multi-tenant and multi-session service built with AgentScope 2.0. + +This example demonstrates + +- how to set up the agent service with Redis storage, and +- how to launch the service and its companion Web UI + +Details about the agent service please refer to the [tutorial](https://docs.agentscope.io/latest/en/deploy/agent-service). + +## Prerequisites + +- Python ≥ 3.11 +- Node.js ≥ 20 with `npx` +- [optional] Gaode/AMap API key in `AMAP_API_KEY` (for the `amap` MCP) + +## Quickstart + +Install AgentScope from PyPI or source: + +```bash +uv pip install agentscope[full] +# or +# uv pip install -e [full] +``` + +Install Redis and start it as backend storage: + +```bash +# macOS (Homebrew) +brew install redis +brew services start redis + +# Linux (systemd) +sudo apt install redis-server +sudo systemctl start redis-server + +# Docker (cross-platform) +docker run --rm -p 6379:6379 redis:7 +``` + +Start the agent service: + +```bash +cd examples/agent_service + +python main.py +``` + +Launch the Web UI in a separate terminal to experience a chat-style interface: + +```bash +cd examples/web_ui/ + +pnpm install +# or npm install + +# Run in dev mode +pnpm dev +``` + +After that, you can set the API endpoint `http://localhost:8000` in the Web UI and start experiencing the agent service. + +Web UI Screenshot + +## What Next + +- You can customize the service in `main.py` by adding your own MCPs, middlewares, or workspace manager implementations. + +- Experience the agent service, including + - human-in-the-loop interactions & permission system +Permission System + + - schedule tasks +Schedule Tasks + + - and more! (stay tuned for future updates) \ No newline at end of file diff --git a/examples/agent_service/main.py b/examples/agent_service/main.py new file mode 100644 index 0000000..5b91325 --- /dev/null +++ b/examples/agent_service/main.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +"""The example script to start the agent service.""" +import os + +import uvicorn +from fastapi.middleware import Middleware +from fastapi.middleware.cors import CORSMiddleware + +from agentscope.app import create_app, SubAgentTemplate +from agentscope.app.message_bus import InMemoryMessageBus +from agentscope.app.rag.knowledge_base_manager import CollectionPerKbManager +from agentscope.app.storage import RedisStorage +from agentscope.app.workspace_manager import LocalWorkspaceManager +from agentscope.mcp import MCPClient, StdioMCPConfig, HttpMCPConfig +from agentscope.permission import PermissionContext, PermissionMode +from agentscope.rag import QdrantStore + +default_mcps = [ + MCPClient( + name="browser-use", + mcp_config=StdioMCPConfig( + command="npx", + args=["@playwright/mcp@latest"], + ), + is_stateful=True, + ), +] + +if os.getenv("AMAP_API_KEY"): + default_mcps.append( + MCPClient( + name="amap", + mcp_config=HttpMCPConfig( + url=f"https://mcp.amap.com/mcp?key=" + f"{os.environ['AMAP_API_KEY']}", + ), + is_stateful=False, + ), + ) + +storage = RedisStorage( + host="localhost", + port=6379, +) + +vector_store = QdrantStore(location=":memory:") + +app = create_app( + storage=storage, + message_bus=InMemoryMessageBus(), + # -- To use a Redis-backed message bus instead (recommended for + # -- multi-process / production deployments), uncomment the lines + # -- below and replace the InMemoryMessageBus() above: + # + # from agentscope.app.message_bus import RedisMessageBus + # message_bus=RedisMessageBus( + # host="localhost", + # port=6379, + # ), + workspace_manager=LocalWorkspaceManager( + basedir=os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "workspaces", + ), + # The default MCP servers that will be added into the workspace + default_mcps=default_mcps, + ), + # Knowledge base feature — backed by an in-memory Qdrant store. The + # CollectionPerKbManager allocates one collection per knowledge base, + # so any embedding dimension is allowed. + knowledge_base_manager=CollectionPerKbManager( + storage=storage, + vector_store=vector_store, + ), + # Customize your own subagent templates + custom_subagent_templates=[ + SubAgentTemplate( + type="explorer", + description=( + "Read-only agents specialized in exploration tasks. It can " + "read files but cannot modify, create, or delete them. Use " + "this agent type when you need to investigate the codebase, " + "understand its structure, or gather information from files " + "to support planning—without making any changes." + ), + system_prompt_template="""You are {member_name}, an explorer \ +agent in team '{team_name}' led by {leader_name}. + +Team purpose: {team_description} + +Your role: {member_description} + +## Responsibilities +- Complete the exploration tasks assigned by the team leader. +- You are read-only: you may inspect files and the codebase, but you must \ +never modify, create, or delete anything. + +## Reporting +- Always report the task result back to {leader_name} using the TeamSay \ +tool, whether the task succeeds or fails. +- Keep your private reasoning private; only share conclusions and findings \ +that the leader needs. + +Note: `TeamSay` is your ONLY channel to communicate with {leader_name} and \ +the other team members. Any other output you produce is invisible to them, \ +so anything you want them to see MUST be sent through `TeamSay`.""", + permission_context=PermissionContext( + # Read-only + mode=PermissionMode.EXPLORE, + ), + ), + ], + extra_middlewares=[ + Middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], + ), + ], +) + + +if __name__ == "__main__": + # Start the service + uvicorn.run( + "main:app", + host="0.0.0.0", + port=8000, + reload=True, + ) diff --git a/examples/long_term_memory/agentic_memory/README.md b/examples/long_term_memory/agentic_memory/README.md new file mode 100644 index 0000000..cdf2daf --- /dev/null +++ b/examples/long_term_memory/agentic_memory/README.md @@ -0,0 +1,83 @@ +# Agentic Memory Middleware + +This example demonstrates `AgenticMemoryMiddleware`, a long-term memory middleware backed by human-readable Markdown files. + +No vector database or embedding model is required. + +## What the demo shows + +`main.py` runs a single Agent against one workspace directory for two turns: + +1. **Turn 1 — persist** + - Receives mock user input containing durable user information. + - Is explicitly asked to remember that information. + - Uses the built-in `Read` / `Write` tools to create or update files under `demo_workspace/Memory`. + +2. **Turn 2 — recall** + - The same Agent instance is asked about the earlier user information. + - Answers from the Markdown memory files persisted on disk by the middleware. + +After the first turn, the script prints the generated Markdown files so you can inspect exactly what was persisted. + +## Quickstart + +Install the dependencies by the following commands: + +```bash +git clone -b main https://github.com/agentscope-ai/agentscope + +uv pip install agentscope +# or from source +# uv pip install -e . +``` + +Run the example with the commands: + +```bash +cd agentscope/examples/long_term_memory/agentic_memory +export DASHSCOPE_API_KEY=sk-...; python main.py +``` + +The demo workspace is created at: + +```text +examples/long_term_memory/agentic_memory/demo_workspace/ +``` + +## Markdown layout + +The middleware creates this directory automatically: + +```text +/Memory/ +`-- MEMORY.md +``` + +The Agent should write each durable memory into its own Markdown file with frontmatter, then add a short pointer to `MEMORY.md`: + +```markdown +--- +name: User profile +description: User lives in Hangzhou and prefers concise Chinese answers +type: user +--- + +Alice Chen lives in Hangzhou and prefers concise Chinese answers. +``` + +`MEMORY.md` is an index, not the memory body: + +```markdown +- [User profile](user_profile.md) — User location and answer-style preference. +``` + +On future turns, `MEMORY.md` is always included in the system prompt. The middleware can then select relevant topic files by filename and frontmatter description and inject their contents as a hint. + +## Notes + +- Memory is workspace-scoped: reuse the same `workdir` to reuse the same Markdown memory. +- A fresh Agent instance can still recall previous facts because they are stored on disk, not in `Agent.state`. +- The Agent is responsible for deciding what to save when the user asks it to remember something. +- `MEMORY.md` should stay concise because it is included in every system prompt. +- Topic files are ordinary Markdown and can be inspected, edited, committed, copied, or deleted with normal filesystem tools. + diff --git a/examples/long_term_memory/agentic_memory/main.py b/examples/long_term_memory/agentic_memory/main.py new file mode 100644 index 0000000..f16310f --- /dev/null +++ b/examples/long_term_memory/agentic_memory/main.py @@ -0,0 +1,238 @@ +# -*- coding: utf-8 -*- +"""AgenticMemoryMiddleware end-to-end demo. + +The demo uses a single Agent with the filesystem-backed long-term memory +middleware and the built-in ``Read`` / ``Write`` tools across two turns: + +1. The Agent receives mock user input that explicitly asks it to remember + durable user information. The middleware injects memory instructions and the + Agent writes Markdown files under ``demo_workspace/Memory``. +2. The same Agent is then asked to recall the earlier user information. The + answer is grounded by the Markdown files persisted on disk by the + middleware. + +Requires: + pip install agentscope + export DASHSCOPE_API_KEY=sk-... +""" +import asyncio +import os +import shutil +from pathlib import Path + +from pydantic import SecretStr + +from agentscope.agent import Agent +from agentscope.credential import DashScopeCredential +from agentscope.event import ( + TextBlockDeltaEvent, + ToolCallDeltaEvent, + ToolCallStartEvent, + ToolResultEndEvent, + ToolResultTextDeltaEvent, +) +from agentscope.message import UserMsg +from agentscope.middleware import AgenticMemoryMiddleware +from agentscope.model import DashScopeChatModel +from agentscope.permission import AdditionalWorkingDirectory, PermissionMode +from agentscope.tool import Read, Toolkit, Write + + +RESET_DEMO_WORKSPACE = True +DEMO_ROOT = Path(__file__).with_name("demo_workspace") + +FIRST_USER_MESSAGE = """ +Please remember these durable facts for future conversations in this +workspace: + +- My name is Alice Chen. +- I live in Hangzhou. +- I prefer concise Chinese answers. +- When evaluating examples, I like seeing a fresh Agent instance prove that + long-term memory was persisted outside the current conversation state. + +Use the filesystem memory instructions in your system prompt: create or update +a topic Markdown memory file with frontmatter, and update MEMORY.md with a +short pointer to that file. Read MEMORY.md first if you need to update it. +""".strip() + +SECOND_USER_MESSAGE = """ +What do you remember about my name, location, answer style, and how I like +examples to demonstrate long-term memory? Read the relevant memory files if +you need details before answering. +""".strip() + + +def _configure_demo_permissions(agent: Agent, workspace_root: Path) -> None: + """Allow the demo Agent to read and write inside the demo workspace. + + Args: + agent (`Agent`): + The Agent whose permission context should be configured. + workspace_root (`Path`): + The directory containing the demo memory files. + """ + agent.state.permission_context.mode = PermissionMode.ACCEPT_EDITS + agent.state.permission_context.working_directories[ + str(workspace_root) + ] = AdditionalWorkingDirectory( + path=str(workspace_root), + source="file-system-memory-demo", + ) + + +def _build_agent(model: DashScopeChatModel, workspace_root: Path) -> Agent: + """Build a fresh Agent attached to one filesystem memory workspace. + + Args: + model (`DashScopeChatModel`): + The chat model used by both the Agent and memory relevance + selection. + workspace_root (`Path`): + The directory that stores ``Memory/MEMORY.md`` and topic files. + + Returns: + `Agent`: + A newly initialized Agent instance. + """ + memory = AgenticMemoryMiddleware(workdir=str(workspace_root)) + agent = Agent( + name="memory_assistant", + system_prompt=( + "You are a concise assistant. When the user asks you to remember " + "durable preferences or profile facts, persist them using the " + "filesystem memory instructions. Use the Read and Write tools for " + "memory files." + ), + model=model, + toolkit=Toolkit(tools=[Read(), Write()]), + middlewares=[memory], + ) + _configure_demo_permissions(agent, workspace_root) + return agent + + +async def _run_turn(agent: Agent, text: str) -> str: + """Run one streamed turn and print tool activity. + + Args: + agent (`Agent`): + The Agent to run. + text (`str`): + The user message. + + Returns: + `str`: + The concatenated assistant text response. + """ + tool_names: dict[str, str] = {} + tool_args: dict[str, str] = {} + tool_results: dict[str, str] = {} + reply_parts: list[str] = [] + + async for event in agent.reply_stream(UserMsg("alice", text)): + if isinstance(event, ToolCallStartEvent): + tool_names[event.tool_call_id] = event.tool_call_name + tool_args[event.tool_call_id] = "" + tool_results[event.tool_call_id] = "" + elif isinstance(event, ToolCallDeltaEvent): + tool_args[event.tool_call_id] += event.delta + elif isinstance(event, ToolResultTextDeltaEvent): + tool_results[event.tool_call_id] += event.delta + elif isinstance(event, ToolResultEndEvent): + tool_id = event.tool_call_id + name = tool_names.pop(tool_id, "") + arguments = tool_args.pop(tool_id, "") + result = tool_results.pop(tool_id, "") + print(f"[tool] {name}({arguments}) -> {event.state}") + for line in result.splitlines(): + print(f" {line}") + elif isinstance(event, TextBlockDeltaEvent): + reply_parts.append(event.delta) + + return "".join(reply_parts) + + +def _print_memory_files(workspace_root: Path) -> None: + """Print the Markdown files persisted by the memory middleware. + + Args: + workspace_root (`Path`): + The demo workspace root. + """ + memory_root = workspace_root / "Memory" + print(f"\n[Markdown memory files] {memory_root}") + if not memory_root.exists(): + print(" The Memory directory has not been created yet.") + return + + for path in sorted(memory_root.rglob("*.md")): + relative = path.relative_to(workspace_root) + print(f"\n--- {relative} ---") + print(path.read_text(encoding="utf-8").strip()) + + +def _print_soft_verification(workspace_root: Path) -> None: + """Print a lightweight check that expected memory keywords were saved. + + Args: + workspace_root (`Path`): + The demo workspace root. + """ + memory_root = workspace_root / "Memory" + combined = ( + "\n".join( + path.read_text(encoding="utf-8", errors="replace") + for path in sorted(memory_root.rglob("*.md")) + ) + if memory_root.exists() + else "" + ) + checks = { + "MEMORY.md exists": (memory_root / "MEMORY.md").exists(), + "mentions Alice Chen": "Alice Chen" in combined, + "mentions Hangzhou": "Hangzhou" in combined, + "mentions concise Chinese answers": ( + "concise Chinese" in combined or "Chinese answers" in combined + ), + } + + print("\n[Soft verification]") + for label, ok in checks.items(): + print(f" {'PASS' if ok else 'WARN'} - {label}") + + +async def main() -> None: + """Run the agentic memory demo.""" + api_key = os.environ["DASHSCOPE_API_KEY"] + + if RESET_DEMO_WORKSPACE: + print(f"=== resetting demo workspace: {DEMO_ROOT} ===") + shutil.rmtree(DEMO_ROOT, ignore_errors=True) + else: + print(f"=== reusing demo workspace: {DEMO_ROOT} ===") + DEMO_ROOT.mkdir(parents=True, exist_ok=True) + + model = DashScopeChatModel( + credential=DashScopeCredential(api_key=SecretStr(api_key)), + model="qwen3.7-max", + stream=False, + ) + + print("\n=== Turn 1: ask the Agent to persist user memory ===") + agent = _build_agent(model, DEMO_ROOT) + print(f"[user]\n{FIRST_USER_MESSAGE}\n") + first_reply = await _run_turn(agent, FIRST_USER_MESSAGE) + print(f"\n[assistant]\n{first_reply}") + + _print_memory_files(DEMO_ROOT) + _print_soft_verification(DEMO_ROOT) + + print("\n=== Turn 2: ask the same Agent to recall memory ===") + print(f"[user]\n{SECOND_USER_MESSAGE}\n") + second_reply = await _run_turn(agent, SECOND_USER_MESSAGE) + print(f"\n[assistant]\n{second_reply}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/long_term_memory/mem0/README.md b/examples/long_term_memory/mem0/README.md new file mode 100644 index 0000000..2c102a8 --- /dev/null +++ b/examples/long_term_memory/mem0/README.md @@ -0,0 +1,387 @@ +# mem0 middleware example + +One runnable demo (`oss_demo.py`) showing the +[mem0](https://github.com/mem0ai/mem0) middleware plugged into an +`agentscope.agent.Agent`. Drives two consecutive agent sessions for +the same `user_id` so mem0's cross-session memory effect is visible, +and prints each middleware contribution (retrieval / tool call / +write-back) inline so you can see when each path fires. + +The demo defaults to the **OSS backend** (open-source mem0, +self-hosted via local Qdrant) with mem0 driven by AgentScope's own +DashScope chat + embedding model — no separate OpenAI key needed +by mem0. To run it against the hosted **mem0 +Platform** instead, swap the `Mem0Middleware(...)` construction for +the alternative shown inline (look for the +``# For the hosted mem0 Platform, swap …`` comment in `oss_demo.py`) +— the rest of the demo is identical. + +## Install + +```bash +# mem0 is an optional AgentScope dependency — pull it via the extra: +pip install "agentscope[mem0]" # resolves to mem0ai>=2.0.0,<3.0.0 +# (equivalent to `pip install agentscope mem0ai>=2.0.0,<3.0.0`) + +export DASHSCOPE_API_KEY=sk-... # OSS path +# Platform path (only if you switch): +# export MEM0_API_KEY=m0-... +# export OPENAI_API_KEY=sk-... # only needed if your agent's chat model is OpenAI +``` + +## Import path + +`Mem0Middleware` is exported from the middleware package: + +```python +from agentscope.middleware import Mem0Middleware +from agentscope.tool import Toolkit +``` + +## Three construction paths + +```python +# 1. Models — build a local OSS AsyncMemory wired to your AgentScope +# chat + embedding model. mem0 defaults for everything else. +Mem0Middleware( + user_id="alice", + chat_model=my_chat_model, + embedding_model=my_embedding_model, + mode="both", +) + +# 2. Models + custom mem0_config — same as (1), but start from your +# customized MemoryConfig (custom vector store, history DB, +# reranker, ...). `chat_model` / `embedding_model` always WIN: +# if mem0_config already specifies an .llm or .embedder, it gets +# OVERWRITTEN by the AgentScope adapter built from your model. +# Every other field of mem0_config (vector_store, history_db_path, +# reranker, etc.) is preserved as-is. +Mem0Middleware( + user_id="alice", + chat_model=my_chat_model, + embedding_model=my_embedding_model, + mem0_config=MemoryConfig( + vector_store=VectorStoreConfig( + provider="qdrant", + config={"host": "my-qdrant", "port": 6333}, + ), + history_db_path="/data/mem0_history.db", + ), + mode="both", +) + +# 3. Client — bring your own pre-built mem0 client. Accepts EITHER +# backend: `mem0.AsyncMemory` (open-source / self-hosted) or +# `mem0.AsyncMemoryClient` (hosted Platform). Use this when you +# want full control over the mem0 setup — custom subclass, a +# pre-warmed client shared across many agents, exotic config +# that doesn't fit the `build_mem0_config` helper, etc. +# +# OSS backend (you assemble the AsyncMemory yourself): +Mem0Middleware( + user_id="alice", + client=AsyncMemory(), # or AsyncMemory.from_config({...}) + mode="both", +) + +# Hosted Platform backend: +Mem0Middleware( + user_id="alice", + client=AsyncMemoryClient(api_key="m0-..."), + mode="both", +) +``` + +Precedence and validation matrix: + +| `client` | `mem0_config` | `chat_model` | `embedding_model` | Behavior | +|:-:|:-:|:-:|:-:|---| +| ✓ | — | — | — | Use `client` as-is. | +| ✓ | any | any | any | Use `client`; the other three are ignored, and a `WARNING` log lists which kwargs got dropped. | +| — | ✓ | — | — | Wrap `mem0_config` in an `AsyncMemory`, no overrides. | +| — | ✓ | ✓ | — | Wrap + override `.llm` with the AgentScope adapter; keep `.embedder` from `mem0_config`. | +| — | ✓ | — | ✓ | Wrap + override `.embedder` only; keep `.llm` from `mem0_config`. | +| — | ✓ | ✓ | ✓ | Wrap + override both `.llm` and `.embedder` (other fields of `mem0_config` preserved). | +| — | — | ✓ | ✓ | Build a fresh `MemoryConfig` (mem0 defaults for vector store / history DB) with the AgentScope adapters wired in. | +| — | — | ✓ | — | ❌ `ValueError` — `chat_model` and `embedding_model` must be passed together when `mem0_config` is omitted. | +| — | — | — | ✓ | ❌ Same. | +| — | — | — | — | ❌ `ValueError` — need one of: `client`, `mem0_config`, or both `chat_model` + `embedding_model`. | + +Why the "client wins" and "config override" paths exist: + +- **`client` wins** lets one `Mem0Middleware(...)` call + shape work for both library callers (who pass AgentScope models) + and production setups (who supply a pre-built `client`). The + `WARNING` log makes any mismatch visible without crashing. +- **Config override of `mem0_config.llm` / `.embedder`** lets you + keep one canonical `MemoryConfig` template (custom vector store, + history DB, reranker, …) and swap just the LLM / embedder per + call site by passing `chat_model` / `embedding_model`. + +## How the middleware controls memory + +The `mode` parameter selects one of three patterns. They differ by +**what the LLM sees** and **what fires automatically**: + +### `static_control` +The middleware does the work, the agent is unaware. Mirroring +AgentScope 1.x's `ReActAgent._retrieve_from_long_term_memory`: + +1. **`on_reply` (pre)** queries mem0 with the latest user message + and pre-fetches the results. +2. **At `ReplyStartEvent`** — which fires right after the agent has + ingested the new user input into `state.context` and before the + reasoning loop starts — the middleware appends an + `AssistantMsg(name="memory", ...)` to `state.context`. This puts + the memory note IMMEDIATELY after the user's new message, matching + v1's placement (it ran right after `self.memory.add(msg)`). +3. **`on_reply` (post)** writes the new `(user, assistant)` exchange + back to mem0. + +The injected memory message **persists** in the agent's context +across turns. Long sessions accumulate one per turn that retrieved +anything; if that becomes a token concern, post-process with +`compress_context` or write your own middleware to pop them. + +### `agent_control` +The middleware lists two tools — `search_memory(keywords, limit)` and +`add_memory(thinking, content)` — and otherwise stays out of the way. +Pass them into the agent's toolkit explicitly when constructing the +agent: + +```python +mw = Mem0Middleware(..., mode="agent_control") +agent = Agent( + ..., + toolkit=Toolkit(tools=await mw.list_tools()), + middlewares=[mw], +) +``` + +The system prompt gets a short nudge telling the agent that memory +tools exist; the actual per-tool usage guidance comes through the +standard tool schema. No automatic retrieval or write-back. + +### `both` (default) +Both patterns are active simultaneously: memories are auto-retrieved +and appended to the agent's context as an assistant note, AND the +tools (with their system-prompt hint) are exposed for explicit +on-demand search / save. This matches AgentScope 1.x's +`ReActAgent.long_term_memory_mode` default. + +## Sharing one middleware across agents + +The local OSS mem0 backend uses on-disk Qdrant by default, and Qdrant +takes an **exclusive lock** on the storage folder +(``/tmp/qdrant`` by default). Two ``Mem0Middleware`` instances each +built from ``chat_model`` + ``embedding_model`` would each construct +their own ``AsyncMemory`` → second one crashes on the lock: + +``` +RuntimeError: Storage folder /tmp/qdrant is already accessed by +another instance of Qdrant client. +``` + +Fix: build **one** ``Mem0Middleware`` instance and pass it to every +agent that should share the same memory namespace: + +```python +mw = Mem0Middleware( + user_id="alice", + chat_model=chat_model, + embedding_model=embedding_model, + mode="both", +) +agent_a = Agent( + ..., + toolkit=Toolkit(tools=await mw.list_tools()), + middlewares=[mw], +) +agent_b = Agent( + ..., + toolkit=Toolkit(tools=await mw.list_tools()), + middlewares=[mw], +) +``` + +This is what the demo does. The memory tools receive the live +`AgentState` at call time, and the middleware resolves the active +agent by `state.session_id`, so sharing one middleware across agents +is safe. + +If you genuinely need a separate Qdrant store per agent, pass a +``mem0_config`` with a distinct ``vector_store.config.path`` or +``collection_name`` for each one. + +### Recommended: run Qdrant in Docker (especially on Windows) + +The local on-disk Qdrant works for single-process demos but is +brittle in real deployments — and **outright painful on Windows**, +where the filesystem-lock semantics differ from Unix and the +exclusive-lock failure mode is harder to recover from. For anything +beyond a single-process Linux/macOS sandbox, run Qdrant as a service: + +```bash +docker run -p 6333:6333 -p 6334:6334 \ + -v $(pwd)/qdrant_storage:/qdrant/storage \ + qdrant/qdrant +``` + +Then point mem0 at it instead of the on-disk path: + +```python +from mem0.configs.base import MemoryConfig +from mem0.vector_stores.configs import VectorStoreConfig + +mem0_cfg = MemoryConfig( + vector_store=VectorStoreConfig( + provider="qdrant", + config={ + "collection_name": "mem0", + "host": "localhost", # the Docker container + "port": 6333, + "embedding_model_dims": 1536, + }, + ), +) +Mem0Middleware( + user_id="alice", + chat_model=chat_model, + embedding_model=embedding_model, + mem0_config=mem0_cfg, +) +``` + +Benefits over on-disk: + +- No file-lock contention — multiple Python processes can connect. +- Survives across runs without manual file cleanup. +- Same shape works for remote Qdrant (Qdrant Cloud, your own + Kubernetes deployment) — just change ``host`` / ``port`` / + ``api_key``. + +## Memory scoping (`user_id` × `agent_id`) + +mem0 tags every stored memory with the `user_id` and `agent_id` +filter values passed at `add` time, and searches by AND-matching those +tags. The middleware exposes the agent dimension via the +`scope_search_by_agent` flag (default `True`): + +| `scope_search_by_agent` | What `add` tags the memory with | What `search` filters by | Effect | +| --- | --- | --- | --- | +| `True` (default) | `user_id` + `agent_id` | `user_id` + `agent_id` | Strict per-agent silos. Agent A's memories invisible to agent B for the same user. | +| `False` | `user_id` + `agent_id` (unchanged) | `user_id` only | Read-broad, write-narrow. All agents for the same user share a memory pool, but each memory still records which agent wrote it (visible in mem0 metadata). | + +`agent_id` defaults to `agent.name`. Override via `agent_id="..."` or +`agent_id=lambda agent: ...` on the middleware constructor. + +When to relax `scope_search_by_agent`: + +- One user has multiple specialized agents (research / coding / + scheduling) that should benefit from each other's discoveries about + the user. +- An agent's `name` might change across deployments but you want the + memory to persist across name changes. + +### A note on agent-centric extraction (currently unreachable) + +mem0 v2's extraction prompt +([`ADDITIVE_EXTRACTION_PROMPT`](https://github.com/mem0ai/mem0/blob/main/mem0/configs/prompts.py)) +has a conditional suffix that switches framing from user-centric +("User stated X") to **agent-centric** ("Agent was informed of X" / +"Agent recommended Y"). It's gated on +`is_agent_scoped = bool(filters.agent_id) and not filters.user_id` — +i.e. only when `agent_id` is provided *without* `user_id`. The +middleware always passes `user_id` (it's a required constructor arg), +so this agent-centric suffix is unreachable through `Mem0Middleware` +today. In practice that's fine — agent persona / configuration is +usually expressed via system prompt rather than long-term memory. + +## Service-mode integration (`agentscope.app`) + +The demos above use the **library mode** — you construct `Agent` +yourself and pass `Mem0Middleware` into its `middlewares=[...]`. For +production deployments via `agentscope.app` (the FastAPI service +layer), the `user_id` already flows through the framework from the +`X-User-ID` HTTP header. Hook in through the +[`extra_agent_middlewares`](../../../../src/agentscope/app/_types.py) +factory: + +```python +from agentscope.app import create_app +from agentscope.middleware import Mem0Middleware +from agentscope.middleware._longterm_memory._mem0._agentscope_adapter \ + import build_mem0_config +from mem0 import AsyncMemory + +# Build the mem0 client ONCE at module scope — local OSS Qdrant +# takes an exclusive lock on its storage folder; per-request +# construction would deadlock under concurrent traffic. +chat_model = ... # shared AgentScope ChatModelBase +emb_model = ... # shared AgentScope EmbeddingModelBase +mem0_client = AsyncMemory( + config=build_mem0_config( + chat_model=chat_model, + embedding_model=emb_model, + ), +) + + +async def long_term_memory_factory( + user_id: str, # ← from the authenticated X-User-ID header + agent_id: str, + session_id: str, +) -> list: + return [ + Mem0Middleware( + user_id=user_id, + client=mem0_client, # shared across all requests + mode="both", + ), + ] + + +app = create_app( + ..., + extra_agent_middlewares=long_term_memory_factory, +) +``` + +Key points: + +- The factory is `async (user_id, agent_id, session_id) -> + list[MiddlewareBase]`, called **once per agent assembly** + (i.e. per chat turn / scheduled trigger). It returns fresh + `Mem0Middleware` instances each time, but they share a single + underlying mem0 client. +- `user_id` is the authenticated caller, injected by `agentscope.app` + via `get_current_user_id` (currently from `X-User-ID` header; will + become JWT-based when auth lands upstream). You forward it straight + to `Mem0Middleware(user_id=user_id, ...)` — no resolver callable + needed. +- For hosted mem0 Platform, swap the `AsyncMemory(config=...)` + construction for `AsyncMemoryClient(api_key=...)` — same factory + shape, no Qdrant lock concern. + +## Notes on the AgentScope-as-mem0-backend path + +When you pass `chat_model` + `embedding_model`, the middleware +internally: + +1. Registers `AgentScopeLLM` / `AgentScopeEmbedding` in mem0's factory + dicts under provider name `"agentscope"`. +2. Substitutes `LlmConfig` / `EmbedderConfig` with subclasses whose + validator allows `"agentscope"` (mem0's stock validator hardcodes a + whitelist that doesn't include us). Other providers continue to be + rejected with mem0's original error. +3. Builds an `AsyncMemory` whose `.llm` and `.embedding_model` route + through the AgentScope adapters. +4. Bridges mem0's sync API onto AgentScope's async models via a + persistent background event loop, so async clients (e.g. Ollama's + `AsyncClient`) keep their connection pool across calls. + +Your embedding model's `dimensions` must match the vector store's +expected dim — mem0's default Qdrant expects 1536, which matches +DashScope's `text-embedding-v2` at `dimensions=1536` (the value used +in `oss_demo.py`). diff --git a/examples/long_term_memory/mem0/oss_demo.py b/examples/long_term_memory/mem0/oss_demo.py new file mode 100644 index 0000000..737d643 --- /dev/null +++ b/examples/long_term_memory/mem0/oss_demo.py @@ -0,0 +1,325 @@ +# -*- coding: utf-8 -*- +"""Mem0 middleware demo (open-source mem0, AgentScope-driven). + +Drives two independent agent sessions for the same ``user_id`` so +mem0's cross-session memory effect is visible. Each turn streams +events from ``agent.reply_stream`` and prints the ones that matter: + +- ``[mem0 → context (static)]`` — the memory note the middleware + appended to ``state.context`` (fires at ``ReplyStartEvent``, before + any tool call, so the output order matches the data flow) +- ``[tool call (agent)]`` — each ``search_memory`` / ``add_memory`` + invocation the agent makes on its own +- ``[assistant]`` — the assistant's reply, concatenated from the + ``TextBlockDeltaEvent`` stream +- ``[context → mem0 (static)]`` — facts the middleware wrote back + after the turn + +The mode tag (``static`` / ``agent``) on each line tells you which +control path produced it. + +Starts each run from a clean mem0 store (for ``user_id``) so the +demo is reproducible. + +Requires: + pip install agentscope mem0ai + export DASHSCOPE_API_KEY=sk-... +""" +import asyncio +import logging +import os +import shutil + +from mem0 import AsyncMemory +from mem0.configs.base import MemoryConfig +from mem0.vector_stores.configs import VectorStoreConfig + +from agentscope.agent import Agent +from agentscope.credential import DashScopeCredential +from agentscope.embedding import DashScopeEmbeddingModel +from agentscope.event import ( + ReplyStartEvent, + TextBlockDeltaEvent, + ToolCallDeltaEvent, + ToolCallStartEvent, + ToolResultEndEvent, + ToolResultTextDeltaEvent, +) +from agentscope.message import UserMsg +from agentscope.middleware import Mem0Middleware +from agentscope.model import DashScopeChatModel +from agentscope.tool import Toolkit + + +MODE = "both" # try "static_control" or "agent_control" too + + +# Silence mem0's noisy init warnings (qdrant migration notice, spaCy +# missing, fastembed missing) — they're informational, not actionable. +logging.getLogger("mem0").setLevel(logging.ERROR) + + +async def _facts_in_mem0(client: AsyncMemory, user_id: str) -> list[str]: + """Return mem0's current facts for ``user_id`` as plain strings.""" + res = await client.get_all(filters={"user_id": user_id}) + items = res.get("results", res) if isinstance(res, dict) else res + out: list[str] = [] + for m in items: + if isinstance(m, dict): + text = m.get("memory") + if isinstance(text, str) and text: + out.append(text) + return out + + +def _injected_memory_bullets(agent: Agent) -> list[str]: + """Extract the bullet lines from the memory note the middleware + appended to ``agent.state.context`` (if any) — strips the section + header and intro so we see only the actual retrieved facts.""" + for msg in agent.state.context: + if getattr(msg, "name", None) != "memory": + continue + hint_text = "\n".join( + block.hint for block in msg.get_content_blocks("hint") + ) + return [ + line[2:].strip() + for line in hint_text.splitlines() + if line.startswith("- ") + ] + return [] + + +async def _run_turn(agent: Agent, user_msg: UserMsg) -> str: + """Drive one reply turn through ``agent.reply_stream`` and print + each middleware contribution as it happens, in the order it + happens: + + 1. ``ReplyStartEvent`` fires right after the agent ingests the new + user input AND the middleware has appended any retrieved memory + note to ``state.context`` (static path) — so this is the right + place to surface ``[mem0 → context]``. + 2. ``ToolCallStartEvent`` / ``ToolResultEndEvent`` bracket each + ``search_memory`` / ``add_memory`` invocation the agent makes + on its own (agent path). + 3. ``TextBlockDeltaEvent`` carries the assistant's streamed reply + text — concatenating every delta yields the final message + content (there is no separate "final Msg" reply_stream + withholds). + + Each printed line is tagged with the mem0 control path that + produced it (``static`` vs ``agent``) so the demo stays readable + in any of the three modes. + """ + pending_args: dict[str, str] = {} + pending_names: dict[str, str] = {} + pending_results: dict[str, str] = {} + text_parts: list[str] = [] + memory_announced = False + + async for ev in agent.reply_stream(inputs=user_msg): + if isinstance(ev, ReplyStartEvent) and not memory_announced: + injected = _injected_memory_bullets(agent) + print( + f"[mem0 → context (static)] retrieved " + f"{len(injected)} memory note(s):", + ) + for b in injected: + print(f" ← {b}") + memory_announced = True + elif isinstance(ev, ToolCallStartEvent): + pending_names[ev.tool_call_id] = ev.tool_call_name + pending_args[ev.tool_call_id] = "" + pending_results[ev.tool_call_id] = "" + elif isinstance(ev, ToolCallDeltaEvent): + pending_args[ev.tool_call_id] += ev.delta + elif isinstance(ev, ToolResultTextDeltaEvent): + pending_results[ev.tool_call_id] += ev.delta + elif isinstance(ev, ToolResultEndEvent): + name = pending_names.pop(ev.tool_call_id, "") + args = pending_args.pop(ev.tool_call_id, "") + result = pending_results.pop(ev.tool_call_id, "") + # ev.state is a StrEnum at the type level but pydantic + # may have deserialized it to a plain str; f-string both + # cases yields the same value string. + print(f"[tool call (agent)] {name}({args}) → state={ev.state}") + for line in result.splitlines() or [""]: + if line: + print(f" → {line}") + elif isinstance(ev, TextBlockDeltaEvent): + text_parts.append(ev.delta) + + return "".join(text_parts) + + +async def main() -> None: + """Drive two cross-session agent turns and print middleware effects.""" + api_key = os.environ["DASHSCOPE_API_KEY"] + user_id = "alice" + + # Wipe mem0's local files BEFORE constructing the client so each + # demo run starts clean. We don't use ``mem0_client.delete_all()`` + # because qdrant-client's local SQLite layer has a race under + # mem0's parallel ``asyncio.gather`` deletes (sqlite3.InterfaceError). + qdrant_path = "/tmp/qdrant" # matches the vector_store config below + history_db = os.path.expanduser("~/.mem0/history.db") + print("=== resetting mem0 local state ===") + print(f" rm -rf {qdrant_path}") + shutil.rmtree(qdrant_path, ignore_errors=True) + print(f" rm -f {history_db}") + try: + os.remove(history_db) + except FileNotFoundError: + pass + + # `stream` can be True or False — the AgentScope→mem0 adapter + # drains an async generator and uses the last chunk (which carries + # the full accumulated content per AgentScope's streaming contract). + # The agent's own `reply_stream` still emits per-delta events + # regardless of this setting, so streaming-mode does not change the + # demo's printed output shape. + chat_model = DashScopeChatModel( + credential=DashScopeCredential(api_key=api_key), + model="qwen3.7-max", + stream=False, + ) + embedding_model = DashScopeEmbeddingModel( + credential=DashScopeCredential(api_key=api_key), + model="text-embedding-v4", + dimensions=1536, # matches mem0's Qdrant default + ) + + # Explicit vector-store config (here we just spell out mem0's + # default local Qdrant — collection ``mem0``, on-disk at + # ``/tmp/qdrant``, 1536-d vectors). Override any of these for + # remote Qdrant, alternate collection names, etc. Pass the + # ``MemoryConfig`` through ``mem0_config=`` and the middleware + # keeps everything you set, only swapping ``.llm`` and + # ``.embedder`` to route through your AgentScope models. + # + # Recommended for Windows users and any production setup: run + # Qdrant in Docker and connect over the network instead of using + # the local on-disk backend. Local on-disk Qdrant takes an + # exclusive file lock that's brittle on Windows (different + # filesystem-lock semantics) and breaks under concurrent agent + # instances. To switch: + # + # docker run -p 6333:6333 -p 6334:6334 \\ + # -v $(pwd)/qdrant_storage:/qdrant/storage \\ + # qdrant/qdrant + # + # vector_store=VectorStoreConfig( + # provider="qdrant", + # config={ + # "collection_name": "mem0", + # "host": "localhost", # Docker container + # "port": 6333, + # "embedding_model_dims": 1536, + # }, + # ) + # + # (You'd also drop the ``shutil.rmtree(qdrant_path)`` wipe above — + # state lives in the Docker volume, not the local filesystem.) + mem0_cfg = MemoryConfig( + vector_store=VectorStoreConfig( + provider="qdrant", + config={ + "collection_name": "mem0", + "path": qdrant_path, + "embedding_model_dims": 1536, + "on_disk": False, + }, + ), + ) + + mw = Mem0Middleware( + user_id=user_id, + agent_id="datascope_assistant", + chat_model=chat_model, + embedding_model=embedding_model, + mem0_config=mem0_cfg, + mode=MODE, + top_k=5, + ) + # For the hosted mem0 Platform, swap the construction above for: + # + # from mem0 import AsyncMemoryClient + # mw = Mem0Middleware( + # user_id=user_id, + # client=AsyncMemoryClient(api_key=os.environ["MEM0_API_KEY"]), + # mode=MODE, + # ) + # + # No local Qdrant / vector_store config needed — extraction and + # storage all happen in mem0's cloud service. + # pylint: disable-next=protected-access + mem0_client: AsyncMemory = mw._client # demo only — peek at the + # constructed client to inspect mem0 state between turns. + + # ================================================================= + # SESSION 1 + # ================================================================= + print(f"\n=== SESSION 1 (mode={MODE!r}) ===") + user_msg_1 = ( + "Hi! For any chart, please default to dark mode and use " + "matplotlib. Also I'm based in Hangzhou." + ) + print(f"\n[user] {user_msg_1}\n") + + before = await _facts_in_mem0(mem0_client, user_id) + + agent = Agent( + name="datascope_assistant", + system_prompt=( + "You are a helpful data-analysis assistant. Be concise. " + "If you learn a durable user preference, save it with " + "the add_memory tool when one is available." + ), + model=chat_model, + toolkit=Toolkit(tools=await mw.list_tools()), + middlewares=[mw], + ) + reply_text = await _run_turn(agent, UserMsg("alice", user_msg_1)) + print(f"\n[assistant] {reply_text}") + + after = await _facts_in_mem0(mem0_client, user_id) + new = [f for f in after if f not in before] + print(f"\n[context → mem0 (static)] extracted {len(new)} new fact(s):") + for f in new: + print(f" + {f}") + + # ================================================================= + # SESSION 2 — fresh Agent, empty chat context. mem0 should bridge. + # ================================================================= + print(f"\n=== SESSION 2 (fresh agent, mem0 bridges; mode={MODE!r}) ===") + user_msg_2 = ( + "Plot me a bar chart of monthly sales — pick reasonable " + "defaults for theme and library." + ) + print(f"\n[user] {user_msg_2}\n") + + before = await _facts_in_mem0(mem0_client, user_id) + + agent = Agent( + name="datascope_assistant", + system_prompt=( + "You are a helpful data-analysis assistant. Be concise. " + "If you learn a durable user preference, save it with " + "the add_memory tool when one is available." + ), + model=chat_model, + toolkit=Toolkit(tools=await mw.list_tools()), + middlewares=[mw], + ) + reply_text = await _run_turn(agent, UserMsg("alice", user_msg_2)) + print(f"\n[assistant] {reply_text}") + + after = await _facts_in_mem0(mem0_client, user_id) + new = [f for f in after if f not in before] + print(f"\n[context → mem0 (static)] extracted {len(new)} new fact(s):") + for f in new: + print(f" + {f}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/long_term_memory/reme/README.md b/examples/long_term_memory/reme/README.md new file mode 100644 index 0000000..3ecbcfa --- /dev/null +++ b/examples/long_term_memory/reme/README.md @@ -0,0 +1,203 @@ +# ReMe middleware example + +One runnable demo (`reme_demo.py`) showing the +[ReMe](https://github.com/agentscope-ai/ReMe) middleware plugged into +an `agentscope.agent.Agent`. Drives two consecutive agent **sessions** +that share one ReMe workspace so ReMe's cross-session memory effect is +visible, and prints each middleware contribution (retrieval / tool +call / write-back) inline so you can see when each path fires. + +ReMe is the AgentScope team's own file-based memory toolkit. Unlike +mem0, it is **embedded in-process** — there is no separate service to +run — and it records memory by **listening to the conversation**: +after every reply the new exchange is written back automatically via +ReMe's `auto_memory` job. The agent never saves memory itself; there +is no add tool. The demo drives ReMe with AgentScope's own DashScope +chat model (LLM-backed `auto_memory` write-back) and DashScope +embedding model (vector search), both injected into the embedded app. + +ReMe's bundled `default` config searches with **BM25 (keyword) only** — +its file store ships with the vector store disabled. A long-term +*memory* demo wants **semantic** recall ("plot monthly sales" should +find a "prefers matplotlib" card), so `reme_demo.py` injects an +`embedding_model` — which turns ReMe's vector store on automatically; +see below. + +## Install + +```bash +# reme-ai is an optional AgentScope dependency — pull it via the extra: +pip install "agentscope[reme]" +# (equivalent to `pip install agentscope reme-ai`) + +export DASHSCOPE_API_KEY=sk-... +``` + +## Import path + +`ReMeMiddleware` is exported from the middleware package: + +```python +from agentscope.middleware import ReMeMiddleware +from agentscope.tool import Toolkit +``` + +## Construction + +The middleware builds and **owns** an embedded `reme.ReMe` app — it is +created lazily on first use and torn down by `await mw.close()`. You +configure it with plain parameters; there is no external app to manage. +User-tunable settings live on a nested `Parameters` model (the agent +service renders its JSON schema as a form): + +```python +ReMeMiddleware( + workspace_dir=".reme", + parameters=ReMeMiddleware.Parameters( + chat_model=my_chat_model, # injected into ReMe's LLM component, + # drives auto_memory write-back + embedding_model=my_embedding_model, # injected into its embedding + # component; also turns ReMe's + # vector store ON automatically + mode="both", + top_k=5, + ), +) +``` + +Both models are fixed for the app's lifetime (never taken from an +agent), so the embedded app's single LLM / embedding component is +well-defined even when one middleware instance is shared across agents. + +| `chat_model` / `embedding_model` | Behavior | +|:-:|---| +| provided | Injected into ReMe's default LLM / embedding components at start; only a DashScope key is needed. An `embedding_model` also enables the vector store for semantic search. | +| omitted | ReMe uses the LLM / embedding backend from its own config/credentials; search stays keyword-only. | + +> **Why inject `embedding_model`?** ReMe starts its embedding +> component eagerly at `start()` — even under the BM25-only default — +> and builds it from credentials in its config. Injecting an +> AgentScope `embedding_model` bypasses that credential path, so the +> only key you need is a DashScope one. It is also what powers vector +> search: providing it flips ReMe's file store from BM25-only to the +> vector store automatically. + +## How the middleware controls memory + +ReMe **always** writes the new exchange back through `auto_memory` +after each reply, in every mode — `mode` only selects how the agent +*retrieves*: + +### `static_control` +The middleware does the retrieval, the agent is unaware: + +1. **`on_reply` (pre)** starts a background `asyncio` task that searches + ReMe with the latest user message, running concurrently with the reply. +2. **`on_reasoning`** polls that task before each reasoning step; once it + has finished, the middleware appends an + `AssistantMsg(name="memory", ...)` `HintBlock` to `state.context` so + the *next* model call sees it. Injection is **best-effort**: a + single-shot reply (one model call) may finish before retrieval does, so + the hint lands on a later step or is skipped for that turn — the same + trade-off as `AgenticMemoryMiddleware`. Turns with a tool call (two or + more reasoning steps) inject reliably. +3. **`on_reply` (post)** writes the new `(user, assistant)` exchange + back via `auto_memory`. + +The injected memory message **persists** in the agent's context across +turns. If long sessions accumulate too many, post-process with +`compress_context` or a custom middleware. + +### `agent_control` +The middleware lists a single `memory_search(query, limit)` tool and +otherwise stays out of the way (auto write-back still runs). Pass it +into the agent's toolkit explicitly: + +```python +mw = ReMeMiddleware(..., mode="agent_control") +agent = Agent( + ..., + toolkit=Toolkit(tools=await mw.list_tools()), + middlewares=[mw], +) +``` + +The system prompt gets a short nudge telling the agent the search tool +exists; per-tool usage guidance comes through the standard tool schema. +No automatic retrieval. + +### `both` (default) +Both retrieval paths are active: memories are auto-retrieved and +appended to the agent's context as an assistant note, AND the +`memory_search` tool (with its system-prompt hint) is exposed for +explicit on-demand search. + +## Memory scoping (`session_id`) + +ReMe scopes write-back by **`session_id`**, read live from +`agent.state.session_id` at hook time — never stored on the +middleware. Search runs **workspace-wide** (across every session), +which is what lets a later session recall an earlier one's memories +even with a different `session_id`. To pin a resumable session, set +the id on the agent: + +```python +from agentscope.state import AgentState + +agent = Agent(..., state=AgentState(session_id="alice-main")) +``` + +The demo does exactly this — `session-1` writes the preference, +`session-2` (a fresh agent, empty chat context) recalls it through the +shared workspace. + +## Sharing one middleware across agents + +Because the `session_id` is read per call (not stored) and the chat +model is fixed at construction (tied to the embedded app's single +LLM), **one** `ReMeMiddleware` can be safely shared across many agents +and sessions — build it once and pass it to each agent: + +```python +mw = ReMeMiddleware( + workspace_dir=".reme", + chat_model=chat_model, + embedding_model=embedding_model, + mode="both", +) +agent_a = Agent(..., middlewares=[mw], state=AgentState(session_id="a")) +agent_b = Agent(..., middlewares=[mw], state=AgentState(session_id="b")) +``` + +This is what the demo does. Call `await mw.close()` on shutdown to tear +down the embedded app (AgentScope doesn't manage middleware lifecycle). + +## Configuration + +`config` selects a ReMe config (defaults to the bundled `"default"`, +which is auto-memory + **BM25-only** search — its file store ships with +`embedding_store: ""`). To enable **vector search**, provide an +`embedding_model` (what the demo does) — the middleware then wires +ReMe's file store to the default embedding store automatically: + +```python +ReMeMiddleware( + workspace_dir=".reme", + parameters=ReMeMiddleware.Parameters( + embedding_model=my_embedding_model, # turns the vector store on + ), +) +``` + +ReMe's `as_llm` / `as_embedding` components are otherwise driven by +environment variables (`LLM_API_KEY`, `EMBEDDING_API_KEY`, ...) from its +own config; injecting AgentScope `chat_model` / `embedding_model` +bypasses those. If you need a config the dedicated parameters don't +expose, point `config` at your own ReMe config file. See ReMe's +`default.yaml` for the full component set. + +> **Note (indexing):** `auto_memory` write-back returns as soon as the +> daily card is written to disk; the card only becomes searchable once +> ReMe indexes it. The demo forces a synchronous `reindex` after each +> write so the next read deterministically sees it, rather than relying +> on ReMe's background index loop. See `_reindex` in `reme_demo.py`. diff --git a/examples/long_term_memory/reme/reme_demo.py b/examples/long_term_memory/reme/reme_demo.py new file mode 100644 index 0000000..c40dfca --- /dev/null +++ b/examples/long_term_memory/reme/reme_demo.py @@ -0,0 +1,336 @@ +# -*- coding: utf-8 -*- +"""ReMe middleware demo (embedded reme-ai, AgentScope-driven). + +Drives two independent agent **sessions** that share one ReMe +workspace, so ReMe's cross-session memory effect is visible: session 1 +states a durable preference, the middleware writes it back +automatically, and a *fresh* session-2 agent — with an empty chat +context — recalls it through ReMe. + +Each turn streams events from ``agent.reply_stream`` and prints the +ones that matter: + +- ``[reme → context (static)]`` — the memory note the middleware + appended to ``state.context``. Retrieval runs in the background and the + note is injected on a reasoning step once the search finishes, so the + demo surfaces it the moment it appears (best-effort: a single-shot reply + may finish before it lands) +- ``[tool call (agent)]`` — each ``memory_search`` invocation the + agent makes on its own (there is no add tool — writing is automatic) +- ``[assistant]`` — the assistant's reply, concatenated from the + ``TextBlockDeltaEvent`` stream +- ``[context → reme (auto)]`` — what ReMe persisted after the turn, + surfaced by searching the workspace + +The mode tag (``static`` / ``agent``) on each line tells you which +control path produced it. + +Unlike mem0, ReMe is **embedded in-process** (no separate service to +run) and records memory by **listening to the conversation** — after +every reply the new exchange is written back via ReMe's ``auto_memory`` +job, in *all* modes. ``mode`` only controls *retrieval*. The agent +never writes memory itself. + +ReMe drives its LLM-backed ``auto_memory`` write-back and its vector +search through AgentScope models injected here — a DashScope chat model +and embedding model — so the only credential needed is a DashScope key. + +Starts each run from a clean workspace so the demo is reproducible. + +Requires: + pip install "agentscope[reme]" + export DASHSCOPE_API_KEY=sk-... +""" +import asyncio +import logging +import os +import shutil +import tempfile + +from agentscope.agent import Agent +from agentscope.credential import DashScopeCredential +from agentscope.embedding import DashScopeEmbeddingModel +from agentscope.event import ( + TextBlockDeltaEvent, + ToolCallDeltaEvent, + ToolCallStartEvent, + ToolResultEndEvent, + ToolResultTextDeltaEvent, +) +from agentscope.message import UserMsg +from agentscope.middleware import ReMeMiddleware +from agentscope.model import DashScopeChatModel +from agentscope.state import AgentState +from agentscope.tool import Toolkit + + +MODE = "both" # try "static_control" or "agent_control" too + +# ReMe writes its memory cards and indexes here. Defaults to a fresh, +# empty random temp dir (created per run) so runs never collide and +# nothing lands in the repo. Override with REME_WORKSPACE_DIR (e.g. +# ".reme") to keep the cards around for inspection; a user-named dir is +# reused as-is and only wiped when you also set REME_DEMO_RESET=1 — the +# demo never silently deletes a directory you pointed it at (it could be +# a real ReMe workspace, a project dir, ~/.reme, ...). +_ENV_WORKSPACE = os.environ.get("REME_WORKSPACE_DIR") +WORKSPACE_DIR = _ENV_WORKSPACE or tempfile.mkdtemp(prefix="reme_demo_") + +# Quiet ReMe's informational startup logs so the demo output stays +# focused on the middleware contributions we print ourselves. +logging.getLogger("reme").setLevel(logging.ERROR) + + +async def _memories_in_reme(mw: ReMeMiddleware, query: str) -> list[str]: + """Return ReMe's persisted memories matching ``query`` as strings. + + Search is workspace-wide (it spans every session), so this is the + natural read-path for inspecting what got written back between + turns — analogous to listing a vector store's facts. + """ + # pylint: disable-next=protected-access + return await mw._search(query, limit=20) # demo only — peek at state + + +async def _reindex(mw: ReMeMiddleware) -> None: + """Synchronously rebuild ReMe's search index from disk. + + ``auto_memory`` write-back returns as soon as the daily card is + written to the workspace, but that card only becomes *searchable* + once ReMe indexes it. ReMe normally does this in a background watch + loop; the demo forces a synchronous ``reindex`` instead so the very + next read deterministically sees the freshly written memory (no + sleeping / polling for the background loop to catch up). + """ + # pylint: disable-next=protected-access + await mw._run_job("reindex") # demo only — make writes searchable now + + +def _injected_memory_bullets(agent: Agent) -> list[str]: + """Extract the bullet lines from the memory note the middleware + appended to ``agent.state.context`` (if any) — strips the section + header and intro so we see only the actual retrieved facts.""" + for msg in agent.state.context: + if getattr(msg, "name", None) != "memory": + continue + hint_text = "\n".join( + block.hint for block in msg.get_content_blocks("hint") + ) + return [ + line[2:].strip() + for line in hint_text.splitlines() + if line.startswith("- ") + ] + return [] + + +async def _run_turn(agent: Agent, user_msg: UserMsg) -> str: + """Drive one reply turn through ``agent.reply_stream`` and print + each middleware contribution as it happens, in the order it + happens: + + 1. ``[reme → context]`` — the retrieved memory note. The middleware + searches ReMe in the background and injects the note on a reasoning + step once the search finishes, so we poll ``state.context`` on every + event and surface it the first time it appears (best-effort; a + single-shot reply may finish before it lands). + 2. ``ToolCallStartEvent`` / ``ToolResultEndEvent`` bracket each + ``memory_search`` invocation the agent makes on its own + (agent path). + 3. ``TextBlockDeltaEvent`` carries the assistant's streamed reply + text — concatenating every delta yields the final message + content. + + Each printed line is tagged with the ReMe control path that + produced it (``static`` vs ``agent``) so the demo stays readable + in any of the three modes. + """ + pending_args: dict[str, str] = {} + pending_names: dict[str, str] = {} + pending_results: dict[str, str] = {} + text_parts: list[str] = [] + memory_announced = False + + def _announce_memory() -> None: + """Print the static-path memory note as soon as it lands in context. + + The middleware retrieves in the background and injects the note in + ``on_reasoning`` once the search finishes, so we poll the context on + every event and surface it the first time it appears (best-effort: + a single-shot reply may never inject one).""" + nonlocal memory_announced + if memory_announced: + return + injected = _injected_memory_bullets(agent) + if not injected: + return + print( + f"[reme → context (static)] retrieved " + f"{len(injected)} memory note(s):", + ) + for b in injected: + print(f" ← {b}") + memory_announced = True + + async for ev in agent.reply_stream(inputs=user_msg): + _announce_memory() + if isinstance(ev, ToolCallStartEvent): + pending_names[ev.tool_call_id] = ev.tool_call_name + pending_args[ev.tool_call_id] = "" + pending_results[ev.tool_call_id] = "" + elif isinstance(ev, ToolCallDeltaEvent): + pending_args[ev.tool_call_id] += ev.delta + elif isinstance(ev, ToolResultTextDeltaEvent): + pending_results[ev.tool_call_id] += ev.delta + elif isinstance(ev, ToolResultEndEvent): + name = pending_names.pop(ev.tool_call_id, "") + args = pending_args.pop(ev.tool_call_id, "") + result = pending_results.pop(ev.tool_call_id, "") + print(f"[tool call (agent)] {name}({args}) → state={ev.state}") + for line in result.splitlines() or [""]: + if line: + print(f" → {line}") + elif isinstance(ev, TextBlockDeltaEvent): + text_parts.append(ev.delta) + + # The note may only land on the final reasoning step, after the last + # event we react to above — poll once more before finishing the turn. + _announce_memory() + + return "".join(text_parts) + + +def _build_agent( + chat_model: DashScopeChatModel, + mw: ReMeMiddleware, + session_id: str, + tools: list, +) -> Agent: + """Construct a data-analysis agent pinned to ``session_id``. + + ReMe scopes write-back by ``session_id`` (read from + ``agent.state.session_id`` at hook time), so a distinct id per + session keeps each conversation's cards apart; search still spans + the whole workspace, which is what bridges the two sessions. + """ + return Agent( + name="datascope_assistant", + system_prompt=( + "You are a helpful data-analysis assistant. Be concise. " + "When the request may depend on a durable fact from a past " + "session (a preference, a name, a prior decision), use the " + "memory_search tool. Saving memory is automatic." + ), + model=chat_model, + toolkit=Toolkit(tools=tools), + middlewares=[mw], + state=AgentState(session_id=session_id), + ) + + +async def main() -> None: + """Drive two cross-session agent turns and print middleware effects.""" + api_key = os.environ["DASHSCOPE_API_KEY"] + + # Start from a clean workspace so the demo is reproducible. The + # default temp dir is already fresh (mkdtemp just created it empty), + # so there is nothing to wipe. A user-provided REME_WORKSPACE_DIR is + # reused as-is and only reset when REME_DEMO_RESET=1 is set — we must + # never silently delete a directory the user named. + if _ENV_WORKSPACE and os.environ.get("REME_DEMO_RESET") == "1": + print("=== resetting ReMe workspace (REME_DEMO_RESET=1) ===") + print(f" rm -rf {WORKSPACE_DIR}") + shutil.rmtree(WORKSPACE_DIR, ignore_errors=True) + elif _ENV_WORKSPACE: + print(f"=== reusing ReMe workspace {WORKSPACE_DIR} ===") + print(" (set REME_DEMO_RESET=1 to wipe it before this run)") + else: + print(f"=== fresh ReMe workspace {WORKSPACE_DIR} ===") + + chat_model = DashScopeChatModel( + credential=DashScopeCredential(api_key=api_key), + model="qwen3.7-max", + stream=True, + ) + embedding_model = DashScopeEmbeddingModel( + credential=DashScopeCredential(api_key=api_key), + model="text-embedding-v4", + dimensions=1024, # matches ReMe's default embedding dimension + ) + + # One middleware, shared across both sessions. ReMe is embedded + # in-process and the middleware owns its lifecycle — it builds the + # reme.ReMe app lazily on first use and closes it on mw.close(). The + # chat + embedding models are fixed here (they drive the embedded + # app's single LLM for auto_memory write-back and its vector search). + # Per-conversation state (session_id) is read live from each agent, + # never stored on the middleware — so sharing one instance across + # agents/sessions is safe. + # + # ReMe's bundled ``default`` config searches keyword-only (its file + # store ships with ``embedding_store: ""``). For a long-term *memory* + # demo we want semantic recall — "plot monthly sales" should find a + # "prefers matplotlib / dark mode" card even without shared keywords — + # so we pass an ``embedding_model``, which the middleware uses to turn + # ReMe's vector store on automatically when it builds the app. + mw = ReMeMiddleware( + workspace_dir=WORKSPACE_DIR, + parameters=ReMeMiddleware.Parameters( + chat_model=chat_model, + embedding_model=embedding_model, + mode=MODE, + top_k=5, + ), + ) + + try: + tools = await mw.list_tools() + + # ============================================================= + # SESSION 1 — state a durable preference; ReMe writes it back. + # ============================================================= + print(f"\n=== SESSION 1 (mode={MODE!r}) ===") + user_msg_1 = ( + "Hi! For any chart, please default to dark mode and use " + "matplotlib. Also I'm based in Hangzhou." + ) + print(f"\n[user] {user_msg_1}\n") + + agent = _build_agent(chat_model, mw, "session-1", tools) + reply_text = await _run_turn(agent, UserMsg("alice", user_msg_1)) + print(f"\n[assistant] {reply_text}") + + # Make session 1's write-back searchable before session 2 reads + # (see _reindex — ReMe's background indexer is not relied upon). + print("\n(indexing the new memory card...)") + await _reindex(mw) + persisted = await _memories_in_reme(mw, "chart preferences location") + print("[context → reme (auto)] workspace now holds:") + for m in persisted: + print(f" + {m}") + + # ============================================================= + # SESSION 2 — fresh agent, empty chat context. ReMe bridges. + # ============================================================= + print( + f"\n=== SESSION 2 (fresh agent, reme bridges; mode={MODE!r}) ===", + ) + user_msg_2 = ( + "Plot me a bar chart of monthly sales — pick reasonable " + "defaults for theme and library." + ) + print(f"\n[user] {user_msg_2}\n") + + agent = _build_agent(chat_model, mw, "session-2", tools) + reply_text = await _run_turn(agent, UserMsg("alice", user_msg_2)) + print(f"\n[assistant] {reply_text}") + finally: + # The middleware owns the embedded ReMe app it built, so a single + # close() tears it down — ReMe's background jobs / thread pool + # shut down cleanly. (AgentScope doesn't manage middleware + # lifecycle, so this must be explicit.) + await mw.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/rag/README.md b/examples/rag/README.md new file mode 100644 index 0000000..dc45898 --- /dev/null +++ b/examples/rag/README.md @@ -0,0 +1,139 @@ +# RAG Examples + +Two library-mode walk-throughs of `agentscope.rag` — no FastAPI service, no manager, no message bus. Each script wires the building blocks (parser, chunker, embedding model, vector store, `KnowledgeBase` handle) by hand so the data flow is visible end-to-end. + +| Script | What it shows | +| --- | --- | +| [`index_and_search.py`](./index_and_search.py) | The minimal pipeline: parse → chunk → embed → insert, then `KnowledgeBase.search`. Start here. | +| [`integrate_with_agent.py`](./integrate_with_agent.py) | Attaches the same `KnowledgeBase` to an `Agent` via `RAGMiddleware`, in both `static` (auto-inject) and `agentic` (tool-driven) modes. | + +Both examples use an in-memory Qdrant store (`location=":memory:"`) and the DashScope `text-embedding-v4` model, so no external services are required. The sections below show how to swap in Milvus Lite or MongoDB instead; those backends need additional setup. + +## Install + +```bash +# From PyPI +uv pip install "agentscope[rag]" + +# Or from source (repo root) +uv pip install -e ".[rag]" +``` + +### Milvus Lite (local persistence) + +To use a local persistent Milvus Lite vector store instead of the +in-memory Qdrant store, install the optional extra: + +```bash +uv pip install "agentscope[milvuslite]" +# Or from source (repo root) +uv pip install -e ".[milvuslite]" +``` + +Then replace the vector store construction in `index_and_search.py` +and/or `integrate_with_agent.py`: + +```python +from agentscope.rag import MilvusLiteStore + +store = MilvusLiteStore(uri="./rag_demo.db") +``` + +### MongoDB Vector Search + +To use MongoDB as the vector backend instead of the in-memory Qdrant +store — useful when your team already runs MongoDB as the primary data +store and wants to avoid maintaining a separate vector database — install +the optional extra: + +```bash +uv pip install "agentscope[mongodb]" +# Or from source (repo root) +uv pip install -e ".[mongodb]" +``` + +**Prerequisites** + +- A MongoDB deployment with [Vector Search](https://www.mongodb.com/docs/atlas/atlas-vector-search/vector-search-overview/) enabled: + - **MongoDB Atlas** — create a cluster and enable Vector Search on the target database; or + - **Self-hosted** — MongoDB 7.0+ replica set with Vector Search enabled. +- A connection URI available in the environment (do not hard-code credentials): + +```bash +export MONGODB_URI="mongodb+srv://user:pass@cluster.mongodb.net/?retryWrites=true&w=majority" +# Self-hosted example: +# export MONGODB_URI="mongodb://localhost:27017" +``` + +Then replace the vector store construction in `index_and_search.py` +and/or `integrate_with_agent.py`: + +```python +import os + +from agentscope.rag import MongoDBStore + +store = MongoDBStore( + uri=os.environ["MONGODB_URI"], + database="agentscope_rag", + # Declare every field you plan to filter on in search(). + # Required for metadata_filter; defaults to ["document_id"] only. + filter_fields=[ + "document_id", + # "chunk.metadata.tenant_id", # uncomment if you use metadata_filter + ], +) + +# MongoDBStore is also an async context manager — same as QdrantStore. +async with store: + knowledge = KnowledgeBase( + name="demo-kb", + description="A toy corpus on cats and AgentScope.", + embedding_model=embedding_model, + vector_store=store, + collection=COLLECTION, + ) + ... +``` + +**Notes** + +- The examples use DashScope `text-embedding-v4` with `dimensions=1024`. + `MongoDBStore.create_collection` is called automatically on the first + index operation with that dimension — keep the embedding model and + index dimensions aligned. +- Unlike Qdrant `:memory:` or Milvus Lite (local `.db` file), MongoDB is + an external service; you must have a reachable cluster before running + the scripts. +- If `search(..., metadata_filter={...})` returns no results or errors, + ensure each metadata key is listed in `filter_fields` as + `chunk.metadata.` when constructing `MongoDBStore`. +- For the full FastAPI RAG service, pass the same `MongoDBStore` instance + to `create_app(vector_store=...)` in `examples/agent_service/main.py` + (the default there uses in-memory Qdrant for zero-setup demos). + +### Choosing a vector backend + +| | Qdrant (default) | Milvus Lite | MongoDB | +| --- | --- | --- | --- | +| Install extra | `agentscope[rag]` | `agentscope[milvuslite]` | `agentscope[mongodb]` | +| External service | No | No | Yes | +| Persistence | No (`:memory:`) | Yes (local `.db`) | Yes (server) | +| Best for | Quick start / tests | Local dev with persistence | Teams already on MongoDB | + +`integrate_with_agent.py` additionally uses `DashScopeChatModel`, which is already in the base `agentscope` dependencies. + +## Run + +```bash +export DASHSCOPE_API_KEY=sk-... + +python examples/rag/index_and_search.py +python examples/rag/integrate_with_agent.py +``` + +When using MongoDB, also export `MONGODB_URI` before running. + +## Service mode + +The two scripts above are library-mode — you drive the pipeline yourself in a single process. For the full service-mode experience (FastAPI endpoints for knowledge base CRUD, document upload, indexing workers, and search), see [`examples/agent_service`](../agent_service) for the backend and [`examples/web_ui`](../web_ui) for the chat-style UI. diff --git a/examples/rag/index_and_search.py b/examples/rag/index_and_search.py new file mode 100644 index 0000000..bca34da --- /dev/null +++ b/examples/rag/index_and_search.py @@ -0,0 +1,183 @@ +# -*- coding: utf-8 -*- +"""Library-mode RAG walk-through — no FastAPI service, no manager. + +End-to-end demo of the building blocks in :mod:`agentscope.rag`: + +1. **Vector store** — :class:`QdrantStore` (in-memory here; swap to + ``url=...`` for a real Qdrant server). +2. **Parser** — :class:`TextParser` turning raw bytes into ``Section`` + objects. +3. **Chunker** — :class:`ApproxTokenChunker` splitting sections into + ``Chunk`` objects (the indexable unit). +4. **Embedding** — any :class:`EmbeddingModelBase` subclass; we use the + DashScope text-embedding model. +5. **KnowledgeBase** — the runtime handle that ties (embedding, vector + store, collection) together and exposes + ``insert_document`` / ``search`` / ``list_documents`` / + ``delete_document``. This is what :class:`RAGMiddleware` consumes. + +The pipeline: + + bytes ── parser ──► Section[] ── chunker ──► Chunk[] + │ + ▼ + knowledge.insert_document(chunks) + │ + ▼ + embed → store on the bound collection + +Search mirrors the same shape: hand the query to +``knowledge.search(...)``, get ``VectorSearchResult`` back. + +Run with:: + + DASHSCOPE_API_KEY=sk-... python examples/rag/index_and_search.py +""" +import asyncio +import os + +from agentscope.credential import DashScopeCredential +from agentscope.embedding import DashScopeEmbeddingModel +from agentscope.message import TextBlock +from agentscope.rag import ( + ApproxTokenChunker, + KnowledgeBase, + QdrantStore, + TextParser, +) + + +COLLECTION = "demo-kb" + +# A toy corpus inlined as bytes so the example has no on-disk +# dependencies. In real use these would come from uploaded files or +# blob-store reads. +DOCUMENTS: dict[str, bytes] = { + "cats.md": ( + b"# Cats\n\n" + b"Cats are small carnivorous mammals. They are popular as pets " + b"because of their playful and affectionate nature.\n\n" + b"Domestic cats sleep around 12-16 hours per day. They are most " + b"active at dawn and dusk (crepuscular behaviour).\n" + ), + "agentscope.md": ( + b"# AgentScope\n\n" + b"AgentScope is a developer-centric framework for building " + b"multi-agent LLM applications. It emphasises transparency, " + b"controllability, and a clear separation between agent logic " + b"and infrastructure.\n\n" + b"Its RAG module ships a parser/chunker/embedding/vector-store " + b"pipeline that can be wired up without the FastAPI service.\n" + ), +} + + +async def build_index( + knowledge: KnowledgeBase, + parser: TextParser, + chunker: ApproxTokenChunker, +) -> None: + """Parse → chunk → insert for every demo document. + + Embedding and vector-store insertion are encapsulated inside + ``knowledge.insert_document`` — caller side only has to bring + pre-chunked content. + """ + for filename, file_bytes in DOCUMENTS.items(): + # 1. Parse: bytes → list[Section] + sections = await parser.parse(file=file_bytes, filename=filename) + + # 2. Chunk: list[Section] → list[Chunk] + chunks = await chunker.chunk(sections) + + # 3. Insert: embeds every chunk under one document id. The + # returned id is yours to keep for later + # ``delete_document``. + document_id = await knowledge.insert_document( + chunks, + document_metadata={"filename": filename}, + ) + print( + f" indexed {filename!r} as document_id={document_id} " + f"({len(chunks)} chunk(s))", + ) + + +async def search( + knowledge: KnowledgeBase, + query: str, + top_k: int = 3, +) -> None: + """Run a search via the :class:`KnowledgeBase` handle and print hits.""" + results = await knowledge.search(queries=[query], top_k=top_k) + + print(f"\nQuery: {query!r}") + if not results: + print(" (no hits)") + return + for rank, result in enumerate(results, start=1): + # Only text chunks are printable as-is. + snippet = ( + result.chunk.content.text + if isinstance(result.chunk.content, TextBlock) + else "" + ) + snippet = snippet.replace("\n", " ").strip() + if len(snippet) > 120: + snippet = snippet[:117] + "..." + print( + f" [{rank}] score={result.score:.4f} " + f"source={result.chunk.source} " + f"document_id={result.document_id}\n" + f" {snippet}", + ) + + +async def main() -> None: + """The main entry point of the example.""" + api_key = os.environ.get("DASHSCOPE_API_KEY") + if not api_key: + raise RuntimeError( + "Set DASHSCOPE_API_KEY before running this example.", + ) + + # The building blocks. All of these are also what the service-mode + # (``create_app``) wiring uses internally — the only difference is + # that here you drive them yourself. + embedding_model = DashScopeEmbeddingModel( + credential=DashScopeCredential(api_key=api_key), + model="text-embedding-v4", + dimensions=1024, + ) + parser = TextParser() + chunker = ApproxTokenChunker(chunk_size=256, overlap=32) + store = QdrantStore(location=":memory:") + + # ``QdrantStore`` is an async context manager — entering it opens + # the client connection; exiting closes it. + async with store: + # One :class:`KnowledgeBase` handle bundles (embedding, vector + # store, collection) together so the rest of this example + # never has to repeat the wiring. The collection is created + # lazily on the first operation (`build_index`). + knowledge = KnowledgeBase( + name="demo-kb", + description="A toy corpus on cats and AgentScope.", + embedding_model=embedding_model, + vector_store=store, + collection=COLLECTION, + ) + + print("Indexing demo corpus ...") + await build_index(knowledge, parser, chunker) + + # A couple of search queries that demonstrate scoring. + await search(knowledge, "When are cats most active?") + await search( + knowledge, + "What framework lets me build multi-agent apps?", + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/rag/integrate_with_agent.py b/examples/rag/integrate_with_agent.py new file mode 100644 index 0000000..e69cfde --- /dev/null +++ b/examples/rag/integrate_with_agent.py @@ -0,0 +1,198 @@ +# -*- coding: utf-8 -*- +"""Wire :class:`RAGMiddleware` into an :class:`Agent` — library mode. + +The middleware in :mod:`agentscope.middleware._rag` is the agent-side +half of RAG: given one or more :class:`~agentscope.rag.KnowledgeBase` +handles (each pairing an embedding model with a vector-store +collection), the middleware drives search on each new user turn and +feeds the matched chunks into the model context. + +This example reuses the indexing pipeline shown in ``index_and_search.py`` +(parse → chunk → embed → insert) and then attaches the same knowledge base +to two agents — one per mode: + +- ``"static"``: on the first reasoning step of a reply, embed the + user's question, search, and inject the top hits as a one-shot + :class:`HintBlock` into the agent's context. The model sees the + matched snippets but never "decides" to search. +- ``"agentic"`` (the default): expose a ``search_knowledge`` tool. The + model decides when (and what) to search, the same way it decides any + other tool call. + +Run with:: + + DASHSCOPE_API_KEY=sk-... python examples/rag/integrate_with_agent.py +""" +import asyncio +import os + +from agentscope.agent import Agent +from agentscope.credential import DashScopeCredential +from agentscope.embedding import DashScopeEmbeddingModel +from agentscope.message import UserMsg +from agentscope.middleware import RAGMiddleware +from agentscope.model import DashScopeChatModel +from agentscope.rag import ( + ApproxTokenChunker, + KnowledgeBase, + QdrantStore, + TextParser, +) +from agentscope.tool import Toolkit + + +COLLECTION = "demo-kb" + +KNOWLEDGE: dict[str, bytes] = { + "company-policy.md": ( + b"# Acme Remote Work Policy\n\n" + b"Employees may work remotely up to three days per week. " + b"Wednesdays are mandatory in-office days for the whole " + b"engineering org so cross-team syncs land on a predictable " + b"day.\n\n" + b"Equipment stipend: each new hire receives a USD 1,500 " + b"one-off stipend for a home-office setup. Receipts must be " + b"submitted within 90 days of the start date.\n" + ), + "release-notes.md": ( + b"# AgentScope 3.0 release notes\n\n" + b"- New ``agentscope.rag`` module: pluggable parser, chunker, " + b"embedding, and vector-store backends.\n" + b"- ``RAGMiddleware`` ships in two modes -- ``static`` for " + b"automatic injection, ``agentic`` for tool-driven search.\n" + b"- Knowledge base service supports embedded and dedicated " + b"worker deployments through a single message-bus channel.\n" + ), +} + + +async def index_corpus(knowledge: KnowledgeBase) -> None: + """Index the demo corpus into the knowledge base. + + Identical pipeline to ``examples/rag/index_and_search.py`` — extracted + as a helper here so the agent-side wiring stays the focus. Each source + file becomes one logical document; ``KnowledgeBase.insert_document`` + embeds and inserts every chunk in a single batch. + """ + parser = TextParser() + chunker = ApproxTokenChunker(chunk_size=256, overlap=32) + for filename, file_bytes in KNOWLEDGE.items(): + sections = await parser.parse(file=file_bytes, filename=filename) + chunks = await chunker.chunk(sections) + await knowledge.insert_document( + chunks, + document_metadata={"filename": filename}, + ) + + +def build_agent( + name: str, + *, + chat_model: DashScopeChatModel, + rag_mw: RAGMiddleware, +) -> Agent: + """Construct an :class:`Agent` with the RAG middleware attached. + + The middleware is just one entry in the ``middlewares=`` list; it + composes with every other middleware (tool offload, mem0, ...) the + agent uses. + """ + return Agent( + name=name, + system_prompt=( + "You are a concise assistant. Use matched context when " + "available; if you don't know, say so." + ), + model=chat_model, + toolkit=Toolkit(), + middlewares=[rag_mw], + ) + + +async def ask(agent: Agent, question: str) -> None: + """Run one reply and print the final assistant message.""" + print(f"\n[{agent.name}] user: {question}") + reply = await agent.reply(UserMsg(name="user", content=question)) + print(f"[{agent.name}] assistant: {reply.get_text_content()}") + + +async def main() -> None: + """The main entry point of the example.""" + api_key = os.environ.get("DASHSCOPE_API_KEY") + if not api_key: + raise RuntimeError( + "Set DASHSCOPE_API_KEY before running this example.", + ) + + credential = DashScopeCredential(api_key=api_key) + chat_model = DashScopeChatModel( + credential=credential, + model="qwen-plus", + stream=False, + ) + embedding_model = DashScopeEmbeddingModel( + credential=credential, + model="text-embedding-v4", + dimensions=1024, + ) + + store = QdrantStore(location=":memory:") + async with store: + # One :class:`KnowledgeBase` handle binds embedding + vector store + + # collection together. ``insert_document`` / ``search`` / + # ``list_documents`` all go through it, and the backing + # collection is created lazily on first use. + knowledge = KnowledgeBase( + name="acme-handbook", + description="Acme HR policies and AgentScope 3.0 release notes.", + embedding_model=embedding_model, + vector_store=store, + collection=COLLECTION, + ) + + await index_corpus(knowledge) + + # ---- Mode 1: static ---- + # Search is automatic on the first reasoning step. The injected + # ``HintBlock`` is one-shot (removed after the model call) so it + # doesn't poison the next turn. + static_mw = RAGMiddleware( + knowledge_bases=[knowledge], + parameters=RAGMiddleware.Parameters( + mode="static", + top_k=3, + emit_hint_event=False, + ), + ) + static_agent = build_agent( + "rag-static-agent", + chat_model=chat_model, + rag_mw=static_mw, + ) + await ask( + static_agent, + "How many remote days per week does Acme allow?", + ) + + # ---- Mode 2: agentic ---- + # The middleware exposes a ``search_knowledge`` tool instead of + # auto-injecting. The model decides when to call it; it may + # also pass ``knowledge_bases=[...]`` to scope the search when + # multiple knowledge bases are bound. + agentic_mw = RAGMiddleware( + knowledge_bases=[knowledge], + parameters=RAGMiddleware.Parameters(mode="agentic", top_k=3), + ) + agentic_agent = build_agent( + "rag-agentic-agent", + chat_model=chat_model, + rag_mw=agentic_mw, + ) + await ask( + agentic_agent, + "Summarise what's new in the AgentScope 3.0 release notes.", + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/web_ui/.gitignore b/examples/web_ui/.gitignore new file mode 100644 index 0000000..6f6850a --- /dev/null +++ b/examples/web_ui/.gitignore @@ -0,0 +1,42 @@ +/tmp +/out-tsc + +node_modules +npm-debug.log* +yarn-debug.log* +yarn-error.log* +/.pnp +.pnp.js + +.vscode/* + +# OS files +.DS_Store + +dist +build +out +.eslintcache +*.log* + +.env +.env.* +__pycache__/ +*.db +*.sqlite +*.sqlite3 + +.idea/ +.vscode +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Mintlify +docs/.mintlify +docs/node_modules +docs/.next +docs/.cache +docs/api-reference/generated \ No newline at end of file diff --git a/examples/web_ui/.husky/pre-commit b/examples/web_ui/.husky/pre-commit new file mode 100644 index 0000000..2312dc5 --- /dev/null +++ b/examples/web_ui/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/examples/web_ui/.prettierignore b/examples/web_ui/.prettierignore new file mode 100644 index 0000000..8237688 --- /dev/null +++ b/examples/web_ui/.prettierignore @@ -0,0 +1,38 @@ +# Dependencies +node_modules +pnpm-lock.yaml + +# Build outputs +dist +build +.next +out + +# Cache +.cache +.turbo +.vite + +# Logs +*.log +logs + +# Environment +.env +.env.* + +# IDE +.vscode +.idea + +# OS +.DS_Store +Thumbs.db + +# Generated files +coverage +*.min.js +*.min.css + +# Claude +.claude diff --git a/examples/web_ui/.prettierrc b/examples/web_ui/.prettierrc new file mode 100644 index 0000000..b0d954c --- /dev/null +++ b/examples/web_ui/.prettierrc @@ -0,0 +1,10 @@ +{ + "useTabs": true, + "tabWidth": 4, + "singleQuote": true, + "semi": true, + "trailingComma": "all", + "printWidth": 100, + "bracketSpacing": true, + "arrowParens": "always" +} diff --git a/examples/web_ui/backend/package.json b/examples/web_ui/backend/package.json new file mode 100644 index 0000000..322febc --- /dev/null +++ b/examples/web_ui/backend/package.json @@ -0,0 +1,22 @@ +{ + "name": "backend", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "nodemon --watch src --ext ts --exec ts-node src/index.ts", + "build": "tsc", + "start": "node dist/index.js" + }, + "dependencies": { + "express": "^4.19.2", + "cors": "^2.8.5" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/cors": "^2.8.17", + "@types/node": "^20.14.0", + "nodemon": "^3.1.4", + "ts-node": "^10.9.2", + "typescript": "^5.5.2" + } +} diff --git a/examples/web_ui/backend/src/index.ts b/examples/web_ui/backend/src/index.ts new file mode 100644 index 0000000..ecb854d --- /dev/null +++ b/examples/web_ui/backend/src/index.ts @@ -0,0 +1,16 @@ +import express from 'express'; +import cors from 'cors'; + +const app = express(); +const PORT = process.env.PORT || 3000; + +app.use(cors()); +app.use(express.json()); + +app.get('/api/health', (_req, res) => { + res.json({ status: 'ok' }); +}); + +app.listen(PORT, () => { + console.log(`Server running on http://localhost:${PORT}`); +}); diff --git a/examples/web_ui/backend/tsconfig.json b/examples/web_ui/backend/tsconfig.json new file mode 100644 index 0000000..2f7d84e --- /dev/null +++ b/examples/web_ui/backend/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/examples/web_ui/frontend/README.md b/examples/web_ui/frontend/README.md new file mode 100644 index 0000000..c2d615f --- /dev/null +++ b/examples/web_ui/frontend/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]); +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x'; +import reactDom from 'eslint-plugin-react-dom'; + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]); +``` diff --git a/examples/web_ui/frontend/components.json b/examples/web_ui/frontend/components.json new file mode 100644 index 0000000..c18236d --- /dev/null +++ b/examples/web_ui/frontend/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/examples/web_ui/frontend/eslint.config.js b/examples/web_ui/frontend/eslint.config.js new file mode 100644 index 0000000..8ae0d0e --- /dev/null +++ b/examples/web_ui/frontend/eslint.config.js @@ -0,0 +1,53 @@ +import js from '@eslint/js'; +import globals from 'globals'; +import importX from 'eslint-plugin-import-x'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import tseslint from 'typescript-eslint'; +import { defineConfig, globalIgnores } from 'eslint/config'; + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + plugins: { + 'import-x': importX, + }, + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + rules: { + 'import-x/order': [ + 'error', + { + groups: ['builtin', 'external', 'internal', 'index'], + 'newlines-between': 'always', + alphabetize: { + order: 'asc', + caseInsensitive: true, + }, + }, + ], + 'react-hooks/set-state-in-effect': 'off', + 'react-refresh/only-export-components': 'warn', + '@typescript-eslint/no-unused-expressions': 'off', + '@typescript-eslint/only-throw-error': 'off', + 'preserve-caught-error': 'off', + }, + }, + { + files: ['**/components/ui/**/*.{ts,tsx}'], + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': 'off', + 'react-refresh/only-export-components': 'off', + 'import-x/order': 'off', + }, + }, +]); diff --git a/examples/web_ui/frontend/index.html b/examples/web_ui/frontend/index.html new file mode 100644 index 0000000..4f4a319 --- /dev/null +++ b/examples/web_ui/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + AgentScope + + +
+ + + diff --git a/examples/web_ui/frontend/package.json b/examples/web_ui/frontend/package.json new file mode 100644 index 0000000..a865f74 --- /dev/null +++ b/examples/web_ui/frontend/package.json @@ -0,0 +1,66 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "preview": "vite preview" + }, + "dependencies": { + "@agentscope-ai/agentscope": "^0.0.13", + "@fontsource-variable/geist": "^5.2.8", + "@tailwindcss/vite": "^4.3.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cron-parser": "^5.5.0", + "date-fns": "^4.1.0", + "framer-motion": "^12.40.0", + "i18next": "^26.2.0", + "i18next-browser-languagedetector": "^8.2.1", + "lucide": "^1.3.0", + "lucide-react": "^1.16.0", + "mime-types": "^3.0.2", + "next-themes": "^0.4.6", + "onborda": "^1.2.5", + "radix-ui": "^1.4.3", + "react": "^19.2.6", + "react-day-picker": "^10.0.1", + "react-diff-view": "^3.3.3", + "react-dom": "^19.2.6", + "react-i18next": "^17.0.8", + "react-markdown": "^10.1.0", + "react-resizable-panels": "^4.11.2", + "react-router-dom": "^7.15.1", + "remark-gfm": "^4.0.1", + "shadcn": "^4.7.0", + "shadcn-prose": "^1.2.1", + "sonner": "^2.0.7", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.3.0", + "tw-animate-css": "^1.4.0", + "unidiff": "^1.0.4", + "vaul": "^1.1.2" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@tailwindcss/typography": "^0.5.19", + "@types/mime-types": "^3.0.1", + "@types/node": "^24.12.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-import-x": "^4.16.2", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.12", + "vite-plugin-svgr": "^5.2.0" + } +} diff --git a/examples/web_ui/frontend/public/agentscope.svg b/examples/web_ui/frontend/public/agentscope.svg new file mode 100644 index 0000000..e781d9c --- /dev/null +++ b/examples/web_ui/frontend/public/agentscope.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/web_ui/frontend/src/App.tsx b/examples/web_ui/frontend/src/App.tsx new file mode 100644 index 0000000..9584518 --- /dev/null +++ b/examples/web_ui/frontend/src/App.tsx @@ -0,0 +1,84 @@ +import { Onborda, OnbordaProvider } from 'onborda'; +import { useMemo, useState } from 'react'; +import { createBrowserRouter, Navigate, RouterProvider, useNavigate } from 'react-router-dom'; +import { Toaster } from 'sonner'; + +import { RouteError } from '@/components/error/RouteError'; +import { AppLayout } from '@/components/layout/AppLayout'; +import { buildChatTour } from '@/components/tour/chatTourSteps'; +import { TourCard } from '@/components/tour/TourCard'; +import { UploadProvider } from '@/context/UploadContext'; +import { useTranslation } from '@/i18n/useI18n'; +import { ChatPage } from '@/pages/chat'; +import { CredentialPage } from '@/pages/credential'; +import { KnowledgePage } from '@/pages/knowledge'; +import { SchedulePage } from '@/pages/schedule'; +import { SetupPage } from '@/pages/setup'; + +function SetupPageRoute() { + const navigate = useNavigate(); + return ( + <> +
+ navigate('/')} /> +
+ + + ); +} + +const router = createBrowserRouter([ + { + element: , + errorElement: , + children: [ + { + // Content-level boundary: a crash in a page replaces only + // the Outlet area, so AppLayout (the icon rail / nav) stays + // usable. The parent route keeps its own errorElement as a + // last-resort catch-all for AppLayout/AppSidebar crashes. + errorElement: , + children: [ + { path: '/', element: }, + { + path: '/chat/:agentId?/:sessionId?/:memberId?', + element: , + }, + { path: '/schedule', element: }, + { path: '/credential', element: }, + { path: '/knowledge', element: }, + { path: '/knowledge/:kbId', element: }, + ], + }, + ], + }, + { path: '/setup', element: , errorElement: }, +]); + +function App() { + const { t } = useTranslation(); + const [setupComplete, setSetupComplete] = useState(() => !!localStorage.getItem('server_url')); + const tours = useMemo(() => [buildChatTour(t)], [t]); + + if (!setupComplete) { + return setSetupComplete(true)} />; + } + + return ( + + + + + + + + + ); +} + +export default App; diff --git a/examples/web_ui/frontend/src/api/agent.ts b/examples/web_ui/frontend/src/api/agent.ts new file mode 100644 index 0000000..557aa39 --- /dev/null +++ b/examples/web_ui/frontend/src/api/agent.ts @@ -0,0 +1,23 @@ +import { client } from './client'; +import type { + AgentListResponse, + AgentView, + AgentSchemaV2Response, + CreateAgentRequest, + CreateAgentResponse, + UpdateAgentRequest, +} from './types'; + +export const agentApi = { + list: () => client.get('/agent/'), + + getSchema: () => client.get('/agent/schema/v2'), + + create: (body: CreateAgentRequest, options?: { silent?: boolean }) => + client.post('/agent/', body, undefined, options), + + update: (agentId: string, body: UpdateAgentRequest, options?: { silent?: boolean }) => + client.patch(`/agent/${agentId}`, body, undefined, options), + + delete: (agentId: string) => client.delete(`/agent/${agentId}`), +}; diff --git a/examples/web_ui/frontend/src/api/chat.ts b/examples/web_ui/frontend/src/api/chat.ts new file mode 100644 index 0000000..cdee462 --- /dev/null +++ b/examples/web_ui/frontend/src/api/chat.ts @@ -0,0 +1,25 @@ +import { client } from './client'; +import type { ChatRequest } from './types'; + +/** + * Chat API — fire-and-forget trigger for chat runs. + * + * Events produced by the run are delivered via the session's SSE + * stream endpoint (``GET /sessions/{sid}/stream``), not in the + * response body of this POST. + */ +export const chatApi = { + /** + * Trigger a chat run for the specified session. + * + * Accepts user messages, human-in-the-loop confirmation events, + * or ``null`` (continue from current state). Returns immediately; + * the caller should already be subscribed to the session's SSE + * stream to receive the resulting events. + * + * @param body - The chat request payload. + * @returns A confirmation object ``{ status, session_id }``. + */ + trigger: (body: ChatRequest) => + client.post<{ status: string; session_id: string }>('/chat/', body), +}; diff --git a/examples/web_ui/frontend/src/api/client.ts b/examples/web_ui/frontend/src/api/client.ts new file mode 100644 index 0000000..2ba6e6f --- /dev/null +++ b/examples/web_ui/frontend/src/api/client.ts @@ -0,0 +1,119 @@ +import { toast } from 'sonner'; + +export const getBaseUrl = () => localStorage.getItem('server_url') ?? ''; +export const getUserId = () => localStorage.getItem('username') ?? ''; + +/** + * Structured error thrown for non-2xx HTTP responses. + * `message` contains the human-readable detail extracted from the backend. + */ +export class ApiError extends Error { + readonly status: number; + readonly detail: string; + + constructor(status: number, detail: string) { + super(detail); + this.name = 'ApiError'; + this.status = status; + this.detail = detail; + } +} + +interface RequestOptions { + method?: string; + body?: unknown; + params?: Record; + /** When true, suppresses the automatic error toast. Useful when the caller shows its own inline error UI. */ + silent?: boolean; +} + +function buildHeaders(hasBody: boolean): Record { + const headers: Record = { 'X-User-ID': getUserId() }; + if (hasBody) headers['Content-Type'] = 'application/json'; + return headers; +} + +/** Parse the response body and extract the `detail` field if the backend returned JSON. */ +async function extractErrorDetail(res: Response): Promise { + const text = await res.text(); + try { + const json = JSON.parse(text) as { detail?: unknown }; + if (typeof json.detail === 'string') return json.detail; + if (json.detail !== undefined) return JSON.stringify(json.detail); + } catch { + // not JSON – fall through + } + return text || res.statusText; +} + +async function request(path: string, options: RequestOptions = {}): Promise { + const { method = 'GET', body, params, silent = false } = options; + const url = new URL(path, getBaseUrl()); + if (params) { + Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v)); + } + + const res = await fetch(url.toString(), { + method, + headers: buildHeaders(body !== undefined), + body: body ? JSON.stringify(body) : undefined, + }); + + if (!res.ok) { + const detail = await extractErrorDetail(res); + const error = new ApiError(res.status, detail); + if (!silent) toast.error(detail); + throw error; + } + + if (res.status === 204) return undefined as T; + return res.json() as Promise; +} + +async function streamRequest( + path: string, + options: RequestOptions & { signal?: AbortSignal } = {}, +): Promise { + const { method = 'GET', body, params, signal, silent = false } = options; + const url = new URL(path, getBaseUrl()); + if (params) { + Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v)); + } + + const res = await fetch(url.toString(), { + method, + headers: buildHeaders(body !== undefined), + body: body ? JSON.stringify(body) : undefined, + signal, + }); + + if (!res.ok) { + const detail = await extractErrorDetail(res); + const error = new ApiError(res.status, detail); + if (!silent) toast.error(detail); + throw error; + } + + return res; +} + +export const client = { + get: (path: string, params?: Record) => + request(path, { method: 'GET', params }), + post: ( + path: string, + body?: unknown, + params?: Record, + options?: { silent?: boolean }, + ) => request(path, { method: 'POST', body, params, silent: options?.silent }), + patch: ( + path: string, + body?: unknown, + params?: Record, + options?: { silent?: boolean }, + ) => request(path, { method: 'PATCH', body, params, silent: options?.silent }), + delete: (path: string, params?: Record) => + request(path, { method: 'DELETE', params }), + stream: (path: string, options?: RequestOptions & { signal?: AbortSignal }) => + streamRequest(path, options), +}; diff --git a/examples/web_ui/frontend/src/api/credential.ts b/examples/web_ui/frontend/src/api/credential.ts new file mode 100644 index 0000000..28f03f5 --- /dev/null +++ b/examples/web_ui/frontend/src/api/credential.ts @@ -0,0 +1,23 @@ +import { client } from './client'; +import type { + CreateCredentialRequest, + CreateCredentialResponse, + CredentialListResponse, + CredentialView, + CredentialSchemasResponse, + UpdateCredentialRequest, +} from './types'; + +export const credentialApi = { + list: () => client.get('/credential/'), + + schemas: () => client.get('/credential/schemas'), + + create: (body: CreateCredentialRequest) => + client.post('/credential/', body), + + update: (credentialId: string, body: UpdateCredentialRequest) => + client.patch(`/credential/${credentialId}`, body), + + delete: (credentialId: string) => client.delete(`/credential/${credentialId}`), +}; diff --git a/examples/web_ui/frontend/src/api/index.ts b/examples/web_ui/frontend/src/api/index.ts new file mode 100644 index 0000000..4a8abdc --- /dev/null +++ b/examples/web_ui/frontend/src/api/index.ts @@ -0,0 +1,9 @@ +export * from './types'; +export { agentApi } from './agent'; +export { sessionApi } from './session'; +export { credentialApi } from './credential'; +export { chatApi } from './chat'; +export { workspaceApi } from './workspace'; +export { scheduleApi } from './schedule'; +export { modelApi, ttsModelApi } from './model'; +export { knowledgeBaseApi } from './knowledgeBase'; diff --git a/examples/web_ui/frontend/src/api/knowledgeBase.ts b/examples/web_ui/frontend/src/api/knowledgeBase.ts new file mode 100644 index 0000000..b6f1f1f --- /dev/null +++ b/examples/web_ui/frontend/src/api/knowledgeBase.ts @@ -0,0 +1,179 @@ +import { ApiError, client, getBaseUrl, getUserId } from './client'; +import type { + CreateKnowledgeBaseRequest, + CreateKnowledgeBaseResponse, + KbMiddlewareParametersSchemaResponse, + KnowledgeBaseView, + ListKbEmbeddingModelsResponse, + ListKnowledgeBasesResponse, + ListKnowledgeDocumentsResponse, + ListKnowledgeDocumentStatusResponse, + ListSupportedContentTypesResponse, + SearchKnowledgeBaseRequest, + SearchKnowledgeBaseResponse, + UpdateKnowledgeBaseRequest, + UploadKnowledgeDocumentResponse, +} from './types'; + +/** + * Callback invoked while bytes are pushed across the wire. + * + * - `loaded` — bytes already sent. + * - `total` — total bytes (may be 0 when the browser cannot compute it, + * e.g. for chunked encodings). + */ +export interface UploadProgress { + loaded: number; + total: number; +} + +export interface UploadDocumentOptions { + /** Fired with byte-level progress while the body is streamed. */ + onProgress?: (progress: UploadProgress) => void; + /** + * Caller-supplied abort signal. Aborting before the server has + * responded rejects the returned promise with a `DOMException` of + * `name === "AbortError"`; aborting after a response has come back + * is a no-op. + */ + signal?: AbortSignal; +} + +/** + * XHR-based upload — `fetch` does not surface byte-level send + * progress in any current browser, so multipart uploads that drive a + * progress UI have to fall back to XMLHttpRequest. + */ +function uploadDocumentXhr( + knowledgeBaseId: string, + file: File, + options: UploadDocumentOptions = {}, +): Promise { + const { onProgress, signal } = options; + const formData = new FormData(); + formData.append('file', file); + + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new DOMException('Aborted', 'AbortError')); + return; + } + + const xhr = new XMLHttpRequest(); + const url = new URL(`/knowledge_bases/${knowledgeBaseId}/documents`, getBaseUrl()); + xhr.open('POST', url.toString(), true); + xhr.setRequestHeader('X-User-ID', getUserId()); + + const onAbort = () => xhr.abort(); + signal?.addEventListener('abort', onAbort, { once: true }); + + const cleanup = () => signal?.removeEventListener('abort', onAbort); + + if (xhr.upload && onProgress) { + xhr.upload.onprogress = (e) => { + onProgress({ + loaded: e.loaded, + total: e.lengthComputable ? e.total : 0, + }); + }; + } + + xhr.onload = () => { + cleanup(); + if (xhr.status >= 200 && xhr.status < 300) { + try { + resolve(JSON.parse(xhr.responseText) as UploadKnowledgeDocumentResponse); + } catch (e) { + reject(e); + } + return; + } + let detail = xhr.responseText || xhr.statusText; + try { + const json = JSON.parse(xhr.responseText) as { + detail?: unknown; + }; + if (typeof json.detail === 'string') detail = json.detail; + else if (json.detail !== undefined) detail = JSON.stringify(json.detail); + } catch { + // keep raw text + } + reject(new ApiError(xhr.status, detail)); + }; + xhr.onerror = () => { + cleanup(); + reject(new ApiError(0, 'Network error')); + }; + xhr.onabort = () => { + cleanup(); + reject(new DOMException('Aborted', 'AbortError')); + }; + + xhr.send(formData); + }); +} + +/** + * Client for the `/knowledge_bases` router. + */ +export const knowledgeBaseApi = { + list: () => client.get('/knowledge_bases/'), + + listEmbeddingModels: () => + client.get('/knowledge_bases/embedding_models'), + + /** Fetch the JSON Schema describing the KB middleware's tunable params. */ + middlewareParametersSchema: () => + client.get( + '/knowledge_bases/middleware/parameters_schema', + ), + + /** List the union of media types + extensions every parser accepts. */ + supportedContentTypes: () => + client.get('/knowledge_bases/supported_content_types'), + + create: (body: CreateKnowledgeBaseRequest) => + client.post('/knowledge_bases/', body), + + update: (knowledgeBaseId: string, body: UpdateKnowledgeBaseRequest) => + client.patch(`/knowledge_bases/${knowledgeBaseId}`, body), + + delete: (knowledgeBaseId: string) => client.delete(`/knowledge_bases/${knowledgeBaseId}`), + + /** List every document registered against a knowledge base. */ + listDocuments: (knowledgeBaseId: string) => + client.get(`/knowledge_bases/${knowledgeBaseId}/documents`), + + /** + * Batch-query lifecycle status for a list of documents. + * + * Missing ids are silently omitted by the server, so the response + * may be shorter than the input. An empty `ids` short-circuits + * locally — the backend treats an empty list as a 200 with + * `items: []`, but skipping the round-trip is friendlier to the + * polling loop. + */ + getDocumentStatus: (knowledgeBaseId: string, ids: string[]) => { + if (ids.length === 0) { + return Promise.resolve({ + items: [], + }); + } + return client.get( + `/knowledge_bases/${knowledgeBaseId}/documents/status`, + { ids: ids.join(',') }, + ); + }, + + uploadDocument: (knowledgeBaseId: string, file: File, options?: UploadDocumentOptions) => + uploadDocumentXhr(knowledgeBaseId, file, options), + + deleteDocument: (knowledgeBaseId: string, documentId: string) => + client.delete(`/knowledge_bases/${knowledgeBaseId}/documents/${documentId}`), + + search: (knowledgeBaseId: string, body: SearchKnowledgeBaseRequest) => + client.post( + `/knowledge_bases/${knowledgeBaseId}/search`, + body, + ), +}; diff --git a/examples/web_ui/frontend/src/api/model.ts b/examples/web_ui/frontend/src/api/model.ts new file mode 100644 index 0000000..3ebe948 --- /dev/null +++ b/examples/web_ui/frontend/src/api/model.ts @@ -0,0 +1,10 @@ +import { client } from './client'; +import type { ListModelResponse, ListTTSModelResponse } from './types'; + +export const modelApi = { + list: (provider: string) => client.get('/model/', { provider }), +}; + +export const ttsModelApi = { + list: (provider: string) => client.get('/tts-model/', { provider }), +}; diff --git a/examples/web_ui/frontend/src/api/schedule.ts b/examples/web_ui/frontend/src/api/schedule.ts new file mode 100644 index 0000000..8a94b5e --- /dev/null +++ b/examples/web_ui/frontend/src/api/schedule.ts @@ -0,0 +1,24 @@ +import { client } from './client'; +import type { + CreateScheduleRequest, + CreateScheduleResponse, + ScheduleListResponse, + ScheduleRecord, + ScheduleSessionsResponse, + UpdateScheduleRequest, +} from './types'; + +export const scheduleApi = { + list: () => client.get('/schedule/'), + + create: (body: CreateScheduleRequest) => + client.post('/schedule/', body), + + update: (scheduleId: string, body: UpdateScheduleRequest) => + client.patch(`/schedule/${scheduleId}`, body), + + delete: (scheduleId: string) => client.delete(`/schedule/${scheduleId}`), + + listSessions: (scheduleId: string) => + client.get(`/schedule/${scheduleId}/sessions`), +}; diff --git a/examples/web_ui/frontend/src/api/session.ts b/examples/web_ui/frontend/src/api/session.ts new file mode 100644 index 0000000..15dc5fe --- /dev/null +++ b/examples/web_ui/frontend/src/api/session.ts @@ -0,0 +1,104 @@ +import { client } from './client'; +import type { + AgentEvent, + CreateSessionRequest, + CreateSessionResponse, + InterruptSessionResponse, + SessionListResponse, + SessionRecord, + UpdateSessionRequest, + Msg, +} from './types'; + +export interface MessagesResponse { + messages: Msg[]; + is_running: boolean; +} + +export const sessionApi = { + list: (agentId: string) => client.get('/sessions/', { agent_id: agentId }), + + create: (body: CreateSessionRequest) => client.post('/sessions/', body), + + update: (sessionId: string, agentId: string, body: UpdateSessionRequest) => + client.patch(`/sessions/${sessionId}`, body, { agent_id: agentId }), + + delete: (sessionId: string, agentId: string) => + client.delete(`/sessions/${sessionId}`, { agent_id: agentId }), + + /** + * Request interruption of an in-progress reply (running or parked). + * + * Backend contract: + * - 202 Accepted → returns `InterruptSessionResponse`; the cancel + * signal was broadcast (running) or a wakeup-interrupt was + * enqueued (parked). Idempotent: an idle target is a silent + * no-op at the agent layer. + * - 404 Not Found → the session does not exist. + */ + interrupt: (sessionId: string, agentId: string) => + client.post(`/sessions/${sessionId}/interrupt`, null, { + agent_id: agentId, + }), + + messages: (sessionId: string, agentId: string, offset = 0, limit = 50) => + client.get(`/sessions/${sessionId}/messages`, { + agent_id: agentId, + offset: String(offset), + limit: String(limit), + }), + + /** + * Subscribe to a session's live event stream via SSE. + * + * Opens a long-lived ``GET /sessions/{sid}/stream`` connection and + * yields each ``AgentEvent`` as it arrives. The connection stays + * open until the caller aborts via the ``signal`` or closes the + * generator. + * + * Uses fetch-based SSE (not native ``EventSource``) so the + * ``X-User-ID`` custom header is sent. + * + * @param sessionId - The session to subscribe to. + * @param agentId - The agent that owns the session. + * @param signal - Optional abort signal to close the connection. + * @returns An async generator yielding ``AgentEvent`` objects. + */ + streamEvents: async function* ( + sessionId: string, + agentId: string, + signal?: AbortSignal, + ): AsyncGenerator { + const res = await client.stream(`/sessions/${sessionId}/stream`, { + method: 'GET', + params: { agent_id: agentId }, + signal, + }); + + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + + for (const line of lines) { + if (line.startsWith('data: ')) { + const json = line.slice(6).trim(); + if (json) yield JSON.parse(json) as AgentEvent; + } + // SSE comment frames (`:...\n`) are silently skipped + // (used for heartbeats). + } + } + } finally { + reader.releaseLock(); + } + }, +}; diff --git a/examples/web_ui/frontend/src/api/types.ts b/examples/web_ui/frontend/src/api/types.ts new file mode 100644 index 0000000..3446910 --- /dev/null +++ b/examples/web_ui/frontend/src/api/types.ts @@ -0,0 +1,736 @@ +// ─── Shared ─────────────────────────────────────────────────────────────────── + +export interface RecordBase { + id: string; + created_at: string; + updated_at: string; +} + +export interface ChatModelConfig { + type: string; + credential_id: string; + model: string; + parameters: Record; +} + +export interface TTSModelConfig { + type: string; + credential_id: string; + model: string; + parameters: Record; +} + +export interface ContextConfig { + trigger_ratio?: number; + reserve_ratio?: number; + tool_result_limit?: number; + compression_prompt?: string; + summary_template?: string; +} + +export interface ReActConfig { + max_iters?: number; + stop_on_reject?: boolean; +} + +export interface InviteConfig { + invitable?: boolean; + invite_description?: string | null; +} + +// ─── Agent ──────────────────────────────────────────────────────────────────── + +export interface AgentData { + id: string; + name: string; + system_prompt: string; + context_config: ContextConfig; + react_config: ReActConfig; + invite_config: InviteConfig; +} + +export interface AgentView extends RecordBase { + user_id: string; + data: AgentData; + /** + * Whether the current viewer may PATCH/DELETE this agent. `false` + * for agents shared to the viewer with read-only permission. + */ + editable: boolean; +} + +export interface CreateAgentRequest { + name: string; + system_prompt?: string; + context_config?: ContextConfig; + react_config?: ReActConfig; + invite_config?: InviteConfig; +} + +export interface CreateAgentResponse { + agent_id: string; +} + +export interface UpdateAgentRequest { + name?: string; + system_prompt?: string; + context_config?: ContextConfig; + react_config?: ReActConfig; + invite_config?: InviteConfig; +} + +export interface AgentListResponse { + agents: AgentView[]; + total: number; +} + +/** + * @deprecated Superseded by {@link AgentSchemaV2Response}. Kept only for + * legacy consumers still calling `GET /agent/schema`. The new form flow + * uses `GET /agent/schema/v2`, which returns the full `AgentData` JSON + * Schema in a single `schema` field. + */ +export interface AgentSchemaResponse { + identity: JSONSchema; + context_config: JSONSchema; + react_config: JSONSchema; +} + +/** + * Response of `GET /agent/schema/v2`. `schema` is the full `AgentData` + * JSON Schema (with `$ref`s inlined, `id` filtered out, and + * `context_config.summary_schema` filtered out). The frontend derives + * its section grouping directly from `schema.properties`: + * - top-level scalar/textarea/boolean properties → "identity" section + * - top-level `object`-typed properties (currently `context_config`, + * `react_config`, and `invite_config`) → one section each + */ +export interface AgentSchemaV2Response { + schema: JSONSchema; +} + +// ─── Session ────────────────────────────────────────────────────────────────── + +export type SessionSource = 'user' | 'schedule'; + +export interface SessionConfig { + name: string; + chat_model_config: ChatModelConfig; + /** Fallback model used when the primary model fails. */ + fallback_chat_model_config: ChatModelConfig | null; + /** TTS model configuration. null means TTS is not enabled. */ + tts_model_config: TTSModelConfig | null; + /** Knowledge bases attached to this session + KB middleware parameters. */ + knowledge_config: SessionKnowledgeConfig | null; + workspace_id: string; +} + +// TODO: update when Python side is finalised +export type AgentState = Record; + +export interface SessionRecord extends RecordBase { + user_id: string; + agent_id: string; + source: SessionSource; + source_schedule_id: string | null; + /** + * The team this session participates in, if any. Set when the + * session is the leader of a team (the session that called + * `TeamCreate`) or a worker spawned by `AgentCreate`. `null` for + * regular standalone sessions. + */ + team_id: string | null; + config: SessionConfig; + state: AgentState; +} + +export interface CreateSessionRequest { + agent_id: string; + workspace_id?: string; + chat_model_config?: ChatModelConfig | null; + /** Optional fallback model. Omit (or pass null) for no fallback. */ + fallback_chat_model_config?: ChatModelConfig | null; + /** Optional TTS model. Omit (or pass null) for no TTS. */ + tts_model_config?: TTSModelConfig | null; + /** Optional knowledge base attachment. Omit (or null) for none. */ + knowledge_config?: SessionKnowledgeConfig | null; +} + +export interface CreateSessionResponse { + session_id: string; +} + +export interface InterruptSessionResponse { + session_id: string; +} + +export interface UpdateSessionRequest { + name?: string; + chat_model_config?: ChatModelConfig; + /** + * New fallback model. PATCH semantics: + * - omit the field → leave unchanged + * - set to `null` → clear the existing fallback + * - set to a value → replace the existing fallback + */ + fallback_chat_model_config?: ChatModelConfig | null; + /** + * New TTS model. PATCH semantics: + * - omit the field → leave unchanged + * - set to `null` → disable TTS + * - set to a value → replace the existing TTS config + */ + tts_model_config?: TTSModelConfig | null; + /** + * New knowledge base attachment. PATCH semantics: + * - omit the field → leave unchanged + * - set to `null` → detach every knowledge base + * - set to a value → replace the existing attachment + */ + knowledge_config?: SessionKnowledgeConfig | null; + permission_mode?: PermissionMode; +} + +export interface SessionListResponse { + sessions: SessionView[]; + total: number; +} + +/** + * Response body for `GET /schedule/{id}/sessions`. Returns plain + * `SessionRecord[]` (no team / is_running enrichment) because + * scheduled-execution sessions are listed for audit purposes only, + * not for opening in the chat UI. + */ +export interface ScheduleSessionsResponse { + sessions: SessionRecord[]; + total: number; +} + +// ─── Team ───────────────────────────────────────────────────────────────────── + +export interface TeamData { + name: string; + description: string; + /** Worker agent ids belonging to the team. */ + member_ids: string[]; +} + +export interface TeamRecord extends RecordBase { + user_id: string; + /** The leader session id — the session that called `TeamCreate`. */ + session_id: string; + data: TeamData; +} + +/** + * One member entry inside `TeamDetailResponse.members`. Pairs the + * worker's `AgentView` with its single `session_id` so the UI can + * navigate straight to the worker's chat. + */ +export interface TeamMemberInfo { + agent: AgentView; + /** `null` if the agent is in an inconsistent state (no session). */ + session_id: string | null; +} + +/** + * Resolved team detail returned inline inside `SessionView.team`. + * + * The leader's `AgentView` is looked up from the team's + * `session_id` → `session.agent_id` chain on the server side. + */ +export interface TeamDetailResponse { + team: TeamRecord; + leader_agent: AgentView | null; + members: TeamMemberInfo[]; +} + +/** + * Per-session bundle returned by `GET /sessions/?agent_id=...`. + * + * Bundles three pieces of information so the chat UI can render a + * session without follow-up requests: the persisted record (incl. + * `state`), whether a chat run is active, and — when the session + * participates in a team — the resolved team detail. + * + * Messages are intentionally separate (`GET /sessions/{id}/messages`) + * since they paginate independently. + */ +export interface SessionView { + session: SessionRecord; + is_running: boolean; + team: TeamDetailResponse | null; +} + +// ─── JSON Schema ────────────────────────────────────────────────────────────── + +/** + * Subset of JSON Schema property fields the frontend renders. Sourced from + * Pydantic's `model_json_schema()` output, including the `format: textarea` + * hint we add via `json_schema_extra` for multi-line strings. + */ +export interface JSONSchemaProperty { + type?: string; + format?: string; + description?: string; + default?: unknown; + const?: unknown; + anyOf?: Array<{ type: string }>; + enum?: unknown[]; + title?: string; + writeOnly?: boolean; + minimum?: number; + maximum?: number; + exclusiveMinimum?: number; + exclusiveMaximum?: number; +} + +export interface JSONSchema { + title?: string; + type?: string; + properties: Record; + required?: string[]; +} + +// ─── Credential ─────────────────────────────────────────────────────────────── + +export type CredentialSchemaProperty = JSONSchemaProperty; + +// Credential schemas always include title + type (Pydantic always emits them +// for credential data classes); we narrow the generic JSONSchema here so call +// sites that read `schema.title` don't have to do null-checks. +export interface CredentialSchema extends JSONSchema { + title: string; + type: string; +} + +export interface CredentialSchemasResponse { + schemas: CredentialSchema[]; +} + +export interface CredentialView extends RecordBase { + user_id: string; + /** + * Credential payload. When the current viewer is not the owner + * (shared credential), only `type` and `name` are populated — + * secret fields are stripped server-side. + */ + data: Record; + /** + * Whether the current viewer may PATCH/DELETE this credential. + * `false` for credentials shared with read-only permission. + */ + editable: boolean; +} + +export interface CreateCredentialRequest { + data: Record; +} + +export interface CreateCredentialResponse { + credential_id: string; +} + +export interface UpdateCredentialRequest { + data: Record; +} + +export interface CredentialListResponse { + credentials: CredentialView[]; + total: number; +} + +// ─── Chat ───────────────────────────────────────────────────────────────────── + +export type { Msg, ContentBlock } from '@agentscope-ai/agentscope/message'; +export type { AgentEvent } from '@agentscope-ai/agentscope/event'; +import type { + UserConfirmResultEvent, + ExternalExecutionResultEvent, +} from '@agentscope-ai/agentscope/event'; +import type { Msg } from '@agentscope-ai/agentscope/message'; + +export interface ChatRequest { + agent_id: string; + session_id: string; + input: Msg | Msg[] | UserConfirmResultEvent | ExternalExecutionResultEvent | null; +} + +// ─── MCP ────────────────────────────────────────────────────────────────────── + +export interface StdioMCPConfig { + type: 'stdio_mcp'; + command: string; + args?: string[] | null; + env?: Record | null; + cwd?: string | null; + encoding_error_handler?: 'strict' | 'ignore' | 'replace'; +} + +export interface HttpMCPConfig { + type: 'http_mcp'; + url: string; + headers?: Record | null; + timeout?: number | null; +} + +export interface MCPClient { + name: string; + is_stateful: boolean; + mcp_config: StdioMCPConfig | HttpMCPConfig; +} + +export interface ToolInfo { + name: string; + description?: string | null; +} + +export interface MCPClientStatus extends MCPClient { + is_healthy: boolean; + tools: ToolInfo[]; +} + +// ─── Skill ──────────────────────────────────────────────────────────────────── + +export interface Skill { + name: string; + description: string; + dir: string; + markdown: string; + updated_at: number; +} + +export interface AddSkillRequest { + skill_path: string; +} + +// ─── Schedule ───────────────────────────────────────────────────────────────── + +export type PermissionMode = + | 'default' + | 'accept_edits' + | 'explore' + | 'bypass' + | 'dont_ask' + | (string & {}); + +export type ScheduleSource = 'USER' | 'AGENT'; + +export interface ScheduleData { + name: string; + description: string; + enabled: boolean; + timezone: string; + cron_expression: string; + started_at: string; + ended_at: string | null; + chat_model_config: ChatModelConfig; + stateful: boolean; + permission_mode: PermissionMode; + source: ScheduleSource; + source_session_id: string; +} + +export interface ScheduleRecord extends RecordBase { + user_id: string; + agent_id: string; + data: ScheduleData; +} + +export interface CreateScheduleRequest { + name: string; + description?: string; + cron_expression: string; + timezone?: string; + agent_id: string; + chat_model_config: ChatModelConfig; + enabled?: boolean; + stateful?: boolean; + permission_mode?: PermissionMode; +} + +export interface CreateScheduleResponse { + schedule_id: string; +} + +export interface UpdateScheduleRequest { + name?: string; + description?: string; + cron_expression?: string; + timezone?: string; + enabled?: boolean; + stateful?: boolean; + permission_mode?: PermissionMode; +} + +export interface ScheduleListResponse { + schedules: ScheduleRecord[]; + total: number; +} + +// ─── Model ──────────────────────────────────────────────────────────────────── + +export interface ModelCard { + type: 'chat_model'; + name: string; + label: string; + status: 'active' | 'deprecated' | 'sunset'; + deprecated_at: string | null; + input_types: string[]; + output_types: string[]; + context_size: number; + output_size: number; + parameter_schema: Record; + parameters_overrides: Record>; +} + +export interface ListModelRequest { + provider: string; +} + +export interface ListModelResponse { + models: ModelCard[]; + total: number; +} + +// ─── Embedding ──────────────────────────────────────────────────────────────── + +export interface EmbeddingModelConfig { + type: string; + credential_id: string; + model: string; + /** + * Output vector dimensions, pinned at config time. Required because + * the backend uses it to size the vector store collection and to + * validate against the manager's `DimensionPolicy`. + */ + dimensions: number; + parameters: Record; +} + +export interface EmbeddingModelCard { + type: 'embedding_model'; + name: string; + label: string; + status: 'active' | 'deprecated' | 'sunset'; + input_types: string[]; + output_types: string[]; + context_size: number | null; + /** Default output dimensions for this model. */ + dimensions: number; + /** + * If set, the only dimensions this model can produce (Matryoshka). + * `null` means the model is fixed-dim at `dimensions`. + */ + supported_dimensions: number[] | null; + parameter_schema: Record; + parameter_overrides: Record>; +} + +// ─── Knowledge Base ─────────────────────────────────────────────────────────── + +/** + * Knowledge base view as exposed by the API. Mirrors + * :class:`agentscope.app._service.KnowledgeBaseView`. + */ +export interface KnowledgeBaseView { + id: string; + name: string; + description: string; + embedding_model_config: EmbeddingModelConfig; + created_at: string; + updated_at: string; + /** + * Whether the current viewer may modify this knowledge base (edit + * metadata, add/delete documents). `false` for knowledge bases + * shared with read-only permission. + */ + editable: boolean; +} + +export interface ListKnowledgeBasesResponse { + knowledge_bases: KnowledgeBaseView[]; + total: number; +} + +export interface CreateKnowledgeBaseRequest { + name: string; + description?: string; + embedding_model_config: EmbeddingModelConfig; +} + +export interface CreateKnowledgeBaseResponse { + knowledge_base_id: string; +} + +/** + * Body for `PATCH /knowledge_bases/{id}`. Only mutable fields can be + * sent; the embedding model is pinned at creation time and cannot + * change because the underlying collection is sized to its dimension. + */ +export interface UpdateKnowledgeBaseRequest { + name?: string; + description?: string; +} + +/** + * Lifecycle states a document can be in. Mirrors + * :class:`agentscope.app.storage.KnowledgeDocumentStatus`. + * + * - `pending` — accepted, blob stored, indexing not yet started. + * - `parsing` / `chunking` / `indexing` — worker phases. + * - `ready` — chunks committed to the vector store. + * - `error` — terminal failure; `error` field carries the reason. + */ +export type KnowledgeDocumentStatus = + | 'pending' + | 'parsing' + | 'chunking' + | 'indexing' + | 'ready' + | 'error'; + +/** + * Document view returned by `/knowledge_bases/{id}/documents` and + * `/knowledge_bases/{id}/documents/status`. Mirrors + * :class:`agentscope.app._router._schema.KnowledgeDocumentView`. + */ +export interface KnowledgeDocumentView { + id: string; + filename: string; + size: number; + content_type: string | null; + status: KnowledgeDocumentStatus; + error: string | null; + chunk_count: number; + created_at: string; + updated_at: string; +} + +export interface ListKnowledgeDocumentsResponse { + documents: KnowledgeDocumentView[]; + total: number; +} + +export interface ListKnowledgeDocumentStatusResponse { + items: KnowledgeDocumentView[]; +} + +export interface UploadKnowledgeDocumentResponse { + document_id: string; + filename: string; + status: KnowledgeDocumentStatus; +} + +export interface SearchKnowledgeBaseRequest { + query: string; + top_k?: number; +} + +/** + * Lightweight chunk shape returned inside `VectorSearchResult`. Mirrors + * :class:`agentscope.rag.Chunk` — content is the raw `TextBlock` / + * `DataBlock` discriminated union the backend ships. + */ +export interface KnowledgeChunk { + content: { type: 'text'; text: string; id?: string } | { type: string; [key: string]: unknown }; + source: string; + chunk_index: number; + total_chunks: number; + metadata: Record; +} + +/** + * One vector search hit returned by the knowledge base search endpoint. + * Mirrors :class:`agentscope.rag.VectorSearchResult` on the backend. + */ +export interface VectorSearchResult { + score: number; + document_id: string; + chunk: KnowledgeChunk; +} + +export interface SearchKnowledgeBaseResponse { + results: VectorSearchResult[]; + total: number; +} + +/** + * Mirrors :class:`agentscope.app.rag.knowledge_base_manager.DimensionPolicyKind`. + */ +export type DimensionPolicyKind = 'any' | 'fixed' | 'locked_by_existing'; + +/** + * Mirrors :class:`agentscope.app.rag.knowledge_base_manager.DimensionPolicy`. + */ +export interface DimensionPolicy { + kind: DimensionPolicyKind; + dimension: number | null; +} + +/** One credential and the embedding models it can serve, post-policy. */ +export interface KbEmbeddingProvider { + credential: CredentialView; + models: EmbeddingModelCard[]; +} + +/** + * Response of `GET /knowledge_bases/embedding_models`. + * + * Server-side already filtered models by the manager's + * :class:`DimensionPolicy` and narrowed matryoshka cards to the + * locked dimension when applicable. The policy is included so the + * UI can render an explanatory banner. + */ +export interface ListKbEmbeddingModelsResponse { + providers: KbEmbeddingProvider[]; + policy: DimensionPolicy; +} + +/** + * Session-level knowledge base attachment. Persisted on + * :class:`SessionConfig.knowledge_config` and translated into a + * `KnowledgeBaseMiddleware` at chat-run time. + * + * `parameters` holds the user-tunable middleware fields verbatim — its + * accepted keys/values are described by the JSON Schema returned from + * `GET /knowledge_bases/middleware/parameters_schema`. + */ +export interface SessionKnowledgeConfig { + knowledge_base_ids: string[]; + parameters: Record; +} + +/** Response of `GET /knowledge_bases/middleware/parameters_schema`. */ +export interface KbMiddlewareParametersSchemaResponse { + parameter_schema: Record; +} + +/** Response of `GET /knowledge_bases/supported_content_types`. */ +export interface ListSupportedContentTypesResponse { + /** Union of IANA media types every registered parser handles. */ + media_types: string[]; + /** Union of filename extensions (each starting with `.`). */ + extensions: string[]; +} + +// ─── TTS ────────────────────────────────────────────────────────────────────── + +export interface TTSModelCard { + type: 'tts_model'; + name: string; + label: string; + status: 'active' | 'deprecated' | 'sunset'; + deprecated_at: string | null; + input_types: string[]; + output_types: string[]; + realtime: boolean; + parameter_schema: Record; + parameters_overrides: Record>; +} + +export interface ListTTSModelResponse { + models: TTSModelCard[]; + total: number; +} diff --git a/examples/web_ui/frontend/src/api/workspace.ts b/examples/web_ui/frontend/src/api/workspace.ts new file mode 100644 index 0000000..2c8e37b --- /dev/null +++ b/examples/web_ui/frontend/src/api/workspace.ts @@ -0,0 +1,38 @@ +import { client } from './client'; +import type { AddSkillRequest, MCPClient, MCPClientStatus, Skill } from './types'; + +export const workspaceApi = { + mcp: { + list: (agentId: string, sessionId: string) => + client.get('/workspace/mcp', { + agent_id: agentId, + session_id: sessionId, + }), + + add: (agentId: string, sessionId: string, mcp: MCPClient) => + client.post('/workspace/mcp', mcp, { agent_id: agentId, session_id: sessionId }), + + remove: (mcpName: string, agentId: string, sessionId: string) => + client.delete(`/workspace/mcp/${mcpName}`, { + agent_id: agentId, + session_id: sessionId, + }), + }, + + skill: { + list: (agentId: string, sessionId: string) => + client.get('/workspace/skill', { agent_id: agentId, session_id: sessionId }), + + add: (agentId: string, sessionId: string, body: AddSkillRequest) => + client.post('/workspace/skill', body, { + agent_id: agentId, + session_id: sessionId, + }), + + remove: (skillName: string, agentId: string, sessionId: string) => + client.delete(`/workspace/skill/${skillName}`, { + agent_id: agentId, + session_id: sessionId, + }), + }, +}; diff --git a/examples/web_ui/frontend/src/assets/images/agentscope.svg b/examples/web_ui/frontend/src/assets/images/agentscope.svg new file mode 100644 index 0000000..e781d9c --- /dev/null +++ b/examples/web_ui/frontend/src/assets/images/agentscope.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/web_ui/frontend/src/assets/images/line-corner.svg b/examples/web_ui/frontend/src/assets/images/line-corner.svg new file mode 100644 index 0000000..07c5909 --- /dev/null +++ b/examples/web_ui/frontend/src/assets/images/line-corner.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/web_ui/frontend/src/assets/images/line-vertical.svg b/examples/web_ui/frontend/src/assets/images/line-vertical.svg new file mode 100644 index 0000000..9815fe5 --- /dev/null +++ b/examples/web_ui/frontend/src/assets/images/line-vertical.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/web_ui/frontend/src/assets/images/mcp.svg b/examples/web_ui/frontend/src/assets/images/mcp.svg new file mode 100644 index 0000000..4c9a39e --- /dev/null +++ b/examples/web_ui/frontend/src/assets/images/mcp.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/examples/web_ui/frontend/src/components/badge/InputTypeBadges.tsx b/examples/web_ui/frontend/src/components/badge/InputTypeBadges.tsx new file mode 100644 index 0000000..351afa5 --- /dev/null +++ b/examples/web_ui/frontend/src/components/badge/InputTypeBadges.tsx @@ -0,0 +1,65 @@ +import { Type, Image, Video, AudioLines } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; + +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; + +// ─── Config ─────────────────────────────────────────────────────────────────── + +interface ModalityConfig { + /** The MIME main-type prefix this slot matches, e.g. "image" */ + mainType: string; + /** Lucide icon to render */ + Icon: LucideIcon; + /** Fallback label shown in tooltip when no subtypes are present */ + label: string; + /** text/plain is a special case — matched by exact MIME type */ + exactMatch?: string; +} + +const MODALITIES: ModalityConfig[] = [ + { mainType: 'text', exactMatch: 'text/plain', Icon: Type, label: 'text/plain' }, + { mainType: 'image', Icon: Image, label: 'image' }, + { mainType: 'video', Icon: Video, label: 'video' }, + { mainType: 'audio', Icon: AudioLines, label: 'audio' }, +]; + +// ─── Component ──────────────────────────────────────────────────────────────── + +interface InputTypeBadgesProps { + /** Array of MIME types, e.g. ["text/plain", "image/jpeg", "audio/mp3"] */ + inputTypes: string[]; + className?: string; +} + +export function InputTypeBadges({ inputTypes, className }: InputTypeBadgesProps) { + return ( + +
+ {MODALITIES.map(({ mainType, exactMatch, Icon, label }) => { + // Find matching MIME types from the model's input_types + const matched = exactMatch + ? inputTypes.filter((t) => t === exactMatch) + : inputTypes.filter((t) => t.startsWith(mainType + '/')); + + const supported = matched.length > 0; + const tooltipText = supported ? matched.join(', ') : label; + + return ( + + + + + + {tooltipText} + + + ); + })} +
+
+ ); +} diff --git a/examples/web_ui/frontend/src/components/badge/StatusBadge.tsx b/examples/web_ui/frontend/src/components/badge/StatusBadge.tsx new file mode 100644 index 0000000..07695cb --- /dev/null +++ b/examples/web_ui/frontend/src/components/badge/StatusBadge.tsx @@ -0,0 +1,55 @@ +import { CheckCircle, XCircle } from 'lucide-react'; + +import { Badge } from '@/components/ui/badge'; +import { Spinner } from '@/components/ui/spinner'; +import { useTranslation } from '@/i18n/useI18n'; +import { cn } from '@/lib/utils'; + +/** + * Displays a status badge with icon and label based on execution status. + * @param root0 - Component props. + * @param root0.className - Optional CSS class. + * @param root0.status - The execution status to display. + * @returns A styled Badge component. + */ +export function StatusBadge({ + className, + status, +}: { + className?: string; + status: 'running' | 'completed' | 'failed'; +}) { + const { t } = useTranslation(); + switch (status) { + case 'running': + return ( + + + {t('common.running')} + + ); + case 'completed': + return ( + + + {t('common.completed')} + + ); + case 'failed': + return ( + + + {t('common.failed')} + + ); + } +} diff --git a/examples/web_ui/frontend/src/components/chat/ChatContent.tsx b/examples/web_ui/frontend/src/components/chat/ChatContent.tsx new file mode 100644 index 0000000..16aa0b0 --- /dev/null +++ b/examples/web_ui/frontend/src/components/chat/ChatContent.tsx @@ -0,0 +1,137 @@ +import type { ContentBlock, Msg, ToolCallBlock } from '@agentscope-ai/agentscope/message'; +import React from 'react'; +import { useRef, useEffect } from 'react'; + +import { EmptyMessage } from './Empty'; +import { MessageBubble } from '@/components/chat/MessageBubble'; +import { TextInput } from '@/components/chat/TextInput.tsx'; +import type { ReplyPhase } from '@/hooks/useMessages'; +import { cn } from '@/lib/utils'; + +interface ChatContentProps { + msgs: Msg[]; + /** + * Reply lifecycle phase from ``useMessages`` — forwarded to + * ``TextInput`` so the single send / stop button can pick its + * icon, tooltip, disabled state and click handler from one source. + */ + phase: ReplyPhase; + disabled: boolean; + onSend: (content: ContentBlock[]) => void; + onUserConfirm: ( + toolCall: ToolCallBlock, + confirm: boolean, + replyId: string, + rules?: ToolCallBlock['suggested_rules'], + ) => void; + autoComplete?: (input: string) => string | null; + className?: string; + /** Called when the user clicks the stop button. */ + onInterrupt?: () => void; + /** + * Optional content pinned at the bottom of the chat — between the + * message scroll area and the text input (e.g. pending subagent HITL + * cards on a team leader's view). Rendered below the conversation so + * a pending confirmation sits next to the input, where the user is + * looking, rather than scrolled off the top. + */ + footerSlot?: React.ReactNode; + /** @see TextInputProps.allowedInputTypes */ + allowedInputTypes: string[]; + /** @see TextInputProps.fileProcessor */ + fileProcessor: (file: File) => Promise; +} + +const ChatContentComponent: React.FC = ({ + msgs, + phase, + disabled, + onSend, + onUserConfirm, + autoComplete, + className, + onInterrupt, + footerSlot, + allowedInputTypes, + fileProcessor, +}) => { + const scrollAreaRef = useRef(null); + const prevMsgCountRef = useRef(0); + const wasNearBottomRef = useRef(true); + + // Auto-scroll to bottom only if user is already near the bottom + useEffect(() => { + const currentCount = msgs.length; + const prevCount = prevMsgCountRef.current; + + const isActive = phase !== 'idle'; + const shouldCheck = + (currentCount > prevCount && prevCount > 0) || (isActive && prevCount > 0); + + if (shouldCheck && scrollAreaRef.current) { + const { scrollHeight } = scrollAreaRef.current; + + // Check if user was near bottom before content changed + const isNearBottom = wasNearBottomRef.current; + + if (isNearBottom) { + scrollAreaRef.current.scrollTo({ + top: scrollHeight, + behavior: 'smooth', + }); + } + } + + prevMsgCountRef.current = currentCount; + }, [msgs, phase]); + + // Track if user is near bottom whenever they scroll + useEffect(() => { + const scrollArea = scrollAreaRef.current; + if (!scrollArea) return; + + const handleScroll = () => { + const { scrollTop, scrollHeight, clientHeight } = scrollArea; + wasNearBottomRef.current = scrollTop + clientHeight >= scrollHeight - 50; + }; + + scrollArea.addEventListener('scroll', handleScroll); + return () => scrollArea.removeEventListener('scroll', handleScroll); + }, []); + + return ( +
+
+
+ {msgs.length > 0 ? ( + msgs.map((message) => ( + + )) + ) : ( + + )} +
+
+ {footerSlot ?
{footerSlot}
: null} + +
+ ); +}; + +export const ChatContent = React.memo(ChatContentComponent); diff --git a/examples/web_ui/frontend/src/components/chat/ConfirmCard.tsx b/examples/web_ui/frontend/src/components/chat/ConfirmCard.tsx new file mode 100644 index 0000000..dd1bfcf --- /dev/null +++ b/examples/web_ui/frontend/src/components/chat/ConfirmCard.tsx @@ -0,0 +1,150 @@ +import type { ToolCallBlock } from '@agentscope-ai/agentscope/message'; +import { ChevronRight } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +import { getDisplayName, renderConfirmBody } from './tool-renderers'; +import { Button } from '@/components/ui/button'; +import { Kbd } from '@/components/ui/kbd'; +import { useTranslation } from '@/i18n/useI18n'; +import { cn } from '@/lib/utils'; + +type SelectOption = 'yes' | 'yes_with_rule' | 'no'; + +export function ConfirmCard({ + toolCall, + onUserConfirm, +}: { + toolCall: ToolCallBlock; + onUserConfirm: (confirm: boolean, rules?: ToolCallBlock['suggested_rules']) => void; +}) { + const { t } = useTranslation(); + const hasSuggestedRules = !!toolCall.suggested_rules?.length; + const options: SelectOption[] = hasSuggestedRules + ? ['yes', 'yes_with_rule', 'no'] + : ['yes', 'no']; + const [selected, setSelected] = useState('yes'); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const currentIndex = options.indexOf(selected); + switch (e.key) { + case 'ArrowUp': + e.preventDefault(); + setSelected(options[(currentIndex - 1 + options.length) % options.length]); + break; + case 'ArrowDown': + e.preventDefault(); + setSelected(options[(currentIndex + 1) % options.length]); + break; + case 'Enter': + e.preventDefault(); + if (selected === 'yes_with_rule') { + onUserConfirm(true, [toolCall.suggested_rules![0]]); + } else { + onUserConfirm(selected === 'yes'); + } + break; + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onUserConfirm, selected, options]); + + return ( +
+
+ {getDisplayName(toolCall, t)} +
+ {renderConfirmBody(toolCall, t)} +
+
+
+ + {t('chat.confirmToolCall')} + + + {hasSuggestedRules && ( + + )} + +
+
+ ); +} diff --git a/examples/web_ui/frontend/src/components/chat/Empty.tsx b/examples/web_ui/frontend/src/components/chat/Empty.tsx new file mode 100644 index 0000000..3f3b9cf --- /dev/null +++ b/examples/web_ui/frontend/src/components/chat/Empty.tsx @@ -0,0 +1,30 @@ +import { MessagesSquare } from 'lucide-react'; + +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@/components/ui/empty'; +import { useTranslation } from '@/i18n/useI18n'; + +/** + * Empty state component shown when there are no messages. + * @returns An empty state element with icon and description. + */ +export function EmptyMessage() { + const { t } = useTranslation(); + + return ( + + + + + + {t('chat.noMessages')} + {t('chat.noMessagesDesc')} + + + ); +} diff --git a/examples/web_ui/frontend/src/components/chat/FileAttachment.tsx b/examples/web_ui/frontend/src/components/chat/FileAttachment.tsx new file mode 100644 index 0000000..703a741 --- /dev/null +++ b/examples/web_ui/frontend/src/components/chat/FileAttachment.tsx @@ -0,0 +1,119 @@ +import { Download, File, FileAudio, FileImage, FileText, FileVideo } from 'lucide-react'; +import * as mime from 'mime-types'; + +interface FileIconProps { + /** The MIME type, e.g. `application/pdf`. */ + mediaType: string; + /** Optional CSS classes forwarded to the icon. */ + className?: string; +} + +/** + * Render a representative icon for a file based on its MIME type. + * + * @param root0 - The component props. + * @param root0.mediaType - The MIME type of the file. + * @param root0.className - CSS classes forwarded to the icon. + * @returns A lucide icon element appropriate for the file kind. + */ +function FileIcon({ mediaType, className }: FileIconProps) { + switch (mediaType.split('/')[0]) { + case 'image': + return ; + case 'audio': + return ; + case 'video': + return ; + case 'text': + return ; + default: + return mediaType === 'application/pdf' ? ( + + ) : ( + + ); + } +} + +/** + * Classify a file href for safe, correct linking. + * + * - `safe`: only `http`/`https`/`data`/`blob` schemes become a clickable + * link, so a malicious model-supplied URL (e.g. `javascript:`) can't turn + * the card into a click-to-execute vector. + * - `downloadable`: the HTML `download` attribute is only honoured for + * same-origin, `blob:`, and `data:` URLs; cross-origin remote URLs ignore + * it and just open. We only set `download` when it will actually work, and + * open remote URLs in a new tab instead. + * + * @param href - The candidate href (remote URL or `data:` URL). + * @returns Whether the href is safe to link and whether `download` applies. + */ +function classifyHref(href: string): { safe: boolean; downloadable: boolean } { + let url: URL; + try { + url = new URL(href, window.location.origin); + } catch { + return { safe: false, downloadable: false }; + } + const safe = ['http:', 'https:', 'data:', 'blob:'].includes(url.protocol); + const downloadable = + url.protocol === 'data:' || + url.protocol === 'blob:' || + url.origin === window.location.origin; + return { safe, downloadable }; +} + +interface FileAttachmentProps { + /** Display name of the file (falls back to a name generated from the extension). */ + name?: string; + /** Resolved href — a remote URL or a `data:` URL — used for download. */ + href: string; + /** MIME type of the file, e.g. `application/pdf`. */ + mediaType: string; +} + +/** + * A compact, downloadable card for a non-previewable file attachment + * (PDF, Word, Excel, plain-text blobs, …). + * + * Image / audio / video data blocks are rendered inline by the caller; + * every other MIME type falls back to this card instead of rendering + * nothing at all. + * + * @param root0 - The component props. + * @param root0.name - Display name of the file. + * @param root0.href - Download href (remote URL or `data:` URL). + * @param root0.mediaType - MIME type of the file. + * @returns A downloadable file-attachment card. + */ +export function FileAttachment({ name, href, mediaType }: FileAttachmentProps) { + const ext = (mime.extension(mediaType) || '').toUpperCase(); + const displayName = name || (ext ? `file.${ext.toLowerCase()}` : 'file'); + const { safe, downloadable } = classifyHref(href); + + // Only safe schemes become a real link. Downloadable hrefs (data:/blob:/ + // same-origin) get `download`; remote URLs open in a new tab, where the + // `download` attribute would be ignored anyway. + const linkProps = !safe + ? {} + : downloadable + ? { href, download: displayName } + : { href, target: '_blank' as const, rel: 'noopener noreferrer' }; + + return ( + + + + + + {displayName} + {ext && {ext}} + + + + ); +} diff --git a/examples/web_ui/frontend/src/components/chat/MessageBubble.tsx b/examples/web_ui/frontend/src/components/chat/MessageBubble.tsx new file mode 100644 index 0000000..13e07c8 --- /dev/null +++ b/examples/web_ui/frontend/src/components/chat/MessageBubble.tsx @@ -0,0 +1,611 @@ +import type { + ContentBlock, + DataBlock, + Msg, + TextBlock, + ToolCallBlock, +} from '@agentscope-ai/agentscope/message'; +import { + ArrowDown, + ArrowUp, + Bot, + CalendarClock, + CheckCircle, + ChevronDownIcon, + CirclePlay, + Copy, + Loader2, + MessageSquareQuote, + Wrench, +} from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; + +import { ConfirmCard } from './ConfirmCard'; +import { FileAttachment } from './FileAttachment'; +import { renderToolGroup } from './tool-renderers'; +import type { TFunction, ToolCallWithResult } from './tool-renderers/types'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible.tsx'; +import { Item, ItemContent } from '@/components/ui/item.tsx'; +import { useAudioBlock, useReplayController } from '@/context/AudioContext'; +import { useTranslation } from '@/i18n/useI18n'; +import { formatNumber, formatTime } from '@/utils/common'; + +interface ToolCallGroupBlock { + type: 'tool_call_group'; + id: string; + toolName: string; + calls: ToolCallWithResult[]; +} + +type ExtendedContentBlock = ContentBlock | ToolCallGroupBlock; + +/** + * Group tool_call blocks of the same name into a single + * `tool_call_group`, with each call paired to its matching + * tool_result by id. + * + * Unlike the previous implementation this does NOT require calls of + * the same name to be consecutive. When the agent issues multiple + * concurrent tool calls (e.g. Glob + Grep), the content layout is + * `[call_Glob, call_Grep, result_Glob, result_Grep]` — the old + * "consecutive-same-name" approach would split call and result into + * separate groups. This version collects all calls first (preserving + * encounter order), then matches results, and finally emits groups + * in the order the first call of each tool name appeared, + * interleaved with non-tool blocks at their original positions. + */ +function groupToolCalls(content: ContentBlock[]): ExtendedContentBlock[] { + // Pass 1: pair calls ↔ results by id, track non-tool blocks. + const callMap = new Map(); + const resultMap = new Map(); + const ordering: Array<{ type: 'tool'; id: string } | { type: 'other'; block: ContentBlock }> = + []; + + for (const block of content) { + if (block.type === 'tool_call') { + const entry: ToolCallWithResult = { call: block }; + callMap.set(block.id, entry); + ordering.push({ type: 'tool', id: block.id }); + } else if (block.type === 'tool_result') { + const matching = callMap.get(block.id); + if (matching) { + matching.result = block; + } else { + resultMap.set(block.id, block); + } + } else { + ordering.push({ type: 'other', block }); + } + } + + // Pass 2: walk the ordering, group consecutive same-name calls + // (now that results are already attached). + const result: ExtendedContentBlock[] = []; + let currentGroup: ToolCallWithResult[] = []; + let currentToolName: string | null = null; + + const flush = () => { + if (currentGroup.length > 0 && currentToolName) { + result.push({ + type: 'tool_call_group', + id: crypto.randomUUID(), + toolName: currentToolName, + calls: currentGroup, + }); + currentGroup = []; + currentToolName = null; + } + }; + + for (const item of ordering) { + if (item.type === 'other') { + flush(); + result.push(item.block); + } else { + const entry = callMap.get(item.id); + if (!entry) continue; + if (currentToolName !== null && currentToolName !== entry.call.name) { + flush(); + } + currentToolName = entry.call.name; + currentGroup.push(entry); + } + } + flush(); + + // Orphan results (no matching call) — render as synthetic groups. + for (const [id, block] of resultMap) { + if (block.type === 'tool_result') { + result.push({ + type: 'tool_call_group', + id: crypto.randomUUID(), + toolName: block.name, + calls: [ + { + call: { + type: 'tool_call', + id, + name: block.name, + input: '', + state: 'finished' as const, + }, + result: block, + }, + ], + }); + } + } + + return result; +} + +const AUDIO_WAVE_LINES: Array<{ x: number; y1: number; y2: number }> = [ + { x: 2, y1: 10, y2: 13 }, + { x: 6, y1: 6, y2: 17 }, + { x: 10, y1: 3, y2: 21 }, + { x: 14, y1: 8, y2: 15 }, + { x: 18, y1: 5, y2: 18 }, + { x: 22, y1: 10, y2: 13 }, +]; + +function AudioWave({ isPlaying = true, className }: { isPlaying?: boolean; className?: string }) { + return ( + <> + {isPlaying && ( + + )} + + {AUDIO_WAVE_LINES.map(({ x, y1, y2 }, i) => ( + + ))} + + + ); +} + +/** + * Inline audio control rendered *inside* the time/usage Badge so the play + * icon visually merges into the same chip rather than floating as its own + * pill. + */ +function AudioInlineControl({ block }: { block: DataBlock }) { + const { t } = useTranslation(); + const audioState = useAudioBlock(block.id); + const replayController = useReplayController(); + const audioRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + + const isStreaming = audioState?.status === 'streaming'; + + // Don't build the giant base64 data URL while bytes are still streaming — + // it would re-allocate on every DATA_BLOCK_DELTA. Live playback during + // that window is handled by the manager's WavStreamPlayer; we only need + // `src` for replay after the stream ends (or for historical messages). + let src: string | null = null; + if (!isStreaming) { + if (audioState?.url) { + src = audioState.url; + } else if (block.source.type === 'url') { + src = block.source.url; + } else if (block.source.type === 'base64' && block.source.data) { + src = `data:${block.source.media_type};base64,${block.source.data}`; + } + } + + // Reset the hidden