chore: import upstream snapshot with attribution
K8s Workspace Integration Tests / k8s-workspace-tests (push) Has been cancelled
Pre-commit / run (ubuntu-latest) (push) Has been cancelled
Python Unittest Coverage / test (macos-15, 3.11) (push) Has been cancelled
Python Unittest Coverage / test (ubuntu-latest, 3.11) (push) Has been cancelled
Python Unittest Coverage / test (windows-latest, 3.11) (push) Has been cancelled
Web UI / check (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:39:27 +08:00
commit c3bf08ac8d
755 changed files with 172616 additions and 0 deletions
+96
View File
@@ -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 <https://xxx>`_
.. 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`
+67
View File
@@ -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: |
**<u>AgentScope is an open-source project. To involve a broader community, we recommend asking your questions in English.</u>**
- 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
+8
View File
@@ -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.
+25
View File
@@ -0,0 +1,25 @@
## PR Title Format
Please ensure your PR title follows the Conventional Commits format:
- Format: `<type>(<scope>): <description>`
- 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
+97
View File
@@ -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 <https://xxx>`_
.. 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`
+152
View File
@@ -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 = "<!-- BEGIN NEWS -->"
end_marker = "<!-- END NEWS -->"
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()
+63
View File
@@ -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
+81
View File
@@ -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
+49
View File
@@ -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
+35
View File
@@ -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"
+43
View File
@@ -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 }}
+35
View File
@@ -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
+32
View File
@@ -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
+39
View File
@@ -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
+46
View File
@@ -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
+166
View File
@@ -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*
+116
View File
@@ -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, .]
+420
View File
@@ -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/<your-username>/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/<short-description>
```
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.
```
<type>(<scope>): <subject>
```
**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.
```
<type>(<scope>): <description>
```
**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.<provider>/`, 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.<provider>._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:
- `<Provider>ChatFormatter` for single-user chat scenarios
- `<Provider>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/
└── <example-name>/
├── 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.
+298
View File
@@ -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/<your-username>/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/<short-description>
```
### 第 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>(<scope>) <subject>
```
**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>(<scope>) <description>
```
**要求:**
- 标题须以下列 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/agentscopemain` 发起 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.<provider>/`,继承 `ChatModelBase`。实现需覆盖:
- 流式与非流式两种模式
- Tools API 集成(function/tool calling)
- `tool_choice` 参数
- 适用时的 reasoning 模型支持
_参考:`agentscope/model/_anthropic/`_
3. **Model card YAML**——位于 `agentscope.model.<provider>._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 对话与单用户对话的处理方式不同:
- `<Provider>ChatFormatter` 处理单用户对话场景
- `<Provider>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/
└── <example-name>/
├── 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 所做的贡献!每一份努力都在为社区构建更好的开源工具。
+391
View File
@@ -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 <wenzhixin2010@gmail.com> (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.
--------------------------------------------------------------------------------
+277
View File
@@ -0,0 +1,277 @@
<p align="center">
<img
src="https://img.alicdn.com/imgextra/i1/O1CN01nTg6w21NqT5qFKH1u_!!6000000001621-55-tps-550-550.svg"
alt="AgentScope Logo"
width="200"
/>
</p>
<span align="center">
[**中文主页**](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)
</span>
<p align="center">
<a href="https://arxiv.org/abs/2402.14034">
<img
src="https://img.shields.io/badge/cs.MA-2402.14034-B31C1C?logo=arxiv&logoColor=B31C1C"
alt="arxiv"
/>
</a>
<a href="https://pypi.org/project/agentscope/">
<img
src="https://img.shields.io/badge/python-3.11+-blue?logo=python"
alt="pypi"
/>
</a>
<a href="https://pypi.org/project/agentscope/">
<img
src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fpypi.org%2Fpypi%2Fagentscope%2Fjson&query=%24.info.version&prefix=v&logo=pypi&label=version"
alt="pypi"
/>
</a>
<a href="https://discord.gg/eYMpfnkG8h">
<img
src="https://img.shields.io/badge/Discord-Join%20Us-5865F2?logo=discord&logoColor=white"
alt="discord"
/>
</a>
<a href="https://docs.agentscope.io/">
<img
src="https://img.shields.io/badge/Docs-English%7C%E4%B8%AD%E6%96%87-blue?logo=markdown"
alt="docs"
/>
</a>
<a href="./LICENSE">
<img
src="https://img.shields.io/badge/license-Apache--2.0-black"
alt="license"
/>
</a>
</p>
<p align="center">
<img src="https://trendshift.io/api/badge/repositories/20310" alt="agentscope-ai%2Fagentscope | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
</p>
## 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.
<img src="assets/images/agentscope.png" alt="agentscope" width="100%"/>
## News
<!-- BEGIN 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/)
<!-- END NEWS -->
[More news →](./docs/NEWS.md)
## Community
Welcome to join our community on
| [Discord](https://discord.gg/eYMpfnkG8h) | DingTalk |
|----------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------|
| <img src="https://gw.alicdn.com/imgextra/i1/O1CN01hhD1mu1Dd3BWVUvxN_!!6000000000238-2-tps-400-400.png" width="100" height="100"> | <img src="./assets/images/dingtalk_qr_code.png" width="100" height="100"> |
## 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`
<table>
<tr>
<td align="center">
<img src="assets/images/team.gif" alt="Agent team" width="100%"/>
<br/>
<sub><b>Agent team</b> — a leader agent spawns workers and coordinates them through the built-in team tools.</sub>
</td>
</tr>
<tr>
<td align="center">
<img src="assets/images/task.gif" alt="Task planning" width="100%"/>
<br/>
<sub><b>Task planning</b> — the agent breaks complex work into a tracked plan and updates it as it goes.</sub>
</td>
</tr>
<tr>
<td align="center">
<img src="assets/images/permission_bypass.gif" alt="Permission control in bypass mode" width="100%"/>
<br/>
<sub><b>Permission control in bypass mode</b> — the agent runs end-to-end without pausing for tool-call confirmations.</sub>
</td>
</tr>
<tr>
<td align="center">
<img src="assets/images/bg_tool.gif" alt="Background task offloading" width="100%"/>
<br/>
<sub><b>Background task offloading</b> — a long-running tool moves to the background; its result later wakes the agent up and the conversation resumes.</sub>
</td>
</tr>
</table>
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:
<a href="https://github.com/agentscope-ai/agentscope/graphs/contributors">
<img src="https://contrib.rocks/image?repo=agentscope-ai/agentscope&max=999&columns=12&anon=1" />
</a>
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`agentscope-ai/agentscope`
- 原始仓库:https://github.com/agentscope-ai/agentscope
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+300
View File
@@ -0,0 +1,300 @@
<p align="center">
<img
src="https://img.alicdn.com/imgextra/i1/O1CN01nTg6w21NqT5qFKH1u_!!6000000001621-55-tps-550-550.svg"
alt="AgentScope Logo"
width="200"
/>
</p>
<span align="center">
[**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)
</span>
<p align="center">
<a href="https://arxiv.org/abs/2402.14034">
<img
src="https://img.shields.io/badge/cs.MA-2402.14034-B31C1C?logo=arxiv&logoColor=B31C1C"
alt="arxiv"
/>
</a>
<a href="https://pypi.org/project/agentscope/">
<img
src="https://img.shields.io/badge/python-3.11+-blue?logo=python"
alt="pypi"
/>
</a>
<a href="https://pypi.org/project/agentscope/">
<img
src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fpypi.org%2Fpypi%2Fagentscope%2Fjson&query=%24.info.version&prefix=v&logo=pypi&label=version"
alt="pypi"
/>
</a>
<a href="https://discord.gg/eYMpfnkG8h">
<img
src="https://img.shields.io/badge/Discord-Join%20Us-5865F2?logo=discord&logoColor=white"
alt="discord"
/>
</a>
<a href="https://docs.agentscope.io/">
<img
src="https://img.shields.io/badge/Docs-English%7C%E4%B8%AD%E6%96%87-blue?logo=markdown"
alt="docs"
/>
</a>
<a href="./LICENSE">
<img
src="https://img.shields.io/badge/license-Apache--2.0-black"
alt="license"
/>
</a>
</p>
<p align="center">
<img src="https://trendshift.io/api/badge/repositories/20310" alt="agentscope-ai%2Fagentscope | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/>
</p>
## 什么是 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 支持
<img src="assets/images/agentscope.png" alt="agentscope" width="100%"/>
## 新闻
<!-- BEGIN NEWS -->
- **[2026-07] `集成`:** 集成 K8sOpenSandbox 工作区/沙箱实现。 [文档](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/)
<!-- END NEWS -->
[更多新闻 →](./docs/NEWS_zh.md)
## 社区
欢迎加入我们的社区
| [Discord](https://discord.gg/eYMpfnkG8h) | 钉钉 |
|----------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------|
| <img src="https://gw.alicdn.com/imgextra/i1/O1CN01hhD1mu1Dd3BWVUvxN_!!6000000000238-2-tps-400-400.png" width="100" height="100"> | <img src="./assets/images/dingtalk_qr_code.png" width="100" height="100"> |
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
## 📑 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)
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
## 快速开始
### 安装
> 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
<table>
<tr>
<td align="center">
<img src="assets/images/team.gif" alt="智能体团队" width="100%"/>
<br/>
<sub><b>智能体团队</b> —— leader 智能体派生 worker,并通过内置的团队工具进行协调。</sub>
</td>
</tr>
<tr>
<td align="center">
<img src="assets/images/task.gif" alt="任务规划" width="100%"/>
<br/>
<sub><b>任务规划</b> —— 智能体将复杂工作拆解为可追踪的计划,并在执行过程中持续更新。</sub>
</td>
</tr>
<tr>
<td align="center">
<img src="assets/images/permission_bypass.gif" alt="bypass 模式下的权限控制" width="100%"/>
<br/>
<sub><b>bypass 模式下的权限控制</b> —— 智能体端到端运行,无需为工具调用确认而暂停。</sub>
</td>
</tr>
<tr>
<td align="center">
<img src="assets/images/bg_tool.gif" alt="后台任务卸载" width="100%"/>
<br/>
<sub><b>工具后台执行</b> —— 长时间运行的工具被转入后台;其结果稍后唤醒智能体并恢复对话。</sub>
</td>
</tr>
</table>
运行以下命令启动智能体服务后端和 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},
}
```
## 贡献者
感谢所有贡献者:
<a href="https://github.com/agentscope-ai/agentscope/graphs/contributors">
<img src="https://contrib.rocks/image?repo=agentscope-ai/agentscope&max=999&columns=12&anon=1" />
</a>
Binary file not shown.

After

Width:  |  Height:  |  Size: 637 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 667 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+12
View File
@@ -0,0 +1,12 @@
<!-- This is the source of truth for all NEWS items. -->
<!-- The first 10 items are automatically synced to README.md and README_zh.md via GitHub Actions. -->
<!-- To update news in READMEs, modify this file and push to trigger the workflow. -->
- **[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/)
+12
View File
@@ -0,0 +1,12 @@
<!-- This is the source of truth for all NEWS items. -->
<!-- The first 10 items are automatically synced to README.md and README_zh.md via GitHub Actions. -->
<!-- To update news in READMEs, modify this file and push to trigger the workflow. -->
- **[2026-07] `集成`:** 集成 K8sOpenSandbox 工作区/沙箱实现。 [文档](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/)
+98
View File
@@ -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
+121
View File
@@ -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.
+78
View File
@@ -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.
<img src="https://gw.alicdn.com/imgextra/i2/O1CN01Phmg1G1brIVC8WXyU_!!6000000003518-2-tps-2938-1736.png" alt="Web UI Screenshot" width="100%">
## 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
<img src="https://gw.alicdn.com/imgextra/i1/O1CN01vGGiBw20agWwpzmjy_!!6000000006866-2-tps-2934-1732.png" alt="Permission System" width="100%">
- schedule tasks
<img src="https://gw.alicdn.com/imgextra/i1/O1CN01Xi3Qw71E2haKKu4z0_!!6000000000294-2-tps-2932-1738.png" alt="Schedule Tasks" width="100%">
- and more! (stay tuned for future updates)
+131
View File
@@ -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,
)
@@ -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
<workdir>/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.
@@ -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, "<unknown>")
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())
+387
View File
@@ -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`).
+325
View File
@@ -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, "<unknown>")
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())
+203
View File
@@ -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`.
+336
View File
@@ -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, "<unknown>")
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())
+139
View File
@@ -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.<key>` 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.
+183
View File
@@ -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 "<non-text chunk>"
)
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())
+198
View File
@@ -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())
+42
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
npx lint-staged
+38
View File
@@ -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
+10
View File
@@ -0,0 +1,10 @@
{
"useTabs": true,
"tabWidth": 4,
"singleQuote": true,
"semi": true,
"trailingComma": "all",
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "always"
}
+22
View File
@@ -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"
}
}
+16
View File
@@ -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}`);
});
+16
View File
@@ -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"]
}
+73
View File
@@ -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...
},
},
]);
```
+25
View File
@@ -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": {}
}
+53
View File
@@ -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',
},
},
]);
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/agentscope.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AgentScope</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+66
View File
@@ -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"
}
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="550" height="550" viewBox="0 0 550 550"><defs><linearGradient x1="0.01500389538705349" y1="0.4831196665763855" x2="0.9407116114637801" y2="0.3076102348892569" id="master_svg0_8_2390"><stop offset="0%" stop-color="#01C5FF" stop-opacity="1"/><stop offset="100%" stop-color="#019DFB" stop-opacity="1"/></linearGradient><linearGradient x1="0.21085502207279205" y1="0.38703426718711853" x2="0.9523109409029081" y2="0.390888421140005" id="master_svg1_8_1638"><stop offset="0%" stop-color="#4701EF" stop-opacity="1"/><stop offset="100%" stop-color="#395EEF" stop-opacity="1"/></linearGradient></defs><g><g></g><g><g><path d="M275.4998779296875,211.25Q275.4998779296875,279.5,373.4999779296875,310.5Q338.4998779296875,287.5,343.9998779296875,232Q344.4969779296875,227.19400000000002,345.9004779296875,222.498Q352.96577792968753,198.857,382.9998779296875,178L415.9998779296875,193.5L469.7738779296875,193.5C477.0958779296875,193.5,481.9218779296875,185.91559999999998,478.3148779296875,179.543Q441.5068779296875,114.5,372.9998779296875,114.5C343.9998779296875,114.5,275.4998779296875,143,275.4998779296875,211.25Z" fill="url(#master_svg0_8_2390)" fill-opacity="1"/></g><g><path d="M343.9999162890625,231.99999791015625Q337.9999162890625,287.5000079101562,373.5000462890625,310.5000079101562L433.4998462890625,333.00010791015626Q449.4994462890625,314.2500079101562,449.4994462890625,295.5000079101562Q449.4994462890625,276.7500079101562,433.4998462890625,258.0000079101562L345.9004862890625,222.49810791015625Q344.4970462890625,227.19412791015625,343.9999162890625,231.99999791015625Z" fill="#0064FC" fill-opacity="1"/></g><g><path d="M122.9998779296875,351.5C124.3900479296875,350.6567,125.7727179296875,349.8325,127.1479779296875,349.0269Q125.0392779296875,350.2098,122.9998779296875,351.5ZM127.1479779296875,349.0269Q150.3716779296875,336,181.9998779296875,336C186.2692779296875,335.7983,190.4211779296875,335.9808,194.4638779296875,336.502C250.5488779296875,343.7327,285.6218779296875,416.14300000000003,321.9998779296875,432Q350.1248779296875,444,377.9998779296875,444Q405.8748779296875,444,433.4998779296875,432Q489.9998779296875,400,489.9998779296875,344.5Q489.9998779296875,283,433.4998779296875,258Q449.4998779296875,276.75,449.4998779296875,295.5Q449.4998779296875,314.25,433.4998779296875,333C394.3168779296875,374.257,360.65987792968747,359.947,319.4498779296875,342.4251C286.9518779296875,328.6077,249.7568779296875,312.7935,201.4527779296875,320.6594C179.2413779296875,324.2763,154.6808779296875,332.9,127.1479779296875,349.0269Z" fill="url(#master_svg1_8_1638)" fill-opacity="1"/></g><g><path d="M61,437.99973876953123L133.8305,437.99973876953123C141.5661,437.99973876953123,148.608,433.53913876953123,151.9131,426.54513876953126L194.464,336.50202976953125C190.421,335.98083496953126,186.269,335.79829376953126,182,335.99999996953125Q147.4999,335.99999996953125,122.9999,351.5000387695313C93.5,373.5000387695313,87,387.0000387695313,61,437.99973876953123Z" fill="#0064FC" fill-opacity="1"/></g><g><path d="M61,438.00038301849366C87,387.00038301849366,93.5,373.50038301849366,122.9999,351.50038301849366C152.2215,333.77438301849367,178.132,324.45738301849366,201.453,320.65938301849366L256.597,207.04148301849364C259.081,201.92438301849364,259.26800000000003,195.99188301849364,257.11199999999997,190.72838301849367L227.948,119.52337301849366C225.653,113.91851301849366,217.805,113.67667301849366,215.169,119.12954301849365L61,438.00038301849366Z" fill="#01C8FF" fill-opacity="1"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

+84
View File
@@ -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 (
<>
<div className="h-screen">
<SetupPage onComplete={() => navigate('/')} />
</div>
<Toaster richColors position="top-right" />
</>
);
}
const router = createBrowserRouter([
{
element: <AppLayout />,
errorElement: <RouteError />,
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: <RouteError />,
children: [
{ path: '/', element: <Navigate to="/chat" replace /> },
{
path: '/chat/:agentId?/:sessionId?/:memberId?',
element: <ChatPage />,
},
{ path: '/schedule', element: <SchedulePage /> },
{ path: '/credential', element: <CredentialPage /> },
{ path: '/knowledge', element: <KnowledgePage /> },
{ path: '/knowledge/:kbId', element: <KnowledgePage /> },
],
},
],
},
{ path: '/setup', element: <SetupPageRoute />, errorElement: <RouteError /> },
]);
function App() {
const { t } = useTranslation();
const [setupComplete, setSetupComplete] = useState(() => !!localStorage.getItem('server_url'));
const tours = useMemo(() => [buildChatTour(t)], [t]);
if (!setupComplete) {
return <SetupPage onComplete={() => setSetupComplete(true)} />;
}
return (
<OnbordaProvider>
<Onborda
steps={tours}
cardComponent={TourCard}
shadowOpacity="0.6"
cardTransition={{ type: 'spring', duration: 0.4 }}
>
<UploadProvider>
<RouterProvider router={router} />
</UploadProvider>
<Toaster richColors position="top-right" />
</Onborda>
</OnbordaProvider>
);
}
export default App;
+23
View File
@@ -0,0 +1,23 @@
import { client } from './client';
import type {
AgentListResponse,
AgentView,
AgentSchemaV2Response,
CreateAgentRequest,
CreateAgentResponse,
UpdateAgentRequest,
} from './types';
export const agentApi = {
list: () => client.get<AgentListResponse>('/agent/'),
getSchema: () => client.get<AgentSchemaV2Response>('/agent/schema/v2'),
create: (body: CreateAgentRequest, options?: { silent?: boolean }) =>
client.post<CreateAgentResponse>('/agent/', body, undefined, options),
update: (agentId: string, body: UpdateAgentRequest, options?: { silent?: boolean }) =>
client.patch<AgentView>(`/agent/${agentId}`, body, undefined, options),
delete: (agentId: string) => client.delete(`/agent/${agentId}`),
};
+25
View File
@@ -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),
};
+119
View File
@@ -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<string, string>;
/** When true, suppresses the automatic error toast. Useful when the caller shows its own inline error UI. */
silent?: boolean;
}
function buildHeaders(hasBody: boolean): Record<string, string> {
const headers: Record<string, string> = { '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<string> {
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<T>(path: string, options: RequestOptions = {}): Promise<T> {
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<T>;
}
async function streamRequest(
path: string,
options: RequestOptions & { signal?: AbortSignal } = {},
): Promise<Response> {
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: <T>(path: string, params?: Record<string, string>) =>
request<T>(path, { method: 'GET', params }),
post: <T>(
path: string,
body?: unknown,
params?: Record<string, string>,
options?: { silent?: boolean },
) => request<T>(path, { method: 'POST', body, params, silent: options?.silent }),
patch: <T>(
path: string,
body?: unknown,
params?: Record<string, string>,
options?: { silent?: boolean },
) => request<T>(path, { method: 'PATCH', body, params, silent: options?.silent }),
delete: <T = void>(path: string, params?: Record<string, string>) =>
request<T>(path, { method: 'DELETE', params }),
stream: (path: string, options?: RequestOptions & { signal?: AbortSignal }) =>
streamRequest(path, options),
};
@@ -0,0 +1,23 @@
import { client } from './client';
import type {
CreateCredentialRequest,
CreateCredentialResponse,
CredentialListResponse,
CredentialView,
CredentialSchemasResponse,
UpdateCredentialRequest,
} from './types';
export const credentialApi = {
list: () => client.get<CredentialListResponse>('/credential/'),
schemas: () => client.get<CredentialSchemasResponse>('/credential/schemas'),
create: (body: CreateCredentialRequest) =>
client.post<CreateCredentialResponse>('/credential/', body),
update: (credentialId: string, body: UpdateCredentialRequest) =>
client.patch<CredentialView>(`/credential/${credentialId}`, body),
delete: (credentialId: string) => client.delete(`/credential/${credentialId}`),
};
@@ -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';
@@ -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<UploadKnowledgeDocumentResponse> {
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<ListKnowledgeBasesResponse>('/knowledge_bases/'),
listEmbeddingModels: () =>
client.get<ListKbEmbeddingModelsResponse>('/knowledge_bases/embedding_models'),
/** Fetch the JSON Schema describing the KB middleware's tunable params. */
middlewareParametersSchema: () =>
client.get<KbMiddlewareParametersSchemaResponse>(
'/knowledge_bases/middleware/parameters_schema',
),
/** List the union of media types + extensions every parser accepts. */
supportedContentTypes: () =>
client.get<ListSupportedContentTypesResponse>('/knowledge_bases/supported_content_types'),
create: (body: CreateKnowledgeBaseRequest) =>
client.post<CreateKnowledgeBaseResponse>('/knowledge_bases/', body),
update: (knowledgeBaseId: string, body: UpdateKnowledgeBaseRequest) =>
client.patch<KnowledgeBaseView>(`/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<ListKnowledgeDocumentsResponse>(`/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<ListKnowledgeDocumentStatusResponse>({
items: [],
});
}
return client.get<ListKnowledgeDocumentStatusResponse>(
`/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<SearchKnowledgeBaseResponse>(
`/knowledge_bases/${knowledgeBaseId}/search`,
body,
),
};
+10
View File
@@ -0,0 +1,10 @@
import { client } from './client';
import type { ListModelResponse, ListTTSModelResponse } from './types';
export const modelApi = {
list: (provider: string) => client.get<ListModelResponse>('/model/', { provider }),
};
export const ttsModelApi = {
list: (provider: string) => client.get<ListTTSModelResponse>('/tts-model/', { provider }),
};
@@ -0,0 +1,24 @@
import { client } from './client';
import type {
CreateScheduleRequest,
CreateScheduleResponse,
ScheduleListResponse,
ScheduleRecord,
ScheduleSessionsResponse,
UpdateScheduleRequest,
} from './types';
export const scheduleApi = {
list: () => client.get<ScheduleListResponse>('/schedule/'),
create: (body: CreateScheduleRequest) =>
client.post<CreateScheduleResponse>('/schedule/', body),
update: (scheduleId: string, body: UpdateScheduleRequest) =>
client.patch<ScheduleRecord>(`/schedule/${scheduleId}`, body),
delete: (scheduleId: string) => client.delete(`/schedule/${scheduleId}`),
listSessions: (scheduleId: string) =>
client.get<ScheduleSessionsResponse>(`/schedule/${scheduleId}/sessions`),
};
+104
View File
@@ -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<SessionListResponse>('/sessions/', { agent_id: agentId }),
create: (body: CreateSessionRequest) => client.post<CreateSessionResponse>('/sessions/', body),
update: (sessionId: string, agentId: string, body: UpdateSessionRequest) =>
client.patch<SessionRecord>(`/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<InterruptSessionResponse>(`/sessions/${sessionId}/interrupt`, null, {
agent_id: agentId,
}),
messages: (sessionId: string, agentId: string, offset = 0, limit = 50) =>
client.get<MessagesResponse>(`/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<AgentEvent> {
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();
}
},
};
+736
View File
@@ -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<string, unknown>;
}
export interface TTSModelConfig {
type: string;
credential_id: string;
model: string;
parameters: Record<string, unknown>;
}
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<string, unknown>;
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<string, JSONSchemaProperty>;
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<string, unknown>;
/**
* Whether the current viewer may PATCH/DELETE this credential.
* `false` for credentials shared with read-only permission.
*/
editable: boolean;
}
export interface CreateCredentialRequest {
data: Record<string, unknown>;
}
export interface CreateCredentialResponse {
credential_id: string;
}
export interface UpdateCredentialRequest {
data: Record<string, unknown>;
}
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<string, string> | null;
cwd?: string | null;
encoding_error_handler?: 'strict' | 'ignore' | 'replace';
}
export interface HttpMCPConfig {
type: 'http_mcp';
url: string;
headers?: Record<string, string> | 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<string, unknown>;
parameters_overrides: Record<string, Record<string, unknown>>;
}
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<string, unknown>;
}
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<string, unknown>;
parameter_overrides: Record<string, Record<string, unknown>>;
}
// ─── 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<string, unknown>;
}
/**
* 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<string, unknown>;
}
/** Response of `GET /knowledge_bases/middleware/parameters_schema`. */
export interface KbMiddlewareParametersSchemaResponse {
parameter_schema: Record<string, unknown>;
}
/** 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<string, unknown>;
parameters_overrides: Record<string, Record<string, unknown>>;
}
export interface ListTTSModelResponse {
models: TTSModelCard[];
total: number;
}
@@ -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<MCPClientStatus[]>('/workspace/mcp', {
agent_id: agentId,
session_id: sessionId,
}),
add: (agentId: string, sessionId: string, mcp: MCPClient) =>
client.post<void>('/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<Skill[]>('/workspace/skill', { agent_id: agentId, session_id: sessionId }),
add: (agentId: string, sessionId: string, body: AddSkillRequest) =>
client.post<void>('/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,
}),
},
};
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="550" height="550" viewBox="0 0 550 550"><defs><linearGradient x1="0.01500389538705349" y1="0.4831196665763855" x2="0.9407116114637801" y2="0.3076102348892569" id="master_svg0_8_2390"><stop offset="0%" stop-color="#01C5FF" stop-opacity="1"/><stop offset="100%" stop-color="#019DFB" stop-opacity="1"/></linearGradient><linearGradient x1="0.21085502207279205" y1="0.38703426718711853" x2="0.9523109409029081" y2="0.390888421140005" id="master_svg1_8_1638"><stop offset="0%" stop-color="#4701EF" stop-opacity="1"/><stop offset="100%" stop-color="#395EEF" stop-opacity="1"/></linearGradient></defs><g><g></g><g><g><path d="M275.4998779296875,211.25Q275.4998779296875,279.5,373.4999779296875,310.5Q338.4998779296875,287.5,343.9998779296875,232Q344.4969779296875,227.19400000000002,345.9004779296875,222.498Q352.96577792968753,198.857,382.9998779296875,178L415.9998779296875,193.5L469.7738779296875,193.5C477.0958779296875,193.5,481.9218779296875,185.91559999999998,478.3148779296875,179.543Q441.5068779296875,114.5,372.9998779296875,114.5C343.9998779296875,114.5,275.4998779296875,143,275.4998779296875,211.25Z" fill="url(#master_svg0_8_2390)" fill-opacity="1"/></g><g><path d="M343.9999162890625,231.99999791015625Q337.9999162890625,287.5000079101562,373.5000462890625,310.5000079101562L433.4998462890625,333.00010791015626Q449.4994462890625,314.2500079101562,449.4994462890625,295.5000079101562Q449.4994462890625,276.7500079101562,433.4998462890625,258.0000079101562L345.9004862890625,222.49810791015625Q344.4970462890625,227.19412791015625,343.9999162890625,231.99999791015625Z" fill="#0064FC" fill-opacity="1"/></g><g><path d="M122.9998779296875,351.5C124.3900479296875,350.6567,125.7727179296875,349.8325,127.1479779296875,349.0269Q125.0392779296875,350.2098,122.9998779296875,351.5ZM127.1479779296875,349.0269Q150.3716779296875,336,181.9998779296875,336C186.2692779296875,335.7983,190.4211779296875,335.9808,194.4638779296875,336.502C250.5488779296875,343.7327,285.6218779296875,416.14300000000003,321.9998779296875,432Q350.1248779296875,444,377.9998779296875,444Q405.8748779296875,444,433.4998779296875,432Q489.9998779296875,400,489.9998779296875,344.5Q489.9998779296875,283,433.4998779296875,258Q449.4998779296875,276.75,449.4998779296875,295.5Q449.4998779296875,314.25,433.4998779296875,333C394.3168779296875,374.257,360.65987792968747,359.947,319.4498779296875,342.4251C286.9518779296875,328.6077,249.7568779296875,312.7935,201.4527779296875,320.6594C179.2413779296875,324.2763,154.6808779296875,332.9,127.1479779296875,349.0269Z" fill="url(#master_svg1_8_1638)" fill-opacity="1"/></g><g><path d="M61,437.99973876953123L133.8305,437.99973876953123C141.5661,437.99973876953123,148.608,433.53913876953123,151.9131,426.54513876953126L194.464,336.50202976953125C190.421,335.98083496953126,186.269,335.79829376953126,182,335.99999996953125Q147.4999,335.99999996953125,122.9999,351.5000387695313C93.5,373.5000387695313,87,387.0000387695313,61,437.99973876953123Z" fill="#0064FC" fill-opacity="1"/></g><g><path d="M61,438.00038301849366C87,387.00038301849366,93.5,373.50038301849366,122.9999,351.50038301849366C152.2215,333.77438301849367,178.132,324.45738301849366,201.453,320.65938301849366L256.597,207.04148301849364C259.081,201.92438301849364,259.26800000000003,195.99188301849364,257.11199999999997,190.72838301849367L227.948,119.52337301849366C225.653,113.91851301849366,217.805,113.67667301849366,215.169,119.12954301849365L61,438.00038301849366Z" fill="#01C8FF" fill-opacity="1"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -0,0 +1,3 @@
<svg viewBox="0 0 12 16" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="gray">
<path d="M2 0 L2 14 L8 14" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 191 B

@@ -0,0 +1,3 @@
<svg width="12" height="16" viewBox="0 0 12 16" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="gray">
<path d="M2 0 L2 16" stroke-width="0.7" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 184 B

@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 11.5L11.18 2.32C12.5 1 14.62 1 15.94 2.32C17.26 3.64 17.26 5.76 15.94 7.08L8.88 14.14" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.97 14.04L15.94 7.08C17.26 5.76 19.38 5.76 20.7 7.08L20.74 7.12C22.06 8.44 22.06 10.56 20.7 11.88L13.03 19.55C12.6 19.98 12.6 20.67 13.03 21.1L14.78 22.85" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.56 4.7L6.62 11.64C5.3 12.96 5.3 15.08 6.62 16.4C7.94 17.72 10.06 17.72 11.38 16.4L18.34 9.44" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 747 B

@@ -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 (
<TooltipProvider>
<div className={cn('flex flex-row gap-x-1', className)}>
{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 (
<Tooltip key={mainType}>
<TooltipTrigger asChild>
<Icon
size={18}
className={supported ? 'stroke-primary' : 'opacity-30'}
/>
</TooltipTrigger>
<TooltipContent>
<span>{tooltipText}</span>
</TooltipContent>
</Tooltip>
);
})}
</div>
</TooltipProvider>
);
}
@@ -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 (
<Badge variant="secondary" className={className}>
<Spinner data-icon="inline-start" />
{t('common.running')}
</Badge>
);
case 'completed':
return (
<Badge
className={cn(
'bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300',
className,
)}
>
<CheckCircle className="size-5" />
{t('common.completed')}
</Badge>
);
case 'failed':
return (
<Badge
className={cn(
'bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300 border-none',
)}
>
<XCircle className="size-5" />
{t('common.failed')}
</Badge>
);
}
}
@@ -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<ContentBlock | null>;
}
const ChatContentComponent: React.FC<ChatContentProps> = ({
msgs,
phase,
disabled,
onSend,
onUserConfirm,
autoComplete,
className,
onInterrupt,
footerSlot,
allowedInputTypes,
fileProcessor,
}) => {
const scrollAreaRef = useRef<HTMLDivElement>(null);
const prevMsgCountRef = useRef<number>(0);
const wasNearBottomRef = useRef<boolean>(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 (
<div className={cn('flex flex-col h-full w-full items-center p-2 gap-4', className)}>
<div
ref={scrollAreaRef}
className="flex-1 w-full max-w-full overflow-auto no-scrollbar overflow-x-hidden"
>
<div className="flex flex-col gap-4 size-full max-w-full">
{msgs.length > 0 ? (
msgs.map((message) => (
<MessageBubble
key={message.id}
message={message}
onUserConfirm={onUserConfirm}
/>
))
) : (
<EmptyMessage />
)}
</div>
</div>
{footerSlot ? <div className="w-full max-w-full shrink-0">{footerSlot}</div> : null}
<TextInput
className="min-w-full max-w-full w-full"
onSend={onSend}
disabled={disabled}
autoComplete={autoComplete}
allowedInputTypes={allowedInputTypes}
fileProcessor={fileProcessor}
phase={phase}
onInterrupt={onInterrupt}
/>
</div>
);
};
export const ChatContent = React.memo(ChatContentComponent);
@@ -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<SelectOption>('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 (
<div className="ring ring-border rounded-xl w-full p-4 space-y-4 text-sm overflow-hidden">
<div className="flex flex-col gap-y-2">
<strong className="text-secondary-foreground">{getDisplayName(toolCall, t)}</strong>
<div className="px-4 py-2 bg-white rounded-sm">
{renderConfirmBody(toolCall, t)}
</div>
</div>
<div className="flex flex-col">
<strong className="text-secondary-foreground mb-1">
{t('chat.confirmToolCall')}
</strong>
<Button
className={cn(
'flex justify-start cursor-pointer',
selected === 'yes' ? 'text-primary' : 'text-muted-foreground',
)}
size="sm"
variant="ghost"
onMouseEnter={() => setSelected('yes')}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onUserConfirm(true);
}}
>
<ChevronRight
className={cn('size-4', selected === 'yes' ? 'visible' : 'invisible')}
/>
1. {t('common.yes')}
<div className={cn(selected === 'yes' ? 'text-muted-foreground' : 'invisible')}>
(<Kbd>Enter</Kbd> {t('confirmCard.toConfirm')})
</div>
</Button>
{hasSuggestedRules && (
<Button
className={cn(
'flex flex-wrap justify-start items-start cursor-pointer h-auto text-left',
selected === 'yes_with_rule' ? 'text-primary' : 'text-muted-foreground',
)}
size="sm"
variant="ghost"
onMouseEnter={() => setSelected('yes_with_rule')}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onUserConfirm(true, [toolCall.suggested_rules![0]]);
}}
>
<span className="flex items-start gap-1 w-full break-words whitespace-normal min-w-0">
<ChevronRight
className={cn(
'size-4 shrink-0 mt-0.5',
selected === 'yes_with_rule' ? 'visible' : 'invisible',
)}
/>
<span className="break-words min-w-0">
2.{' '}
{t('confirmCard.yesWithRule', {
toolName: toolCall.suggested_rules![0].tool_name,
ruleContent: toolCall.suggested_rules![0].rule_content,
})}
{selected === 'yes_with_rule' && (
<span className="text-muted-foreground ml-1 whitespace-nowrap">
(<Kbd>Enter</Kbd> {t('confirmCard.toConfirm')})
</span>
)}
</span>
</span>
</Button>
)}
<Button
className={cn(
'flex justify-start cursor-pointer',
selected === 'no' ? 'text-primary' : 'text-muted-foreground',
)}
size="sm"
variant="ghost"
onMouseEnter={() => setSelected('no')}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onUserConfirm(false);
}}
>
<ChevronRight
className={cn('size-4', selected === 'no' ? 'visible' : 'invisible')}
/>
{hasSuggestedRules ? '3' : '2'}. {t('common.no')}
<div className={cn(selected === 'no' ? 'text-muted-foreground' : 'invisible')}>
(<Kbd>Enter</Kbd> {t('confirmCard.toConfirm')})
</div>
</Button>
</div>
</div>
);
}
@@ -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 (
<Empty className="size-full">
<EmptyHeader>
<EmptyMedia variant="icon">
<MessagesSquare />
</EmptyMedia>
<EmptyTitle>{t('chat.noMessages')}</EmptyTitle>
<EmptyDescription>{t('chat.noMessagesDesc')}</EmptyDescription>
</EmptyHeader>
</Empty>
);
}
@@ -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 <FileImage className={className} />;
case 'audio':
return <FileAudio className={className} />;
case 'video':
return <FileVideo className={className} />;
case 'text':
return <FileText className={className} />;
default:
return mediaType === 'application/pdf' ? (
<FileText className={className} />
) : (
<File className={className} />
);
}
}
/**
* 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 (
<a
{...linkProps}
className="group flex w-fit max-w-xs items-center gap-3 rounded-lg border bg-muted/40 px-3 py-2 no-underline transition-colors hover:bg-muted"
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground">
<FileIcon mediaType={mediaType} className="size-4" />
</span>
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium text-foreground">{displayName}</span>
{ext && <span className="text-xs text-muted-foreground">{ext}</span>}
</span>
<Download className="size-4 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
</a>
);
}
@@ -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<string, ToolCallWithResult>();
const resultMap = new Map<string, ContentBlock>();
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 && (
<style>{`
@keyframes audioWave {
0%, 100% { transform: scaleY(1); }
50% { transform: scaleY(0.3); }
}
`}</style>
)}
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
{AUDIO_WAVE_LINES.map(({ x, y1, y2 }, i) => (
<line
key={x}
x1={x}
x2={x}
y1={y1}
y2={y2}
style={{
transformOrigin: `${x}px 12px`,
animation: isPlaying
? `audioWave 0.8s ease-in-out ${i * 0.12}s infinite`
: 'none',
}}
/>
))}
</svg>
</>
);
}
/**
* 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<HTMLAudioElement | null>(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 <audio> when the source URL changes (e.g. streaming
// just transitioned to a Blob URL). Without an explicit load() some
// browsers keep the previous (or empty) source bound to the element.
useEffect(() => {
const el = audioRef.current;
if (!el || !src) return;
setIsPlaying(false);
el.load();
}, [src]);
// Pause when a newer reply interrupts this block's playback.
const interruptCount = audioState?.interruptCount ?? 0;
useEffect(() => {
if (interruptCount === 0) return;
const el = audioRef.current;
if (el && !el.paused) {
el.pause();
}
}, [interruptCount]);
if (isStreaming) {
return <AudioWave isPlaying className="ml-1" />;
}
if (!src) return null;
const toggle = async () => {
const el = audioRef.current;
if (!el) return;
if (el.paused) {
replayController?.play(el);
try {
await el.play();
} catch (err) {
console.error('Audio playback failed', err);
}
} else {
el.pause();
replayController?.stop();
}
};
return (
<>
<button
type="button"
onClick={toggle}
aria-label={
isPlaying ? t('messageBubble.pauseAudio') : t('messageBubble.playAudio')
}
className="ml-1 inline-flex cursor-pointer items-center transition-opacity hover:opacity-70"
>
{isPlaying ? (
<AudioWave isPlaying className="size-3" />
) : (
<CirclePlay className="size-3" />
)}
</button>
<audio
ref={audioRef}
src={src}
preload="auto"
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onEnded={() => setIsPlaying(false)}
/>
</>
);
}
/**
* Render a single content block. Tool call groups are dispatched to
* `renderToolGroup`; the per-group truncation at the first `asking` call
* (and the trailing ConfirmCard) lives here so renderers only see a clean
* list of calls.
*/
function renderBlock(
block: ExtendedContentBlock,
index: number,
t: TFunction,
onUserConfirm?: (
toolCallBlock: ToolCallBlock,
confirm: boolean,
rules?: ToolCallBlock['suggested_rules'],
) => void,
) {
switch (block.type) {
case 'tool_call_group': {
const firstAsk = block.calls.findIndex((item) => item.call.state === 'asking');
const visible = firstAsk === -1 ? block.calls : block.calls.slice(0, firstAsk + 1);
const askingCall = firstAsk === -1 ? null : block.calls[firstAsk].call;
return (
<div key={index} className="flex flex-col gap-y-4 text-muted-foreground">
{renderToolGroup(block.toolName, visible, t)}
{askingCall && (
<ConfirmCard
toolCall={askingCall}
onUserConfirm={(confirm, rules) => {
if (onUserConfirm) onUserConfirm(askingCall, confirm, rules);
}}
/>
)}
</div>
);
}
case 'text':
return (
<div key={index} className="prose w-full min-w-full">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code: ({ className, children, ...props }) => {
const isInline = !String(className ?? '').startsWith('language-');
if (isInline) {
return (
<code className={`${className ?? ''} break-all`} {...props}>
{children}
</code>
);
}
return (
<div className="relative w-full">
<Button
size="icon-xs"
variant="ghost"
className="absolute top-0 right-0 z-10"
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
await navigator.clipboard.writeText(
String(children),
);
}}
>
<Copy />
</Button>
<div className="overflow-x-auto max-w-full w-full">
<code className={className} {...props}>
{children}
</code>
</div>
</div>
);
},
}}
>
{block.text}
</ReactMarkdown>
</div>
);
case 'thinking':
return (
<details key={index} className="text-muted-foreground">
<summary className="cursor-pointer select-none">
{t('messageBubble.thinking')}
</summary>
<p className="mt-1 whitespace-pre-wrap">{block.thinking}</p>
</details>
);
case 'data': {
const dataType = block.source.media_type.split('/')[0];
// Audio data blocks render in the footer (see AudioFooterControl),
// not inline alongside text.
if (dataType === 'audio') return null;
const data =
block.source.type === 'url'
? block.source.url
: `data:${block.source.media_type};base64,${block.source.data}`;
switch (dataType) {
case 'image':
return (
<img
key={index}
src={data}
alt={block.name || 'Uploaded image'}
className="max-h-80 max-w-full rounded-lg object-contain"
/>
);
case 'video':
return (
<video
key={index}
controls
src={data}
className="max-h-80 max-w-full rounded-lg"
/>
);
default:
return (
<FileAttachment
key={index}
name={block.name}
href={data}
mediaType={block.source.media_type}
/>
);
}
}
case 'hint': {
// Parse source: try JSON, fall back to plain string, default to t('common.message').
let hintLabel: string;
let hintSublabel: string | null = null;
let HintIcon = MessageSquareQuote;
if (block.source) {
try {
const parsed = JSON.parse(block.source) as {
label?: string;
sublabel?: string;
};
hintLabel = parsed.label
? t(`messageBubble.hintSource.${parsed.label}`)
: block.source;
hintSublabel = parsed.sublabel ?? null;
if (parsed.label === 'team_message') HintIcon = Bot;
else if (parsed.label === 'schedule') HintIcon = CalendarClock;
else if (parsed.label === 'tool_output') HintIcon = Wrench;
} catch {
hintLabel = block.source;
}
} else {
hintLabel = t('common.message');
}
const items: (TextBlock | DataBlock)[] =
typeof block.hint === 'string'
? [{ type: 'text', id: `${block.id}-text`, text: block.hint }]
: block.hint;
return (
<Item variant={'outline'} className="max-w-full">
<ItemContent className="max-w-full">
<Collapsible>
<CollapsibleTrigger asChild>
<Button className="group w-full max-w-full" variant="ghost">
<HintIcon className="size-3.5" />
<span className="tracking-tight">{hintLabel}</span>
{hintSublabel && (
<span className="text-muted-foreground font-normal truncate max-w-[200px]">
{hintSublabel}
</span>
)}
<ChevronDownIcon className="ml-auto group-data-[state=open]:rotate-180" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="p-2.5 pt-0 max-w-full overflow-hidden break-all text-muted-foreground">
{items.map((inner, i) => renderBlock(inner, i, t))}
</CollapsibleContent>
</Collapsible>
</ItemContent>
</Item>
);
}
default:
return null;
}
}
interface MessageBubbleProps {
message: Msg;
onUserConfirm: (
toolCallBlock: ToolCallBlock,
confirm: boolean,
replyId: string,
rules?: ToolCallBlock['suggested_rules'],
) => void;
}
/**
* A message bubble component that displays a chat message.
*
* Running state is derived from `message.finished_at`: a missing or null
* `finished_at` means the agent is still producing this reply. The bottom
* status row shows a single left-aligned badge laid out as
* `[state-icon] [duration] [↑in ↓out]`:
* - State icon: spinning `Loader2` while running, static `CheckCircle`
* once finished.
* - Duration is `now - created_at` while running (ticking each second),
* `finished_at - created_at` once complete.
* - Token counts only appear once `usage` is populated with non-zero
* values — typically after the message finishes.
*
* When `content` is empty and the message is still running, the bubble
* body is omitted entirely so only the bottom status row renders.
*/
export function MessageBubble({ message, onUserConfirm }: MessageBubbleProps) {
const isUser = message.role === 'user';
const { t } = useTranslation();
const isRunning = !message.finished_at;
const hasUsage =
!!message.usage &&
((message.usage.input_tokens ?? 0) > 0 || (message.usage.output_tokens ?? 0) > 0);
// Tick once per second while running so the elapsed time updates live.
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!isRunning) return;
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, [isRunning]);
const blocks = groupToolCalls(message.content);
const audioBlocks = message.content.filter(
(b): b is DataBlock => b.type === 'data' && b.source.media_type.split('/')[0] === 'audio',
);
// Audio data blocks are rendered in the footer, so they shouldn't keep an
// otherwise-empty body bubble alive.
const hasBodyContent = blocks.some(
(b) => !(b.type === 'data' && b.source.media_type.split('/')[0] === 'audio'),
);
const showBody = hasBodyContent;
const showFooter = !isUser;
const startMs = new Date(message.created_at).getTime();
const endMs = isRunning ? now : new Date(message.finished_at!).getTime();
const elapsedSeconds = Math.max(0, (endMs - startMs) / 1000);
const elapsedText = formatTime(elapsedSeconds);
return (
<div
className={`flex flex-col w-full max-w-full ${isUser ? 'items-end' : 'items-start'} mb-4`}
>
{showBody && (
<div
className={`p-4 rounded-xl space-y-2 max-w-full ${
isUser ? 'w-fit bg-secondary' : 'w-full min-w-full'
}`}
>
{blocks.map((block, i) =>
renderBlock(
block,
i,
t,
(
toolCall: ToolCallBlock,
confirm: boolean,
rules?: ToolCallBlock['suggested_rules'],
) => {
onUserConfirm(toolCall, confirm, message.id, rules);
toolCall.state = confirm ? 'allowed' : 'finished';
},
),
)}
</div>
)}
{showFooter && (
<div className="flex flex-row items-center text-muted-foreground gap-x-4 px-2 w-full">
<Badge
variant="secondary"
aria-label={isRunning ? t('messageBubble.running') : undefined}
>
{isRunning ? (
<Loader2 data-icon="inline-start" className="animate-spin" />
) : (
<CheckCircle data-icon="inline-start" />
)}
<span className="tabular-nums tracking-tighter">{elapsedText}</span>
{hasUsage && (
<>
<ArrowUp data-icon="inline-start" className="ml-1" />
<span className="tabular-nums">
{formatNumber(message.usage?.input_tokens ?? 0)}
</span>
<ArrowDown data-icon="inline-start" className="ml-1" />
<span className="tabular-nums">
{formatNumber(message.usage?.output_tokens ?? 0)}
</span>
</>
)}
{audioBlocks.map((block) => (
<AudioInlineControl key={block.id} block={block} />
))}
</Badge>
</div>
)}
</div>
);
}
@@ -0,0 +1,56 @@
import type { ToolCallBlock } from '@agentscope-ai/agentscope/message';
import { Users } from 'lucide-react';
import { ConfirmCard } from './ConfirmCard';
import type { SubagentHitlEntry } from '@/hooks/useMessages';
import { useTranslation } from '@/i18n/useI18n';
/**
* A pending HITL confirmation raised by a team *member* (worker)
* session, surfaced on the *leader* view so the user can answer it
* without drilling into the member's own session.
*
* Renders one {@link ConfirmCard} per pending tool call, reusing the
* exact same confirm UI (and suggested-rules interaction) as a
* first-party confirmation. The only addition is a header naming the
* member that is asking.
*
* Confirmation is routed through {@link onConfirm}, which the parent
* wires to ``useMessages``' ``onSubagentConfirm`` — POSTing the result
* to the leader front door, where the backend forwards it to the
* worker session (design §3.6).
*/
export function SubagentHitlCard({
entry,
onConfirm,
}: {
entry: SubagentHitlEntry;
onConfirm: (
toolCall: ToolCallBlock,
confirm: boolean,
rules?: ToolCallBlock['suggested_rules'],
) => void;
}) {
const { t } = useTranslation();
const toolCalls = entry.event.tool_calls ?? [];
if (toolCalls.length === 0) return null;
return (
<div className="ring ring-border rounded-xl w-full p-3 space-y-3 bg-secondary/30">
<div className="flex items-center gap-2 text-sm font-medium text-secondary-foreground">
<Users className="size-4 shrink-0" />
<span>{t('chat.subagentConfirmTitle', { name: entry.worker_agent_name })}</span>
</div>
<div className="space-y-2">
{toolCalls.map((toolCall) => (
<ConfirmCard
key={toolCall.id}
toolCall={toolCall}
onUserConfirm={(confirm, rules) => onConfirm(toolCall, confirm, rules)}
/>
))}
</div>
</div>
);
}
@@ -0,0 +1,412 @@
import type { ContentBlock, TextBlock } from '@agentscope-ai/agentscope/message';
import { Paperclip, Send, Loader2, Square, X, type LucideIcon } from 'lucide-react';
import React, {
useState,
useRef,
useMemo,
type KeyboardEvent,
useImperativeHandle,
forwardRef,
} from 'react';
import { Button } from '../ui/button';
import { Kbd } from '../ui/kbd';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import type { ReplyPhase } from '@/hooks/useMessages';
import { useTranslation } from '@/i18n/useI18n.ts';
import { cn } from '@/lib/utils';
import { isMac } from '@/utils/platform';
/**
* Represents a file that has been selected and processed (or is being processed).
*/
interface ProcessedFile {
/** Original file name for display */
name: string;
/** Processing status */
status: 'processing' | 'done';
/** The resulting ContentBlock after processing (available when status === 'done') */
block: ContentBlock | null;
}
interface TextInputProps {
onSend: (blocks: ContentBlock[]) => void;
placeholder?: string;
autoComplete?: (input: string) => string | null;
disabled?: boolean;
className?: string;
/**
* Controls which file types the file picker accepts.
* Uses standard MIME types and file extensions, e.g.:
* - Images: "image/*" or "image/jpeg", "image/png"
* - Audio: "audio/*" or "audio/mpeg", "audio/wav"
* - Video: "video/*"
* - Plain text:"text/plain"
* - PDF: "application/pdf"
* - Word: ".doc,.docx,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
* - Excel: ".xls,.xlsx,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
*
* When undefined → no restriction (all files allowed).
* When empty array [] → attachment button is disabled (model accepts no files).
*/
allowedInputTypes?: string[];
/**
* Called immediately when a file is selected (at attach time, NOT at send time).
* Should resolve to a ContentBlock to include in the message, or null to skip the file.
* Runs concurrently for all selected files; the UI shows a loading state per file while processing.
*/
fileProcessor: (file: File) => Promise<ContentBlock | null>;
/**
* The current reply lifecycle phase from ``useMessages``. Drives the
* send / stop button in one shot:
* - ``idle`` — Send (enabled when there is content to send)
* - ``streaming`` — Stop (click to interrupt)
* - ``interrupting`` — Stop (disabled while the interrupt is in flight)
*/
phase?: ReplyPhase;
onInterrupt?: () => void;
}
export interface TextInputRef {
focus: () => void;
}
/**
* A text input component with file attachment support and autocomplete functionality.
*
* @param root0 - The component props.
* @param root0.onSend - Callback function to handle sending content blocks.
* @param root0.placeholder - Placeholder text for the input field.
* @param root0.autoComplete - Function to provide autocomplete suggestions.
* @param root0.disabled - Whether the input is disabled.
* @param root0.className - Additional CSS classes for styling.
* @returns A TextInput component.
*/
export const TextInput = forwardRef<TextInputRef, TextInputProps>(
(
{
onSend,
placeholder,
autoComplete,
disabled = false,
className,
allowedInputTypes,
fileProcessor,
phase = 'idle',
onInterrupt,
},
ref,
) => {
const { t } = useTranslation();
const defaultPlaceholder = placeholder || t('chat.inputPlaceholder');
const [value, setValue] = useState('');
const [files, setFiles] = useState<ProcessedFile[]>([]);
const [isFocused, setIsFocused] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const measureRef = useRef<HTMLSpanElement>(null);
// Derive the accept attribute for the hidden file input
const acceptAttr =
allowedInputTypes && allowedInputTypes.length > 0
? allowedInputTypes.join(',')
: undefined;
// Attachment button is disabled when the model explicitly accepts no file types
const attachDisabled =
disabled || (allowedInputTypes !== undefined && allowedInputTypes.length === 0);
// Whether any file is still being processed (block send until all done)
const hasProcessing = files.some((f) => f.status === 'processing');
useImperativeHandle(ref, () => ({
focus: () => textareaRef.current?.focus(),
}));
// Calculate autocomplete suggestion using useMemo
const suggestion = useMemo(() => {
if (autoComplete && value && isFocused) {
const result = autoComplete(value);
// Only return the part after the cursor
if (result && result.startsWith(value)) {
return result.substring(value.length);
}
return result || '';
}
return '';
}, [value, autoComplete, isFocused]);
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
// Tab key to select autocomplete
if (e.key === 'Tab' && suggestion) {
e.preventDefault();
setValue(value + suggestion);
return;
}
// Enter to send message, Shift+Enter for new line
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
handleSend();
}
};
const handleSend = () => {
if (!value.trim() || disabled || hasProcessing) return;
const blocks: ContentBlock[] = [];
// Add text block
if (value.trim()) {
const textBlock: TextBlock = {
id: crypto.randomUUID(),
type: 'text',
text: value.trim(),
};
blocks.push(textBlock);
}
// Add processed file blocks (skip errored ones)
files.forEach((f) => {
if (f.status === 'done' && f.block) {
blocks.push(f.block);
}
});
onSend?.(blocks);
setValue('');
setFiles([]);
};
/**
* Send / stop button configuration derived from the current reply
* phase. One struct = one branch of rendering, so the JSX stays flat.
*/
const sendButton: {
icon: LucideIcon;
tooltip: string;
disabled: boolean;
onClick: (() => void) | undefined;
} = (() => {
if (phase === 'streaming') {
return {
icon: Square,
tooltip: t('textInput.stop'),
disabled: false,
onClick: onInterrupt,
};
}
if (phase === 'interrupting') {
return {
icon: Square,
tooltip: t('textInput.stopping'),
disabled: true,
onClick: onInterrupt,
};
}
return {
icon: Send,
tooltip: t('textInput.send'),
disabled: disabled || !value.trim() || hasProcessing,
onClick: handleSend,
};
})();
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files) return;
const selected = Array.from(e.target.files);
// Reset input value so the same file can be re-selected
e.target.value = '';
selected.forEach((file) => {
// Insert a placeholder in processing state
const placeholder: ProcessedFile = {
name: file.name,
status: 'processing',
block: null,
};
setFiles((prev) => [...prev, placeholder]);
fileProcessor(file)
.then((block) => {
setFiles(
(prev) =>
prev
.map((f) =>
f.name === file.name && f.status === 'processing'
? block
? { ...f, status: 'done', block }
: null
: f,
)
.filter(Boolean) as ProcessedFile[],
);
})
.catch(() => {
// Caller is responsible for error notification (e.g. toast).
// Just silently remove the entry here.
setFiles((prev) =>
prev.filter(
(f) => !(f.name === file.name && f.status === 'processing'),
),
);
});
});
};
return (
<div
id="tour-chat-input"
className={cn(
'flex flex-col gap-2 rounded-2xl border bg-background p-3',
className,
)}
data-tour="chat-input"
>
{/* File list */}
{files.length > 0 && (
<div className="flex flex-wrap gap-2">
{files.map((file, index) => (
<div
key={index}
className="flex items-center gap-1 rounded bg-muted px-2 py-1 text-sm"
>
{file.status === 'processing' && (
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-muted-foreground" />
)}
<span className="max-w-[200px] truncate">{file.name}</span>
<button
onClick={() => setFiles(files.filter((_, i) => i !== index))}
className="text-muted-foreground hover:text-foreground"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
{/* Input area */}
<div className="relative">
<div className="relative">
{/* Hidden measurement element */}
<span
ref={measureRef}
className="invisible absolute whitespace-pre text-sm"
style={{
font: 'inherit',
padding: '0.5rem 0.75rem',
lineHeight: '1.5em',
}}
>
{value}
</span>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder={defaultPlaceholder}
disabled={disabled}
rows={1}
className="w-full resize-none rounded-md border-0 bg-transparent px-3 py-2 text-sm outline-none placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
style={{
maxHeight: 'calc(1.5em * 6)',
lineHeight: '1.5em',
overflowY: 'auto',
}}
autoFocus={true}
/>
{/* Autocomplete suggestion - using absolute positioning overlay */}
{suggestion && isFocused && (
<div
className="pointer-events-none absolute left-0 top-0 px-3 py-2 text-sm"
style={{
lineHeight: '1.5em',
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
}}
>
{/* Invisible input text */}
<span className="invisible">{value}</span>
{/* Suggestion text */}
<span className="text-muted-foreground">{suggestion}</span>
{/* Tab hint */}
<span className="ml-2 text-xs text-muted-foreground/60">
<Kbd>Tab</Kbd> {t('textInput.toComplete')}
</span>
</div>
)}
</div>
{/* Button row */}
<div className="mt-2 flex items-center justify-between">
<div>
<span
className={`text-muted-foreground text-sm ${!isFocused && 'hidden'}`}
>
<Kbd>{isMac() ? '⇧' : 'Shift'}</Kbd> + <Kbd>Enter</Kbd>{' '}
{t('textInput.newLine')}
</span>
</div>
<div className="flex gap-2">
{/* Attachment button */}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => fileInputRef.current?.click()}
disabled={attachDisabled}
className="shrink-0 rounded-full"
>
<Paperclip className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{attachDisabled && allowedInputTypes?.length === 0
? t('textInput.attachNotSupported')
: t('textInput.attach')}
</TooltipContent>
</Tooltip>
{/* Send / Stop button — driven by ``sendButton`` config */}
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
onClick={sendButton.onClick}
disabled={sendButton.disabled}
size="icon"
className="shrink-0 rounded-full"
>
<sendButton.icon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{sendButton.tooltip}</TooltipContent>
</Tooltip>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
multiple
accept={acceptAttr}
onChange={handleFileSelect}
className="hidden"
/>
</div>
</div>
</div>
</div>
);
},
);
TextInput.displayName = 'TextInput';
@@ -0,0 +1,58 @@
import {
defaultGetDisplayName,
defaultRenderCallArgs,
defaultRenderGroup,
defaultRenderResult,
} from './DefaultRenderer';
import type { ToolRenderer } from './types';
function parseInput(input: string): Record<string, unknown> {
try {
return JSON.parse(input);
} catch {
return {};
}
}
export const BashRenderer: ToolRenderer = {
getDisplayName: () => 'Bash',
renderCallArgs: (call) => {
const { command } = parseInput(call.input) as { command?: string };
return command || call.input;
},
renderResult: (call, result, t) => {
if (call.state === 'asking' || !result || result.state === 'running') {
return t('common.running');
}
if (result.state === 'interrupted') {
return t('common.interrupted');
}
return undefined;
},
renderConfirmBody: (call) => {
const { command, description } = parseInput(call.input) as {
command?: string;
description?: string;
};
return (
<div className="w-full max-w-full overflow-hidden text-ellipsis truncate">
<div className="text-secondary-foreground font-mono">{command}</div>
{description && <div className="text-muted-foreground">{description}</div>}
</div>
);
},
renderGroup: (calls, t) =>
defaultRenderGroup(calls, t, {
getDisplayName: (call) =>
BashRenderer.getDisplayName?.(call, t) ?? defaultGetDisplayName(call),
renderCallArgs: (call) =>
BashRenderer.renderCallArgs?.(call, t) ?? defaultRenderCallArgs(call),
renderResult: (call, result) =>
BashRenderer.renderResult?.(call, result, t) ??
defaultRenderResult(call, result, t),
}),
};
@@ -0,0 +1,136 @@
import type { ToolCallBlock, ToolResultBlock } from '@agentscope-ai/agentscope/message';
import { ChevronRight } from 'lucide-react';
import * as mime from 'mime-types';
import type { ReactNode } from 'react';
import { CornerLine, ToolStateIcon } from './_shared';
import type { TFunction, ToolCallWithResult } from './types';
import { Button } from '@/components/ui/button.tsx';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible.tsx';
function processToolInput(input: string): string {
try {
const obj = JSON.parse(input);
const entries = Object.entries(obj).map(([k, v]) => `${k}: "${v}"`);
return entries.join('\n');
} catch {
return input;
}
}
export function defaultGetDisplayName(call: ToolCallBlock): string {
return call.name;
}
export function defaultRenderCallArgs(call: ToolCallBlock): ReactNode {
if (call.input.length <= 2) return null;
return processToolInput(call.input);
}
export function defaultRenderResult(
call: ToolCallBlock,
result: ToolResultBlock,
t: TFunction,
maxLines = 7,
): ReactNode {
if (call.state === 'asking' || !result || result.state === 'running') {
return <span>{t('common.running')} ...</span>;
}
if (result.state === 'interrupted') {
return <span>{t('common.interrupted')}</span>;
}
let resultStr: string;
if (typeof result.output === 'string') {
resultStr = result.output;
} else {
const parts = result.output.map((b) => {
if (b.type === 'text') return b.text;
const mainType = b.source.media_type.split('/')[0].toUpperCase();
const ext = (mime.extension(b.source.media_type) || 'bin').toLowerCase();
return `[${mainType}.${ext}]`;
});
resultStr = parts.join('\n');
}
let lines = resultStr.split('\n');
if (lines.length > maxLines) {
const total = lines.length;
lines = lines.slice(0, maxLines);
lines.push(t('tool.moreLines', { count: total - maxLines }));
}
return (
<div className="flex flex-col flex-1 min-w-0">
{lines.map((line, i) => (
<div key={i} className="truncate">
{line}
</div>
))}
</div>
);
}
export function defaultRenderConfirmBody(call: ToolCallBlock): ReactNode {
return (
<div className="w-full max-w-full overflow-hidden text-ellipsis truncate">
<div className="text-secondary-foreground">{call.input}</div>
</div>
);
}
/**
* Default group layout: each call is an independent block with a state icon,
* `displayName(args)` header line, and (optionally) a corner-line-prefixed
* result block beneath it. Used by tools without a custom `renderGroup`,
* e.g. arbitrary MCP tools.
*
* `getDisplayName` / `renderCallArgs` / `renderResult` are passed in from
* `index.ts` so this function stays decoupled from the renderer registry.
*/
export function defaultRenderGroup(
calls: ToolCallWithResult[],
_t: TFunction,
resolvers: {
getDisplayName: (call: ToolCallBlock) => string;
renderCallArgs: (call: ToolCallBlock) => ReactNode;
renderResult: (call: ToolCallBlock, result: ToolResultBlock) => ReactNode;
},
): ReactNode {
return (
<>
{calls.map(({ call, result }) => {
const displayName = resolvers.getDisplayName(call);
const args = resolvers.renderCallArgs(call);
const resultContent = result ? resolvers.renderResult(call, result) : null;
return (
<Collapsible key={call.id} className="flex flex-col w-full max-w-full text-sm">
<CollapsibleTrigger asChild>
<Button
variant={'ghost'}
className="group hover:bg-transparent data-[state=open]:bg-transparent justify-start px-0 gap-2 active:!translate-y-0"
>
<ToolStateIcon states={[result?.state]} />
<strong className="shrink-0 text-primary">{displayName}</strong>
{args && <span className="truncate min-w-0 text-left">{args}</span>}
{resultContent && (
<ChevronRight className="size-3 shrink-0 group-data-[state=open]:rotate-90" />
)}
</Button>
</CollapsibleTrigger>
{resultContent && (
<CollapsibleContent className="flex flex-row gap-x-2 pl-6 max-w-full">
<CornerLine />
{resultContent}
</CollapsibleContent>
)}
</Collapsible>
);
})}
</>
);
}
@@ -0,0 +1,204 @@
import { ChevronDown, MoreHorizontal, Minus, Plus } from 'lucide-react';
import type { ReactElement } from 'react';
import { useMemo, useState } from 'react';
import { Decoration, Diff, Hunk, parseDiff } from 'react-diff-view';
import type { ChangeData, DiffType, GutterOptions, HunkData } from 'react-diff-view';
import 'react-diff-view/style/index.css';
const MAX_VISIBLE_DIFF_LINES = 18;
interface DiffPreviewProps {
/**
* Pre-computed unified-diff text (produced by the backend Edit / Write
* tools and delivered via ``ToolResultBlock.metadata.diff``). It carries
* absolute file line numbers and naturally handles multi-hunk diffs from
* ``replace_all``. The component renders nothing if this is empty.
*/
unifiedDiff: string;
}
function hunkLineCount(hunk: HunkData): number {
return hunk.changes.length;
}
function getVisibleHunks(hunks: HunkData[], expanded: boolean): HunkData[] {
if (expanded) return hunks;
let visibleLineCount = 0;
const visibleHunks: HunkData[] = [];
for (const hunk of hunks) {
const remaining = MAX_VISIBLE_DIFF_LINES - visibleLineCount;
if (remaining <= 0) break;
const hunkLines = hunkLineCount(hunk);
if (hunkLines <= remaining) {
visibleHunks.push(hunk);
visibleLineCount += hunkLines;
continue;
}
// Hunk doesn't fit in the remaining budget. If we haven't shown
// anything yet (typical for new-file / full-rewrite diffs that
// produce a single very large hunk), include a truncated slice so
// the user still sees the start of the change. Otherwise stop so
// we never overshoot ``MAX_VISIBLE_DIFF_LINES``.
if (visibleHunks.length === 0) {
visibleHunks.push(truncateHunkChanges(hunk, remaining));
}
break;
}
return visibleHunks;
}
/**
* Build a new ``HunkData`` containing only the first ``maxLines`` changes of
* ``hunk``. ``oldLines`` / ``newLines`` are recomputed from the slice so
* react-diff-view's line-number accounting stays internally consistent.
*/
function truncateHunkChanges(hunk: HunkData, maxLines: number): HunkData {
if (hunk.changes.length <= maxLines) return hunk;
const changes = hunk.changes.slice(0, maxLines);
let oldLines = 0;
let newLines = 0;
for (const change of changes) {
if (change.type === 'normal') {
oldLines += 1;
newLines += 1;
} else if (change.type === 'delete') {
oldLines += 1;
} else if (change.type === 'insert') {
newLines += 1;
}
}
return { ...hunk, changes, oldLines, newLines };
}
function countHiddenLines(allHunks: HunkData[], visibleHunks: HunkData[]): number {
const total = allHunks.reduce((sum, hunk) => sum + hunkLineCount(hunk), 0);
const visible = visibleHunks.reduce((sum, hunk) => sum + hunkLineCount(hunk), 0);
return Math.max(0, total - visible);
}
function getLineClassName({ changes }: { changes: Array<{ type: string }> }): string {
if (changes.some((change) => change.type === 'insert')) {
return 'bg-emerald-500/10';
}
if (changes.some((change) => change.type === 'delete')) {
return 'bg-red-500/10';
}
return 'bg-transparent';
}
// Pick the line number we want to display in the single visible gutter column.
// - normal rows show the new-side line number (mirrors GitHub's unified view)
// - insert / delete rows only have one `lineNumber` field, so use it directly
function getDisplayedLineNumber(change: ChangeData): number | undefined {
if (change.type === 'normal') return change.newLineNumber;
return change.lineNumber;
}
// Render only the "new" side of each row. The "old" side <col> is removed
// from the table layout via `visibility: collapse` (see arbitrary variants
// on the table className below), so the unified diff effectively renders
// as a single gutter column showing `<line-number> +/-`.
function renderGutter({ change, side }: GutterOptions) {
if (side === 'old') return null;
let marker = null;
if (change.type === 'insert') {
marker = <Plus className="size-2.5 text-emerald-600 dark:text-emerald-400" />;
} else if (change.type === 'delete') {
marker = <Minus className="size-2.5 text-red-600 dark:text-red-400" />;
}
return (
<span className="inline-flex w-full items-center justify-between tabular-nums">
<span>{getDisplayedLineNumber(change)}</span>
{marker}
</span>
);
}
// Lines between two consecutive hunks that the unified diff omitted.
// ``hunk.oldStart`` is 1-based and ``oldLines`` is the count of old-side
// lines covered (including context). So the next hunk's ``oldStart`` minus
// the end of the previous hunk gives the gap.
function gapLinesBetween(prev: HunkData, next: HunkData): number {
return next.oldStart - (prev.oldStart + prev.oldLines);
}
export function DiffPreview({ unifiedDiff }: DiffPreviewProps) {
const [expanded, setExpanded] = useState(false);
const diffFile = useMemo(
() => parseDiff(unifiedDiff, { nearbySequences: 'zip' })[0],
[unifiedDiff],
);
if (!diffFile || diffFile.hunks.length === 0) {
return <div className="text-xs text-muted-foreground">No textual changes detected.</div>;
}
const visibleHunks = getVisibleHunks(diffFile.hunks, expanded);
const hiddenLines = countHiddenLines(diffFile.hunks, visibleHunks);
return (
<Diff
diffType={diffFile.type as DiffType}
hunks={visibleHunks}
viewType="unified"
renderGutter={renderGutter}
className="w-full border-collapse font-mono text-xs leading-5 [&>colgroup>col:first-child]:!w-0 [&>colgroup>col:first-child]:[visibility:collapse] [&_td.diff-gutter:first-of-type]:!p-0 [&_td.diff-gutter:first-of-type]:!border-r-0 [&_td.diff-gutter:first-of-type]:!w-0"
hunkClassName="align-top"
lineClassName="align-top"
gutterClassName="select-none border-r border-border px-2 text-right text-muted-foreground"
codeClassName="whitespace-pre-wrap break-all px-3"
generateLineClassName={getLineClassName}
>
{(hunks) => {
const children: ReactElement[] = [];
hunks.forEach((hunk, idx) => {
if (idx > 0) {
// Insert an ellipsis decoration between hunks so it's
// visually obvious there are skipped lines between
// e.g. an edit at line 20 and another at line 70.
const gap = gapLinesBetween(hunks[idx - 1], hunk);
children.push(
<Decoration key={`gap-${hunk.oldStart}-${hunk.newStart}`}>
<div className="flex items-center gap-2 border-y border-dashed border-border bg-muted/30 px-3 py-1 text-[10px] text-muted-foreground select-none">
<MoreHorizontal className="h-3 w-3" />
<span className="tabular-nums">
{gap > 0
? `${gap} unchanged line${gap === 1 ? '' : 's'}`
: 'unchanged lines'}
</span>
</div>
</Decoration>,
);
}
children.push(
<Hunk key={`hunk-${hunk.oldStart}-${hunk.newStart}`} hunk={hunk} />,
);
});
if (hiddenLines > 0) {
children.push(
<Decoration key="collapsed-diff-lines">
<button
type="button"
className="flex w-full items-center justify-center gap-1 border-t border-border bg-muted/50 px-3 py-2 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={() => setExpanded(true)}
>
<ChevronDown className="h-3.5 w-3.5" />
{hiddenLines} more lines (click to expand)
</button>
</Decoration>,
);
}
return children;
}}
</Diff>
);
}
@@ -0,0 +1,108 @@
import unidiff from 'unidiff';
import {
defaultGetDisplayName,
defaultRenderCallArgs,
defaultRenderGroup,
defaultRenderResult,
} from './DefaultRenderer';
import { DiffPreview } from './DiffPreview';
import type { ToolRenderer } from './types';
import {
countDiffStats,
DiffStats,
getFilePath,
getResultDiff,
parseInput,
tryGetFileName,
} from '@/components/chat/tool-renderers/_shared.tsx';
/**
* Count the real inserted / deleted lines between ``oldText`` and ``newText``
* by computing a unified diff and tallying the leading ``+`` / ``-`` markers.
* This is the per-occurrence diff size — for ``replace_all`` the backend
* reports the totalled counts via ``result.metadata`` (see ``countDiffStats``).
*/
function countLineChanges(
oldText: string,
newText: string,
): { insertions: number; deletions: number } {
const diffText = unidiff.diffAsText(oldText, newText, { context: 0 });
return countDiffStats(diffText);
}
function renderEditDiff(result: { metadata?: Record<string, unknown> }) {
// The post-execution diff is always produced by the backend Edit tool
// (with absolute line numbers and one hunk per replaced occurrence).
// If it's missing we deliberately render nothing — falling back to a
// client-side diff of ``old_string`` / ``new_string`` would silently
// produce misleading line numbers and miss the other replace_all
// occurrences.
const unifiedDiff = getResultDiff(result);
if (!unifiedDiff) return null;
return <DiffPreview unifiedDiff={unifiedDiff} />;
}
export const EditRenderer: ToolRenderer = {
getDisplayName: (call) => call.name,
renderCallArgs: (call) => {
// While the tool-call JSON is still streaming, ``call.input`` is a
// partial dict and parsing yields the wrong file name / empty
// strings. Return an empty fragment (not ``null``) so the index.ts
// ``?? defaultRenderCallArgs`` fallback doesn't dump the raw JSON.
const fileName = tryGetFileName(call.input);
if (!fileName) return <></>;
const input = parseInput(call.input);
const oldString = typeof input.old_string === 'string' ? input.old_string : '';
const newString = typeof input.new_string === 'string' ? input.new_string : '';
// Use the real per-occurrence insert/delete counts rather than the
// raw line counts of old_string / new_string (which over-count when
// most lines are unchanged context).
const { insertions, deletions } = countLineChanges(oldString, newString);
return (
<div className="flex items-center gap-2 font-normal">
{fileName}
<DiffStats insertions={insertions} deletions={deletions} />
</div>
);
},
renderConfirmBody: (call) => (
<div className="w-full max-w-full overflow-hidden text-ellipsis truncate">
<div className="text-secondary-foreground">{getFilePath(call.input)}</div>
</div>
),
renderGroup: (calls, t) =>
defaultRenderGroup(calls, t, {
getDisplayName: (call) =>
EditRenderer.getDisplayName?.(call, t) ?? defaultGetDisplayName(call),
renderCallArgs: (call) => {
// When the backend has provided the post-execution unified diff
// (handles replace_all correctly), prefer its insert/delete
// counts over the per-occurrence client-side estimate.
const enriched = calls.find((c) => c.call.id === call.id);
const resultDiff = enriched?.result
? getResultDiff(enriched.result as { metadata?: Record<string, unknown> })
: undefined;
if (resultDiff) {
const fileName = tryGetFileName(call.input);
if (!fileName) return null;
const { insertions, deletions } = countDiffStats(resultDiff);
return (
<div className="flex items-center gap-2 font-normal">
{fileName}
<DiffStats insertions={insertions} deletions={deletions} />
</div>
);
}
return EditRenderer.renderCallArgs?.(call, t) ?? defaultRenderCallArgs(call);
},
renderResult: (call, result) =>
(result.state === 'success' ? renderEditDiff(result) : null) ??
EditRenderer.renderResult?.(call, result, t) ??
defaultRenderResult(call, result, t),
}),
};
@@ -0,0 +1,30 @@
import { ToolCallGroupList } from './_shared';
import type { ToolRenderer } from './types';
function parseInput(input: string): Record<string, unknown> {
try {
return JSON.parse(input);
} catch {
return {};
}
}
function getPattern(input: string): string {
const { pattern } = parseInput(input) as { pattern?: string };
return pattern || input;
}
export const GlobRenderer: ToolRenderer = {
getDisplayName: (_call, t) => t('tool.glob.name'),
renderCallArgs: (call) => getPattern(call.input),
renderGroup: (calls, t) => (
<ToolCallGroupList
calls={calls}
inline
label={<strong className="truncate text-primary text-sm">{t('tool.glob.name')}</strong>}
renderItem={(item) => getPattern(item.call.input)}
/>
),
};
@@ -0,0 +1,30 @@
import { ToolCallGroupList } from './_shared';
import type { ToolRenderer } from './types';
function parseInput(input: string): Record<string, unknown> {
try {
return JSON.parse(input);
} catch {
return {};
}
}
function getPattern(input: string): string {
const { pattern } = parseInput(input) as { pattern?: string };
return pattern || input;
}
export const GrepRenderer: ToolRenderer = {
getDisplayName: (_call, t) => t('tool.grep.name'),
renderCallArgs: (call) => getPattern(call.input),
renderGroup: (calls, t) => (
<ToolCallGroupList
calls={calls}
inline
label={<strong className="truncate text-primary text-sm">{t('tool.grep.name')}</strong>}
renderItem={(item) => getPattern(item.call.input)}
/>
),
};
@@ -0,0 +1,116 @@
/* eslint-disable react-refresh/only-export-components -- renderer constant is co-located with its inline component by design */
import type { ToolResultBlock } from '@agentscope-ai/agentscope/message';
import { ChevronRight } from 'lucide-react';
import { useState } from 'react';
import { CornerLine, getFilePath, ToolStateIcon } from './_shared';
import type { TFunction, ToolCallWithResult, ToolRenderer } from './types';
import { Button } from '@/components/ui/button.tsx';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { formatNumber } from '@/utils/common';
/** Count lines in a tool result's output string, including text content from
* non-text blocks. Returns 0 when the result is missing or empty. */
function countResultLines(result?: ToolResultBlock): number {
if (!result) return 0;
let str: string;
if (typeof result.output === 'string') {
str = result.output;
} else {
str = result.output.map((b) => (b.type === 'text' ? b.text : '')).join('\n');
}
if (!str) return 0;
return str.split('\n').length;
}
/** Collapse consecutive Read calls of the same `file_path` into one bucket so
* the path is shown once, followed by one corner-row per call. Order is
* preserved; a different path or a re-occurrence after another path starts a
* new bucket. */
function groupByConsecutivePath(
calls: ToolCallWithResult[],
): Array<{ path: string; calls: ToolCallWithResult[] }> {
const groups: Array<{ path: string; calls: ToolCallWithResult[] }> = [];
for (const item of calls) {
const path = getFilePath(item.call.input);
const last = groups[groups.length - 1];
if (last && last.path === path) {
last.calls.push(item);
} else {
groups.push({ path, calls: [item] });
}
}
return groups;
}
function ReadGroup({ calls, t }: { calls: ToolCallWithResult[]; t: TFunction }) {
const [open, setOpen] = useState(false);
const name = t('tool.read.name');
return (
<Collapsible open={open} onOpenChange={setOpen} className="flex flex-col w-full ">
<CollapsibleTrigger asChild>
<Button
variant={'ghost'}
className="group hover:bg-transparent data-[state=open]:bg-transparent justify-start px-0 gap-2 active:!translate-y-0"
>
<ToolStateIcon states={calls.map((c) => c.result?.state)} />
<span className="flex items-center text-sm truncate gap-1">
<strong className="text-primary">{name}</strong>
{t('tool.read.fileCount', { count: calls.length })}
</span>
<ChevronRight className="size-3 group-data-[state=open]:rotate-90" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="pl-6 pt-2 flex flex-col gap-y-2 text-sm">
{groupByConsecutivePath(calls).map((group, gIdx) => (
<div key={gIdx} className="flex flex-col min-w-0">
<div className="truncate">
{/*<strong className="text-primary">{name}</strong>*/}
{/*<span className="text-muted-foreground">({group.path})</span>*/}
<span
className={
'text-xs !overflow-visible !whitespace-normal !text-clip break-all'
}
>
{group.path}
</span>
</div>
{group.calls.map(({ call, result }) => {
if (!result) return null;
const lines = countResultLines(result);
return (
<div
key={call.id}
className="flex flex-row gap-x-2 items-center pl-2 text-xs"
>
<CornerLine />
<span className="text-muted-foreground">
{t('tool.read.lineCount', {
count: lines,
formatted: formatNumber(lines),
})}
</span>
</div>
);
})}
</div>
))}
</CollapsibleContent>
</Collapsible>
);
}
export const ReadRenderer: ToolRenderer = {
getDisplayName: (_call, t) => t('tool.read.name'),
renderCallArgs: (call) => getFilePath(call.input),
renderConfirmBody: (call) => (
<div className="w-full max-w-full overflow-hidden text-ellipsis truncate">
<div className="text-secondary-foreground">{getFilePath(call.input)}</div>
</div>
),
renderGroup: (calls, t) => <ReadGroup calls={calls} t={t} />,
};
@@ -0,0 +1,91 @@
/* eslint-disable react-refresh/only-export-components -- renderer constant is co-located with its inline component by design */
import { useState } from 'react';
import { CornerLine, ToolStateIcon } from './_shared';
import type { TFunction, ToolCallWithResult, ToolRenderer } from './types';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
function parseInput(input: string): Record<string, unknown> {
try {
return JSON.parse(input);
} catch {
return {};
}
}
/**
* Collapsed: "TaskCreate Created 3 tasks ..."
* Expanded:
* TaskCreate Created 3 tasks
* Research duck facts
* Find five interesting facts about ducks
* Write summary
* Compile the research into a brief summary
* Review code
* Check for bugs and style issues
*/
function TaskCreateGroup({ calls, t }: { calls: ToolCallWithResult[]; t: TFunction }) {
const [open, setOpen] = useState(false);
return (
<Collapsible open={open} onOpenChange={setOpen} className="flex flex-col w-full">
<CollapsibleTrigger className="flex flex-row gap-x-2 w-full max-w-full items-center cursor-pointer text-left">
<ToolStateIcon states={calls.map((c) => c.result?.state)} />
<span className="text-sm flex-1 min-w-0 truncate">
<strong className="text-primary">{t('tool.taskCreate.label')}</strong>{' '}
{t('tool.taskCreate.count', { count: calls.length })}
{!open && <span className="text-muted-foreground"> ...</span>}
</span>
</CollapsibleTrigger>
<CollapsibleContent className="pl-6 pt-2 flex flex-col gap-y-2 text-sm">
{calls.map(({ call, result }) => {
const input = parseInput(call.input);
const subject = (input.subject as string) || '(untitled)';
const description = (input.description as string) || '';
// Extract the numeric task id from result text:
// "Task (id=3) created successfully: ..."
const resultText =
typeof result?.output === 'string'
? result.output
: Array.isArray(result?.output)
? result.output.map((b) => ('text' in b ? b.text : '')).join('')
: '';
const idMatch = resultText.match(/^Task \(id=(\d+)\)/);
const taskId = idMatch ? idMatch[1] : null;
return (
<div key={call.id} className="flex flex-col min-w-0">
<span className="text-xs break-all font-medium">
{taskId && (
<span className="text-muted-foreground font-mono">
#{taskId}
</span>
)}
{taskId && ' '}
{subject}
</span>
{description && (
<div className="flex flex-row gap-x-2 items-start pl-2 text-xs">
<CornerLine />
<span className="text-muted-foreground break-all">
{description}
</span>
</div>
)}
</div>
);
})}
</CollapsibleContent>
</Collapsible>
);
}
export const TaskCreateRenderer: ToolRenderer = {
getDisplayName: (_call, t) => t('tool.taskCreate.name'),
renderCallArgs: (call) => {
const input = parseInput(call.input);
return (input.subject as string) || '';
},
renderGroup: (calls, t) => <TaskCreateGroup calls={calls} t={t} />,
};
@@ -0,0 +1,93 @@
import {
defaultGetDisplayName,
defaultRenderCallArgs,
defaultRenderGroup,
defaultRenderResult,
} from './DefaultRenderer';
import { DiffPreview } from './DiffPreview';
import type { ToolRenderer } from './types';
import {
countDiffStats,
DiffStats,
getFilePath,
getResultDiff,
tryGetFileName,
} from '@/components/chat/tool-renderers/_shared.tsx';
export const WriteRenderer: ToolRenderer = {
getDisplayName: (call) => call.name,
renderCallArgs: (call) => {
// While the tool-call JSON is still streaming, ``call.input`` is a
// partial dict (e.g. ``{"file_path": "foo", "content": "...``) and
// parsing yields the wrong file name or none at all. Render an empty
// fragment (not ``null``) so the index.ts ``?? defaultRenderCallArgs``
// fallback doesn't kick in and dump the raw JSON string.
const fileName = tryGetFileName(call.input);
if (!fileName) return <></>;
// Pre-execution we only know the new ``content`` (not the previous
// file body), so any ``+N`` count would be misleading on overwrites.
// The post-execution renderGroup override below adds ``+N -M`` once
// ``metadata.diff`` arrives.
return <div className="flex items-center gap-2 font-normal">{fileName}</div>;
},
renderConfirmBody: (call) => (
<div className="w-full max-w-full overflow-hidden text-ellipsis truncate">
<div className="text-secondary-foreground">{getFilePath(call.input)}</div>
</div>
),
renderResult: (call, result, t) => {
if (result.state === 'success') {
// The backend Write tool always attaches a unified diff in
// ``metadata.diff`` (correctly representing both new-file
// creation against /dev/null and overwrites of existing files
// with absolute line numbers). If it's missing we fall through
// rather than render a misleading client-side diff.
const unifiedDiff = getResultDiff(result as { metadata?: Record<string, unknown> });
if (unifiedDiff) {
return <DiffPreview unifiedDiff={unifiedDiff} />;
}
return undefined;
}
if (call.state === 'asking' || !result || result.state === 'running') {
return t('common.running');
}
if (result.state === 'interrupted') {
return t('common.interrupted');
}
return undefined;
},
renderGroup: (calls, t) =>
defaultRenderGroup(calls, t, {
getDisplayName: (call) =>
WriteRenderer.getDisplayName?.(call, t) ?? defaultGetDisplayName(call),
renderCallArgs: (call) => {
// Once the backend has produced the post-execution unified
// diff, compute and show the real ``+N -M`` stats — this
// correctly accounts for overwrites of existing files
// (which the pre-execution renderCallArgs cannot know).
const enriched = calls.find((c) => c.call.id === call.id);
const resultDiff = enriched?.result
? getResultDiff(enriched.result as { metadata?: Record<string, unknown> })
: undefined;
if (resultDiff) {
const fileName = tryGetFileName(call.input);
if (!fileName) return null;
const { insertions, deletions } = countDiffStats(resultDiff);
return (
<div className="flex items-center gap-2 font-normal">
{fileName}
<DiffStats insertions={insertions} deletions={deletions} />
</div>
);
}
return WriteRenderer.renderCallArgs?.(call, t) ?? defaultRenderCallArgs(call);
},
renderResult: (call, result) =>
WriteRenderer.renderResult?.(call, result, t) ??
defaultRenderResult(call, result, t),
}),
};
@@ -0,0 +1,207 @@
import type { ToolResultBlock } from '@agentscope-ai/agentscope/message';
import { Circle, Minus, Plus } from 'lucide-react';
import type { ReactNode } from 'react';
import type { ToolCallWithResult } from './types';
import lineCornerSvg from '@/assets/images/line-corner.svg';
import lineVerticalSvg from '@/assets/images/line-vertical.svg';
import { formatNumber } from '@/utils/common.ts';
/**
* Pick the connector image for an item at `index` of `total`:
* corner for the last row, vertical otherwise.
*/
function getLineImage(index: number, total: number): string {
return index === total - 1 ? lineCornerSvg : lineVerticalSvg;
}
/**
* Single tree-line cell. Use inside flex rows where one column is the line
* and the next is the actual content.
*/
export function TreeLine({
index,
total,
className = 'w-3 h-full',
}: {
index: number;
total: number;
className?: string;
}) {
return (
<div className="flex-shrink-0 h-full items-center">
<img src={getLineImage(index, total)} alt="" className={className} />
</div>
);
}
/**
* Single corner-only line, used when only one trailing item exists.
*/
export function CornerLine({ className = 'w-3 h-4' }: { className?: string }) {
return (
<div className="flex-shrink-0">
<img src={lineCornerSvg} alt="" className={className} />
</div>
);
}
/**
* Aggregated state icon over a list of tool result states. Mirrors the
* pre-refactor priority: running/undefined → pulsing muted; all success →
* green; any error → red; any interrupted → yellow; otherwise muted.
*/
export function ToolStateIcon({ states }: { states: (ToolResultBlock['state'] | undefined)[] }) {
if (states.includes('running') || states.includes(undefined)) {
return (
<Circle className="size-2.5 text-muted-foreground fill-muted-foreground animate-pulse shrink-0" />
);
}
if (states.every((state) => state === 'success')) {
return <Circle className="size-2.5 text-green-500 fill-green-500 shrink-0" />;
}
if (states.some((state) => state === 'error')) {
return <Circle className="size-2.5 text-red-500 fill-red-500 shrink-0" />;
}
if (states.some((state) => state === 'interrupted')) {
return <Circle className="size-2.5 text-yellow-500 fill-yellow-500 shrink-0" />;
}
return <Circle className="size-2.5 text-muted-foreground fill-muted-foreground shrink-0" />;
}
/**
* Header + indented item list, used by Read / Glob / Grep group renderers.
* Each item shows only the per-call args (no result), connected by SVG tree
* lines. `inline` lays items horizontally instead of stacked.
*/
export function ToolCallGroupList({
calls,
label,
renderItem,
inline,
}: {
calls: ToolCallWithResult[];
label: ReactNode;
renderItem: (item: ToolCallWithResult) => ReactNode;
inline?: boolean;
}) {
return (
<div className="flex flex-col w-full">
<div className="flex flex-row gap-x-2 w-full max-w-full items-center">
<ToolStateIcon states={calls.map((item) => item.result?.state)} />
{label}
</div>
<div className={`flex ${inline ? 'flex-row' : 'flex-col'} gap-x-2 pl-6 max-w-full`}>
{calls.map((item, index) => (
<div
key={item.call.id}
className="flex flex-row gap-x-2 w-full max-w-full items-stretch"
>
<TreeLine index={index} total={calls.length} />
<div className="truncate flex-1 min-w-0 text-sm">{renderItem(item)}</div>
</div>
))}
</div>
</div>
);
}
/**
* Parse the input arguments from the given string.
* @param input
* @returns The JSON Record or empty object if parsing fails.
*/
export function parseInput(input: string): Record<string, unknown> {
try {
return JSON.parse(input);
} catch {
return {};
}
}
/**
* Get the filepath from the input arguments
* @param input
* @returns The filepath from the input string
*/
export function getFilePath(input: string): string {
const { file_path } = parseInput(input) as { file_path?: string };
return file_path || input;
}
/**
* Get the filename
* @param input
* @returns The filename from the input string, considering different OS path separators.
*/
export function getFileName(input: string): string {
const filePath = getFilePath(input);
const segments = filePath.split(/[/\\]+/).filter(Boolean);
return segments.length > 0 ? segments[segments.length - 1] : filePath;
}
/**
* Like ``getFileName`` but returns ``undefined`` when the input is not yet
* a fully parseable JSON object with a non-empty ``file_path`` field. Use
* this in ``renderCallArgs`` so partial JSON streamed during tool-call
* generation doesn't render a garbled file name (e.g. a fragment of the
* ``content`` field being mistaken for the path).
*/
export function tryGetFileName(input: string): string | undefined {
let parsed: unknown;
try {
parsed = JSON.parse(input);
} catch {
return undefined;
}
if (!parsed || typeof parsed !== 'object') return undefined;
const filePath = (parsed as { file_path?: unknown }).file_path;
if (typeof filePath !== 'string' || filePath.length === 0) return undefined;
const segments = filePath.split(/[/\\]+/).filter(Boolean);
return segments.length > 0 ? segments[segments.length - 1] : filePath;
}
/**
* Tally inserted / deleted lines from a unified diff text. The leading
* ``+++`` / ``---`` lines (file headers) are excluded.
*/
export function countDiffStats(diffText: string): {
insertions: number;
deletions: number;
} {
let insertions = 0;
let deletions = 0;
for (const line of diffText.split('\n')) {
if (line.startsWith('+') && !line.startsWith('+++')) insertions++;
else if (line.startsWith('-') && !line.startsWith('---')) deletions++;
}
return { insertions, deletions };
}
/**
* Extract the ``diff`` field from a ToolResultBlock metadata bag, returning
* ``undefined`` when missing or empty so callers can use it with ``??``.
*/
export function getResultDiff(result: { metadata?: Record<string, unknown> }): string | undefined {
const diff = result.metadata?.diff;
return typeof diff === 'string' && diff.length > 0 ? diff : undefined;
}
/**
* Compact ``+N -M`` badge used in tool call headers for Edit / Write to show
* how many lines were inserted and deleted.
*/
export function DiffStats({ insertions, deletions }: { insertions: number; deletions: number }) {
return (
<div className="flex items-center gap-0.5">
<div className="flex items-center text-emerald-600 dark:text-emerald-400">
<Plus className="size-2.5 stroke-2" />
{formatNumber(insertions)}
</div>
<div className="flex items-center text-red-600 dark:text-red-400">
<Minus className="size-2.5 stroke-2" />
{formatNumber(deletions)}
</div>
</div>
);
}
@@ -0,0 +1,80 @@
import type { ToolCallBlock, ToolResultBlock } from '@agentscope-ai/agentscope/message';
import type { ReactNode } from 'react';
import { BashRenderer } from './BashRenderer';
import {
defaultGetDisplayName,
defaultRenderCallArgs,
defaultRenderConfirmBody,
defaultRenderGroup,
defaultRenderResult,
} from './DefaultRenderer';
import { EditRenderer } from './EditRenderer';
import { GlobRenderer } from './GlobRenderer';
import { GrepRenderer } from './GrepRenderer';
import { ReadRenderer } from './ReadRenderer';
import { TaskCreateRenderer } from './TaskCreateRenderer';
import type { TFunction, ToolCallWithResult, ToolRenderer } from './types';
import { WriteRenderer } from './WriteRenderer';
const renderers: Record<string, ToolRenderer> = {
Bash: BashRenderer,
Read: ReadRenderer,
Write: WriteRenderer,
Edit: EditRenderer,
Glob: GlobRenderer,
Grep: GrepRenderer,
TaskCreate: TaskCreateRenderer,
};
function getRenderer(toolName: string): ToolRenderer {
return renderers[toolName] ?? {};
}
export function getDisplayName(call: ToolCallBlock, t: TFunction): string {
const r = getRenderer(call.name);
return r.getDisplayName?.(call, t) ?? defaultGetDisplayName(call);
}
export function renderCallArgs(call: ToolCallBlock, t: TFunction): ReactNode {
const r = getRenderer(call.name);
return r.renderCallArgs?.(call, t) ?? defaultRenderCallArgs(call);
}
export function renderResult(
call: ToolCallBlock,
result: ToolResultBlock,
t: TFunction,
): ReactNode {
const r = getRenderer(call.name);
return r.renderResult?.(call, result, t) ?? defaultRenderResult(call, result, t);
}
export function renderConfirmBody(call: ToolCallBlock, t: TFunction): ReactNode {
const r = getRenderer(call.name);
return r.renderConfirmBody?.(call, t) ?? defaultRenderConfirmBody(call);
}
/**
* Render a group of consecutive tool calls of the same name.
*
* - If the tool's renderer defines `renderGroup`, delegate to it.
* - Otherwise fall back to `defaultRenderGroup`, wired with this registry's
* resolvers so per-tool `getDisplayName` / `renderCallArgs` / `renderResult`
* still apply.
*/
export function renderToolGroup(
toolName: string,
calls: ToolCallWithResult[],
t: TFunction,
): ReactNode {
const r = getRenderer(toolName);
if (r.renderGroup) {
return r.renderGroup(calls, t);
}
return defaultRenderGroup(calls, t, {
getDisplayName: (call) => getDisplayName(call, t),
renderCallArgs: (call) => renderCallArgs(call, t),
renderResult: (call, result) => renderResult(call, result, t),
});
}
@@ -0,0 +1,23 @@
import type { ToolCallBlock, ToolResultBlock } from '@agentscope-ai/agentscope/message';
import type { ReactNode } from 'react';
export type TFunction = (key: string, params?: Record<string, unknown>) => string;
export interface ToolCallWithResult {
call: ToolCallBlock;
result?: ToolResultBlock;
}
export interface ToolRenderer {
getDisplayName?: (call: ToolCallBlock, t: TFunction) => string;
renderCallArgs?: (call: ToolCallBlock, t: TFunction) => ReactNode;
renderResult?: (call: ToolCallBlock, result: ToolResultBlock, t: TFunction) => ReactNode;
renderConfirmBody?: (call: ToolCallBlock, t: TFunction) => ReactNode;
/**
* Render a group of consecutive tool calls of the same name.
* Receives the visible (non-truncated) calls — `MessageBubble` truncates
* at the first `asking` call before invoking, and renders ConfirmCard
* separately.
*/
renderGroup?: (calls: ToolCallWithResult[], t: TFunction) => ReactNode;
}
@@ -0,0 +1,83 @@
import { CircleAlert, Loader2, PlusCircle } from 'lucide-react';
import { useState, type ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useTranslation } from '@/i18n/useI18n';
interface AddSkillDialogProps {
children: ReactNode;
onAdd: (skillPath: string) => Promise<void>;
}
export function AddSkillDialog({ children, onAdd }: AddSkillDialogProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [skillPath, setSkillPath] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async () => {
if (!skillPath.trim()) return;
setLoading(true);
setError(null);
try {
await onAdd(skillPath.trim());
setSkillPath('');
setOpen(false);
} catch (e) {
setError((e as Error).message);
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="!w-[500px] !max-w-[500px]">
<DialogHeader>
<DialogTitle>{t('dialog-skill-add.title')}</DialogTitle>
<DialogDescription>{t('dialog-skill-add.description')}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-y-2">
<Label htmlFor="skill-path">{t('dialog-skill-add.pathLabel')}</Label>
<Input
id="skill-path"
placeholder={t('dialog-skill-add.pathPlaceholder')}
value={skillPath}
onChange={(e) => setSkillPath(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSubmit();
}}
/>
{error && <p className="text-destructive text-sm">{error}</p>}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)} disabled={loading}>
<CircleAlert className="size-3.5" />
{t('common.cancel')}
</Button>
<Button onClick={handleSubmit} disabled={loading || !skillPath.trim()}>
{loading ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<PlusCircle className="size-3.5" />
)}
{loading ? t('dialog-mcp-create.adding') : t('common.add')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,137 @@
import { CircleAlert, Loader2, PlusCircle } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { ContextConfig, InviteConfig, ReActConfig } from '@/api';
import {
AgentFormFields,
defaultAgentFormValues,
type AgentFormValues,
type AgentSection,
} from '@/components/form/AgentFormFields';
import type { SchemaFormValue } from '@/components/form/SchemaForm';
import { Alert, AlertDescription } from '@/components/ui/alert.tsx';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogDescription,
DialogTrigger,
} from '@/components/ui/dialog';
import { useAgents } from '@/hooks/useAgents';
import { useAgentSchema } from '@/hooks/useAgentSchema';
import { formatApiErrorForAlert } from '@/lib/api-error';
interface Props {
onCreated?: () => void;
triggerId?: string;
}
export function AgentDialog({ onCreated, triggerId }: Props) {
const { create } = useAgents();
const { t } = useTranslation();
const { schema } = useAgentSchema();
const [open, setOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [values, setValues] = useState<AgentFormValues | null>(null);
const [errorMsg, setErrorMsg] = useState('');
useEffect(() => {
if (open && schema && !values) {
setValues(defaultAgentFormValues(schema));
}
if (!open) {
setValues(null);
setErrorMsg('');
}
}, [open, schema, values]);
const handleChange = (section: AgentSection, key: string, value: SchemaFormValue) => {
setErrorMsg('');
setValues((prev) =>
prev ? { ...prev, [section]: { ...prev[section], [key]: value } } : prev,
);
};
const handleSubmit = async () => {
if (!values) return;
const name = (values.identity.name as string | undefined)?.trim();
if (!name) return;
setErrorMsg('');
setSubmitting(true);
try {
await create(
{
name,
system_prompt: values.identity.system_prompt as string | undefined,
context_config: values.context_config as unknown as ContextConfig,
react_config: values.react_config as unknown as ReActConfig,
invite_config: values.invite_config as unknown as InviteConfig,
},
{ silent: true },
);
setOpen(false);
onCreated?.();
} catch (e) {
setErrorMsg(formatApiErrorForAlert(e));
} finally {
setSubmitting(false);
}
};
const nameValid = !!(values?.identity.name as string | undefined)?.trim();
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button id={triggerId}>
<PlusCircle />
<span>{t('dialog-agent-create.trigger')}</span>
</Button>
</DialogTrigger>
<DialogContent className="!w-[500px] !max-w-[500px]">
<DialogHeader>
<DialogTitle>{t('dialog-agent-create.title')}</DialogTitle>
<DialogDescription className="sr-only">
{t('dialog-agent-create.description')}
</DialogDescription>
</DialogHeader>
<div className="no-scrollbar -mx-4 max-h-[75vh] overflow-y-auto px-4">
{schema && values ? (
<AgentFormFields schema={schema} values={values} onChange={handleChange} />
) : (
<p className="text-muted-foreground text-sm">{t('common.loading')}</p>
)}
</div>
{errorMsg && (
<Alert variant="destructive">
<CircleAlert />
<AlertDescription className="whitespace-pre-wrap">
{errorMsg}
</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button variant="ghost" onClick={() => setOpen(false)} disabled={submitting}>
<CircleAlert className="size-3.5" />
{t('common.cancel')}
</Button>
<Button
onClick={handleSubmit}
disabled={!nameValid || submitting || !schema || !values}
>
{submitting ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<PlusCircle className="size-3.5" />
)}
{submitting ? t('common.creating') : t('common.create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,152 @@
import { CircleAlert, Loader2, PlusCircle } from 'lucide-react';
import { useState, useEffect } from 'react';
import { credentialApi } from '@/api';
import type { CredentialSchema } from '@/api';
import { SchemaForm, type SchemaFormValue } from '@/components/form/SchemaForm';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field.tsx';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useCredentials } from '@/hooks/useCredentials';
import { useTranslation } from '@/i18n/useI18n.ts';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated?: () => void;
defaultType?: string;
}
export function CreateCredentialDialog({ open, onOpenChange, onCreated, defaultType }: Props) {
const { create } = useCredentials();
const { t } = useTranslation();
const [schemas, setSchemas] = useState<CredentialSchema[]>([]);
const [loadingSchemas, setLoadingSchemas] = useState(false);
const [selectedType, setSelectedType] = useState('');
const [values, setValues] = useState<Record<string, SchemaFormValue>>({});
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!open) return;
setLoadingSchemas(true);
credentialApi
.schemas()
.then((res) => {
setSchemas(res.schemas);
if (res.schemas.length > 0) {
const first = (res.schemas[0].properties.type?.const as string) ?? '';
setSelectedType(defaultType ?? first);
}
})
.finally(() => setLoadingSchemas(false));
}, [open, defaultType]);
const selectedSchema = schemas.find(
(s) => (s.properties.type?.const as string) === selectedType,
);
const handleTypeChange = (type: string) => {
setSelectedType(type);
setValues({});
};
const handleSubmit = async () => {
if (!selectedSchema) return;
setSubmitting(true);
try {
const data: Record<string, unknown> = { type: selectedType };
for (const [key, prop] of Object.entries(selectedSchema.properties)) {
if (key === 'id' || key === 'type' || prop.const !== undefined) continue;
const val = values[key];
if (val !== undefined && val !== '') data[key] = val;
}
await create({ data });
onOpenChange(false);
onCreated?.();
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="!w-[500px] !max-w-[500px]">
<DialogHeader>
<DialogTitle>{t('dialog-credential-create.title')}</DialogTitle>
<DialogDescription>
{t('dialog-credential-create.description')}
</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel>{t('dialog-credential-create.selectType')}</FieldLabel>
<Select
value={selectedType}
onValueChange={handleTypeChange}
disabled={loadingSchemas}
>
<SelectTrigger>
<SelectValue
placeholder={
loadingSchemas
? t('common.loading')
: t('dialog-credential-create.selectTypePlaceholder')
}
/>
</SelectTrigger>
<SelectContent>
{schemas.map((s) => (
<SelectItem
key={s.properties.type?.const as string}
value={s.properties.type?.const as string}
>
{s.title}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
{selectedSchema && (
<SchemaForm
schema={selectedSchema}
values={values}
onChange={(key, val) => setValues((prev) => ({ ...prev, [key]: val }))}
/>
)}
</FieldGroup>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
<CircleAlert className="size-3.5" />
{t('common.cancel')}
</Button>
<Button onClick={handleSubmit} disabled={submitting || !selectedSchema}>
{submitting ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<PlusCircle className="size-3.5" />
)}
{submitting ? t('common.creating') : t('common.create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,246 @@
import { CircleAlert, Info, Loader2, PlusCircle } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import type { EmbeddingModelCard, EmbeddingModelConfig } from '@/api';
import { DimensionSelect } from '@/components/select/DimensionSelect';
import { EmbeddingSelect } from '@/components/select/EmbeddingSelect';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert.tsx';
import { Button } from '@/components/ui/button.tsx';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog.tsx';
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field.tsx';
import { Input } from '@/components/ui/input.tsx';
import { Textarea } from '@/components/ui/textarea.tsx';
import { useKbEmbeddingModels } from '@/hooks/useKbEmbeddingModels';
import { useKnowledgeBases } from '@/hooks/useKnowledgeBases';
import { useTranslation } from '@/i18n/useI18n.ts';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated?: (knowledgeBaseId: string) => void;
onAddCredential?: () => void;
/**
* Bumped externally (e.g. after a credential is created) to ask the
* embedding selector to refetch its options.
*/
credentialRefetchTrigger?: number;
}
interface SelectedEmbedding {
type: string;
credentialId: string;
model: string;
card: EmbeddingModelCard;
}
export function CreateKnowledgeBaseDialog({
open,
onOpenChange,
onCreated,
onAddCredential,
credentialRefetchTrigger,
}: Props) {
const { t } = useTranslation();
const { create } = useKnowledgeBases();
const { providers, policy, loading } = useKbEmbeddingModels(credentialRefetchTrigger);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [selected, setSelected] = useState<SelectedEmbedding | null>(null);
const [dimension, setDimension] = useState<number | null>(null);
const [submitting, setSubmitting] = useState(false);
const [errorKey, setErrorKey] = useState<string | null>(null);
useEffect(() => {
if (!open) {
setName('');
setDescription('');
setSelected(null);
setDimension(null);
setErrorKey(null);
setSubmitting(false);
}
}, [open]);
const dimensionOptions = useMemo<number[] | null>(() => {
if (!selected) return null;
const sd = selected.card.supported_dimensions;
return sd && sd.length > 0 ? sd : [selected.card.dimensions];
}, [selected]);
const handleSelectEmbedding = (sel: SelectedEmbedding) => {
setSelected(sel);
const sd = sel.card.supported_dimensions;
const defaultDim =
sd && sd.length > 0
? sd.includes(sel.card.dimensions)
? sel.card.dimensions
: sd[0]
: sel.card.dimensions;
setDimension(defaultDim);
};
const handleSubmit = async () => {
if (!name.trim()) {
setErrorKey('dialog-knowledge-base-create.errors.nameRequired');
return;
}
if (!selected) {
setErrorKey('dialog-knowledge-base-create.errors.embeddingRequired');
return;
}
if (dimension == null || dimension <= 0) {
setErrorKey('dialog-knowledge-base-create.errors.dimensionRequired');
return;
}
setErrorKey(null);
setSubmitting(true);
try {
const config: EmbeddingModelConfig = {
type: selected.type,
credential_id: selected.credentialId,
model: selected.model,
dimensions: dimension,
parameters: {},
};
const knowledgeBaseId = await create({
name: name.trim(),
description: description.trim(),
embedding_model_config: config,
});
onCreated?.(knowledgeBaseId);
onOpenChange(false);
} finally {
setSubmitting(false);
}
};
const isLockedPolicy = policy && policy.kind !== 'any';
const noCompatibleModels = !loading && providers.length === 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="!w-[500px] !max-w-[500px]">
<DialogHeader>
<DialogTitle>{t('dialog-knowledge-base-create.title')}</DialogTitle>
<DialogDescription>
{t('dialog-knowledge-base-create.description')}
</DialogDescription>
</DialogHeader>
{isLockedPolicy && policy?.dimension != null && (
<Alert>
<Info className="size-4" />
<AlertTitle>
{t('dialog-knowledge-base-create.policy.lockedTitle', {
dimension: policy.dimension,
})}
</AlertTitle>
<AlertDescription>
{t('dialog-knowledge-base-create.policy.lockedDescription', {
dimension: policy.dimension,
})}
</AlertDescription>
</Alert>
)}
{noCompatibleModels && (
<Alert variant="destructive">
<CircleAlert className="size-4" />
<AlertTitle>
{t('dialog-knowledge-base-create.policy.noCompatibleTitle')}
</AlertTitle>
<AlertDescription>
{isLockedPolicy && policy?.dimension != null
? t('dialog-knowledge-base-create.policy.noCompatibleLocked', {
dimension: policy.dimension,
})
: t('dialog-knowledge-base-create.policy.noCompatibleAny')}
</AlertDescription>
</Alert>
)}
<FieldGroup>
<Field>
<FieldLabel>{t('dialog-knowledge-base-create.name.label')}</FieldLabel>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('dialog-knowledge-base-create.name.placeholder')}
disabled={submitting}
/>
</Field>
<Field>
<FieldLabel>
{t('dialog-knowledge-base-create.descriptionField.label')}
</FieldLabel>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t(
'dialog-knowledge-base-create.descriptionField.placeholder',
)}
disabled={submitting}
rows={3}
/>
</Field>
<Field orientation="horizontal">
<FieldLabel>
{t('dialog-knowledge-base-create.embeddingModel.label')}
</FieldLabel>
<EmbeddingSelect
value={
selected
? {
type: selected.type,
credential_id: selected.credentialId,
model: selected.model,
}
: null
}
providers={providers}
loading={loading}
onChange={handleSelectEmbedding}
onAddCredential={onAddCredential}
/>
</Field>
<Field orientation="horizontal">
<FieldLabel>{t('dialog-knowledge-base-create.dimension.label')}</FieldLabel>
<DimensionSelect
value={dimension}
options={dimensionOptions}
onChange={setDimension}
disabled={submitting}
/>
</Field>
{errorKey && <p className="text-destructive text-sm">{t(errorKey)}</p>}
</FieldGroup>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
<CircleAlert className="size-3.5" />
{t('common.cancel')}
</Button>
<Button onClick={handleSubmit} disabled={submitting || noCompatibleModels}>
{submitting ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<PlusCircle className="size-3.5" />
)}
{submitting ? t('common.creating') : t('common.create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,70 @@
import { Loader2, CheckCircle, CircleAlert } from 'lucide-react';
import { useState } from 'react';
import type { ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { useTranslation } from '@/i18n/useI18n';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: ReactNode;
confirmLabel?: string;
onConfirm: () => Promise<void>;
}
export function DeleteDialog({
open,
onOpenChange,
title,
description,
confirmLabel,
onConfirm,
}: Props) {
const { t } = useTranslation();
const [deleting, setDeleting] = useState(false);
const handleConfirm = async () => {
setDeleting(true);
try {
await onConfirm();
onOpenChange(false);
} finally {
setDeleting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={deleting}>
<CircleAlert className="size-3.5" />
{t('common.cancel')}
</Button>
<Button onClick={handleConfirm} disabled={deleting} autoFocus>
{deleting ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<CheckCircle className="size-3.5" />
)}
{confirmLabel ?? t('common.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,151 @@
import { CircleAlert, Loader2, Save } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { AgentView, ContextConfig, InviteConfig, ReActConfig } from '@/api';
import {
AgentFormFields,
defaultAgentFormValues,
type AgentFormValues,
type AgentSection,
} from '@/components/form/AgentFormFields';
import type { SchemaFormValue } from '@/components/form/SchemaForm';
import { Alert, AlertDescription } from '@/components/ui/alert.tsx';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { useAgents } from '@/hooks/useAgents';
import { useAgentSchema } from '@/hooks/useAgentSchema';
import { formatApiErrorForAlert } from '@/lib/api-error';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
agent: AgentView;
onUpdated?: () => void;
}
export function EditAgentDialog({ open, onOpenChange, agent, onUpdated }: Props) {
const { update } = useAgents();
const { t } = useTranslation();
const { schema } = useAgentSchema();
const [submitting, setSubmitting] = useState(false);
const [values, setValues] = useState<AgentFormValues | null>(null);
const [errorMsg, setErrorMsg] = useState('');
useEffect(() => {
if (!open || !schema) {
if (!open) {
setValues(null);
setErrorMsg('');
}
return;
}
// Start from schema defaults, then overlay the existing agent's data so
// any unset fields fall back to defaults rather than empty.
const base = defaultAgentFormValues(schema);
const d = agent.data;
setValues({
identity: {
...base.identity,
name: d.name,
system_prompt: d.system_prompt,
},
context_config: { ...base.context_config, ...(d.context_config ?? {}) },
react_config: { ...base.react_config, ...(d.react_config ?? {}) },
invite_config: { ...base.invite_config, ...(d.invite_config ?? {}) },
});
setErrorMsg('');
}, [open, schema, agent]);
const handleChange = (section: AgentSection, key: string, value: SchemaFormValue) => {
setErrorMsg('');
setValues((prev) =>
prev ? { ...prev, [section]: { ...prev[section], [key]: value } } : prev,
);
};
const handleSubmit = async () => {
if (!values) return;
const name = (values.identity.name as string | undefined)?.trim();
if (!name) return;
setErrorMsg('');
setSubmitting(true);
try {
await update(
agent.id,
{
name,
system_prompt: values.identity.system_prompt as string | undefined,
context_config: values.context_config as unknown as ContextConfig,
react_config: values.react_config as unknown as ReActConfig,
invite_config: values.invite_config as unknown as InviteConfig,
},
{ silent: true },
);
onOpenChange(false);
onUpdated?.();
} catch (e) {
setErrorMsg(formatApiErrorForAlert(e));
} finally {
setSubmitting(false);
}
};
const nameValid = !!(values?.identity.name as string | undefined)?.trim();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="!w-[500px] !max-w-[500px]">
<DialogHeader>
<DialogTitle>{t('dialog-agent-edit.title')}</DialogTitle>
<DialogDescription className="sr-only">
{t('dialog-agent-edit.description')}
</DialogDescription>
</DialogHeader>
<div className="no-scrollbar -mx-4 max-h-[75vh] overflow-y-auto px-4">
{schema && values ? (
<AgentFormFields schema={schema} values={values} onChange={handleChange} />
) : (
<p className="text-muted-foreground text-sm">{t('common.loading')}</p>
)}
</div>
{errorMsg && (
<Alert variant="destructive">
<CircleAlert />
<AlertDescription className="whitespace-pre-wrap">
{errorMsg}
</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
<CircleAlert className="size-3.5" />
{t('common.cancel')}
</Button>
<Button
onClick={handleSubmit}
disabled={!nameValid || submitting || !schema || !values}
>
{submitting ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Save className="size-3.5" />
)}
{submitting ? t('common.saving') : t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

Some files were not shown because too many files have changed in this diff Show More