Compare commits
No commits in common. "main" and "v0.9.x" have entirely different histories.
2365 changed files with 17042 additions and 582072 deletions
8
.github/ISSUE_TEMPLATE/config.yml
vendored
8
.github/ISSUE_TEMPLATE/config.yml
vendored
|
|
@ -1,8 +0,0 @@
|
|||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Ask a question or discuss a topic
|
||||
url: https://discord.com/invite/BkMR2NUsjU
|
||||
about: Ask questions and discuss with other community members
|
||||
- name: Join Yao community
|
||||
url: https://yaoapps.com/community
|
||||
about: Join the community to get updates and news
|
||||
13
.github/ISSUE_TEMPLATE/issue_report.md
vendored
13
.github/ISSUE_TEMPLATE/issue_report.md
vendored
|
|
@ -1,13 +0,0 @@
|
|||
---
|
||||
name: "Report an issue"
|
||||
about: "Report an issue to help us improve"
|
||||
labels: ""
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
## Context
|
||||
|
||||
- **Yao Version( yao version --all )**:
|
||||
- **Platform**:
|
||||
2
.github/actions/setup-db/Dockerfile
vendored
2
.github/actions/setup-db/Dockerfile
vendored
|
|
@ -1,4 +1,4 @@
|
|||
FROM docker:latest
|
||||
FROM docker:stable
|
||||
|
||||
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
|
|
|
|||
3
.github/actions/setup-db/action.yml
vendored
3
.github/actions/setup-db/action.yml
vendored
|
|
@ -6,9 +6,6 @@ inputs:
|
|||
description: "The name of database"
|
||||
required: false
|
||||
default: "github"
|
||||
port:
|
||||
description: "The port of database"
|
||||
required: false
|
||||
user:
|
||||
description: "The user of database"
|
||||
required: false
|
||||
|
|
|
|||
11
.github/actions/setup-db/entrypoint.sh
vendored
11
.github/actions/setup-db/entrypoint.sh
vendored
|
|
@ -3,18 +3,11 @@
|
|||
docker_run="docker run"
|
||||
|
||||
startMySQL() {
|
||||
|
||||
PORT="3306"
|
||||
if [ ! -z "$INPUT_PORT" ]; then
|
||||
PORT=$INPUT_PORT
|
||||
fi
|
||||
|
||||
|
||||
VERSION=$1
|
||||
echo "Start MySQL $VERSION"
|
||||
docker_run="$docker_run -e MYSQL_RANDOM_ROOT_PASSWORD=true -e MYSQL_USER=$INPUT_USER -e MYSQL_PASSWORD=$INPUT_PASSWORD"
|
||||
docker_run="$docker_run -e MYSQL_DATABASE=$INPUT_DB"
|
||||
docker_run="$docker_run -d -p $PORT:3306 mysql:$VERSION --port=3306 --sql-mode=''"
|
||||
docker_run="$docker_run -d -p 3306:3306 mysql:$VERSION --port=3306 --sql-mode=''"
|
||||
|
||||
if [ "$VERSION" = "5.6" ]; then
|
||||
docker_run="$docker_run --character-set-server=utf8 --collation-server=utf8_general_ci"
|
||||
|
|
@ -24,7 +17,7 @@ startMySQL() {
|
|||
|
||||
sh -c "$docker_run"
|
||||
|
||||
DB_HOST="tcp(127.0.0.1:$PORT)/$INPUT_DB?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
DB_HOST="tcp(127.0.0.1:3306)/$INPUT_DB?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
DB_USER=$INPUT_USER
|
||||
echo "DB_HOST=$DB_HOST" >> $GITHUB_ENV
|
||||
echo "DB_USER=$DB_USER" >> $GITHUB_ENV
|
||||
|
|
|
|||
113
.github/actions/setup-yao/action.yml
vendored
113
.github/actions/setup-yao/action.yml
vendored
|
|
@ -1,113 +0,0 @@
|
|||
name: "Setup Yao Build Environment"
|
||||
description: "Checkout dependency repos, setup Go toolchain, and install build tools (v1.0.0)"
|
||||
|
||||
inputs:
|
||||
go-version:
|
||||
description: "Go version to install"
|
||||
default: "1.25"
|
||||
repo-kun:
|
||||
description: "Kun repository (owner/repo)"
|
||||
required: true
|
||||
repo-xun:
|
||||
description: "Xun repository (owner/repo)"
|
||||
required: true
|
||||
repo-gou:
|
||||
description: "Gou repository (owner/repo)"
|
||||
required: true
|
||||
checkout-app:
|
||||
description: "Checkout yao-dev-app (demo application for tests)"
|
||||
default: "true"
|
||||
checkout-init:
|
||||
description: "Checkout yao-init (for Yao server startup in CI)"
|
||||
default: "false"
|
||||
apple-private-key:
|
||||
description: "Apple private key content for OAuth certs (optional)"
|
||||
default: ""
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
# -- Dependency repositories --
|
||||
- name: Checkout Kun
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ inputs.repo-kun }}
|
||||
path: kun
|
||||
|
||||
- name: Checkout Xun
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ inputs.repo-xun }}
|
||||
path: xun
|
||||
|
||||
- name: Checkout Gou
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ inputs.repo-gou }}
|
||||
path: gou
|
||||
|
||||
- name: Checkout V8Go
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: yaoapp/v8go
|
||||
path: v8go
|
||||
|
||||
- name: Unzip libv8
|
||||
shell: bash
|
||||
run: |
|
||||
for file in $(find ./v8go -name "libv8*.zip"); do
|
||||
dir=$(dirname "$file")
|
||||
echo "Extracting $file to $dir"
|
||||
unzip -o -d "$dir" "$file"
|
||||
rm -rf "$dir/__MACOSX"
|
||||
done
|
||||
|
||||
- name: Checkout Demo App
|
||||
if: ${{ inputs.checkout-app == 'true' }}
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: yaoapp/yao-dev-app
|
||||
path: app
|
||||
|
||||
- name: Checkout Extension
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: yaoapp/yao-extensions-dev
|
||||
path: extension
|
||||
|
||||
- name: Checkout yao-init
|
||||
if: ${{ inputs.checkout-init == 'true' }}
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: yaoapp/yao-init
|
||||
path: yao-init
|
||||
|
||||
# -- Move all dependencies to parent directory (Go workspace layout) --
|
||||
- name: Move Dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
mv kun ../
|
||||
mv xun ../
|
||||
mv gou ../
|
||||
mv v8go ../
|
||||
[ -d app ] && mv app ../
|
||||
mv extension ../
|
||||
[ -d yao-init ] && mv yao-init ../
|
||||
|
||||
# -- Setup Apple Private Key (if provided) --
|
||||
- name: Setup Apple Private Key
|
||||
if: ${{ inputs.apple-private-key != '' }}
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p ../app/openapi/certs/apple
|
||||
echo "${{ inputs.apple-private-key }}" > ../app/openapi/certs/apple/signin_client_secret_key.p8
|
||||
|
||||
# -- Go toolchain --
|
||||
- name: Setup Go ${{ inputs.go-version }}
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ inputs.go-version }}
|
||||
|
||||
- name: Setup Go Tools
|
||||
shell: bash
|
||||
run: make tools
|
||||
10
.github/codesign/entitlements.plist
vendored
10
.github/codesign/entitlements.plist
vendored
|
|
@ -1,10 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
141
.github/env/sandbox-v2.env
vendored
141
.github/env/sandbox-v2.env
vendored
|
|
@ -1,141 +0,0 @@
|
|||
# ============================================================
|
||||
# Yao CI Environment — sandbox-v2 (v1.0.0)
|
||||
# Loaded via: cat .github/env/sandbox-v2.env >> $GITHUB_ENV
|
||||
# ============================================================
|
||||
|
||||
# ========================================
|
||||
# Yao Runtime (YAO_ prefix, read by Yao)
|
||||
# ========================================
|
||||
YAO_HOST=0.0.0.0
|
||||
YAO_PORT=5099
|
||||
YAO_GRPC_HOST=0.0.0.0
|
||||
YAO_GRPC_PORT=9099
|
||||
YAO_DB_DRIVER=sqlite3
|
||||
YAO_SESSION=memory
|
||||
YAO_ENV=development
|
||||
|
||||
# ========================================
|
||||
# CI Test Parameters (YAO_CI_ prefix)
|
||||
# ========================================
|
||||
|
||||
# -- Network --
|
||||
YAO_CI_BRIDGE_IP=172.17.0.1
|
||||
|
||||
# -- Yao service ports (tests read these, not YAO_PORT/YAO_GRPC_PORT) --
|
||||
YAO_CI_HTTP_PORT=5099
|
||||
YAO_CI_GRPC_PORT=9099
|
||||
YAO_CI_URL=http://127.0.0.1:5099
|
||||
YAO_CI_GRPC=127.0.0.1:9099
|
||||
|
||||
# -- OAuth token generation (ci-token tool) --
|
||||
YAO_CI_OAUTH_SUBJECT=ci-test-user
|
||||
YAO_CI_OAUTH_USER_ID=ci-test-user
|
||||
YAO_CI_OAUTH_TEAM_ID=ci-test-team
|
||||
YAO_CI_OAUTH_SCOPE=tai:tunnel
|
||||
YAO_CI_OAUTH_TTL=24h
|
||||
|
||||
# ========================================
|
||||
# Tai Instances
|
||||
#
|
||||
# Connection modes:
|
||||
# tai-local → DIRECT (--direct, Yao dials Tai gRPC directly)
|
||||
# tai-docker → TUNNEL (default, Tai dials Yao gRPC, reverse tunnel)
|
||||
# tai-k8s → TUNNEL (same as above, with K8s runtime)
|
||||
# tai-hostexec → TUNNEL (same as above, no container runtime)
|
||||
# ========================================
|
||||
|
||||
# -- tai-local (DIRECT mode, auto-detect Docker via /var/run/docker.sock) --
|
||||
# Yao dials tai-local gRPC directly, so gRPC port must be reachable.
|
||||
# Docker proxy on 12376 (not default 12375) to avoid conflict with tai-docker.
|
||||
YAO_CI_TAI_LOCAL_HOST=127.0.0.1
|
||||
YAO_CI_TAI_LOCAL_GRPC_PORT=19103
|
||||
YAO_CI_TAI_LOCAL_HTTP_PORT=8102
|
||||
YAO_CI_TAI_LOCAL_VNC_PORT=16083
|
||||
YAO_CI_TAI_LOCAL_DOCKER_PORT=12376
|
||||
YAO_CI_TAI_LOCAL_GRPC=127.0.0.1:19103
|
||||
YAO_CI_TAI_LOCAL_DOCKER_API=tcp://127.0.0.1:12376
|
||||
|
||||
# -- tai-docker (TUNNEL mode, explicit Docker API proxy) --
|
||||
# Tunnel: Tai connects to Yao gRPC. Sandbox connects to Yao, traffic forwarded via tunnel.
|
||||
# gRPC port used only for Tai's own listener; Yao accesses via tunnel, not direct dial.
|
||||
YAO_CI_TAI_DOCKER_HOST=127.0.0.1
|
||||
YAO_CI_TAI_DOCKER_GRPC_PORT=19100
|
||||
YAO_CI_TAI_DOCKER_HTTP_PORT=8099
|
||||
YAO_CI_TAI_DOCKER_VNC_PORT=16080
|
||||
YAO_CI_TAI_DOCKER_API_PORT=12375
|
||||
YAO_CI_TAI_DOCKER_API=tcp://127.0.0.1:12375
|
||||
|
||||
# -- tai-k8s (TUNNEL mode, K8s API proxy via k3d) --
|
||||
# K8s proxy on 16444 (not 16443) because k3d --api-port already binds 16443.
|
||||
# TAI_K8S_UPSTREAM points to k3d at 127.0.0.1:16443; proxy exposes on 16444.
|
||||
YAO_CI_TAI_K8S_HOST=127.0.0.1
|
||||
YAO_CI_TAI_K8S_GRPC_PORT=19101
|
||||
YAO_CI_TAI_K8S_HTTP_PORT=8100
|
||||
YAO_CI_TAI_K8S_VNC_PORT=16081
|
||||
YAO_CI_TAI_K8S_API_PORT=16444
|
||||
YAO_CI_K3D_API_PORT=16443
|
||||
|
||||
# -- tai-hostexec (TUNNEL mode, no container runtime, HostExec only) --
|
||||
YAO_CI_TAI_HOSTEXEC_HOST=127.0.0.1
|
||||
YAO_CI_TAI_HOSTEXEC_GRPC_PORT=19102
|
||||
YAO_CI_TAI_HOSTEXEC_HTTP_PORT=8101
|
||||
YAO_CI_TAI_HOSTEXEC_VNC_PORT=16082
|
||||
|
||||
# ========================================
|
||||
# Sandbox V2 addresses (used by test code)
|
||||
# ========================================
|
||||
YAO_CI_SANDBOX_LOCAL_ADDR=tai://127.0.0.1:19103
|
||||
YAO_CI_SANDBOX_DOCKER_ADDR=tai://127.0.0.1:19100
|
||||
YAO_CI_SANDBOX_K8S_ADDR=tai://127.0.0.1:19101
|
||||
YAO_CI_SANDBOX_IMAGE=yaoapp/tai-sandbox-test:latest
|
||||
|
||||
# ========================================
|
||||
# HostExec addresses
|
||||
# ========================================
|
||||
YAO_CI_HOSTEXEC_LOCAL_ADDR=127.0.0.1:19103
|
||||
YAO_CI_HOSTEXEC_DOCKER_ADDR=127.0.0.1:19100
|
||||
YAO_CI_HOSTEXEC_K8S_ADDR=127.0.0.1:19101
|
||||
YAO_CI_HOSTEXEC_ONLY_ADDR=127.0.0.1:19102
|
||||
|
||||
# -- Tunnel --
|
||||
YAO_CI_TUNNEL=true
|
||||
|
||||
# ========================================
|
||||
# Database (MySQL / PostgreSQL / SQLite)
|
||||
# ========================================
|
||||
MYSQL_TEST_HOST=127.0.0.1
|
||||
MYSQL_TEST_PORT=3308
|
||||
MYSQL_TEST_USER=test
|
||||
MYSQL_TEST_PASS=123456
|
||||
|
||||
PG_TEST_HOST=127.0.0.1
|
||||
PG_TEST_PORT=5432
|
||||
PG_TEST_USER=test
|
||||
PG_TEST_PASS=123456
|
||||
|
||||
SQLITE_DB=./app/db/yao.db
|
||||
|
||||
# ========================================
|
||||
# Legacy variable mapping (migrate later)
|
||||
# ========================================
|
||||
TAI_TEST_HOST=127.0.0.1
|
||||
TAI_TEST_DOCKER=tcp://127.0.0.1:12375
|
||||
TAI_TEST_GRPC_PORT=19100
|
||||
TAI_TEST_HTTP_PORT=8099
|
||||
TAI_TEST_VNC_PORT=16080
|
||||
TAI_TEST_DOCKER_PORT=12375
|
||||
TAI_TEST_K8S_HOST=127.0.0.1
|
||||
TAI_TEST_K8S_PORT=16444
|
||||
TAI_TEST_K8S_GRPC_PORT=19101
|
||||
TAI_TEST_K8S_HTTP_PORT=8100
|
||||
TAI_TEST_K8S_VNC_PORT=16081
|
||||
TAI_TEST_HOST_IP=172.17.0.1
|
||||
TAI_TEST_TUNNEL=true
|
||||
TAI_TEST_YAO_URL=http://127.0.0.1:5099
|
||||
TAI_TEST_YAO_GRPC=127.0.0.1:9099
|
||||
SANDBOX_TEST_LOCAL_ADDR=tai://127.0.0.1:19103
|
||||
SANDBOX_TEST_REMOTE_ADDR=tai://127.0.0.1:19100
|
||||
SANDBOX_TEST_K8S_REMOTE_ADDR=tai://127.0.0.1:19101
|
||||
SANDBOX_TEST_HOSTEXEC_ADDR=tai://127.0.0.1:19102
|
||||
SANDBOX_TEST_IMAGE=yaoapp/tai-sandbox-test:latest
|
||||
DOCKER_BRIDGE_IP=172.17.0.1
|
||||
48
.github/workflows/build-linux.yml
vendored
48
.github/workflows/build-linux.yml
vendored
|
|
@ -1,48 +0,0 @@
|
|||
name: Build Linux Artifacts
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tags:
|
||||
description: "Version tags"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: "ubuntu-latest"
|
||||
container:
|
||||
image: yaoapp/yao-build:1.0.0
|
||||
|
||||
env:
|
||||
CF_ACCESS_KEY_ID: ${{ secrets.CF_ACCESS_KEY_ID }}
|
||||
CF_SECRET_ACCESS_KEY: ${{ secrets.CF_SECRET_ACCESS_KEY }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
|
||||
steps:
|
||||
- name: Configure R2 For Cloudflare
|
||||
run: |
|
||||
aws configure set aws_access_key_id $CF_ACCESS_KEY_ID
|
||||
aws configure set aws_secret_access_key $CF_SECRET_ACCESS_KEY
|
||||
aws configure set default.region us-east-1 # Update with your R2 region if different
|
||||
aws configure set default.s3.signature_version s3v4
|
||||
aws configure set default.s3.endpoint_url https://$R2_ACCOUNT_ID.r2.cloudflarestorage.com
|
||||
aws --version
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
export PATH=$PATH:/github/home/go/bin
|
||||
/app/build.sh
|
||||
ls -l /data
|
||||
|
||||
- name: Archive production artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: yao-linux
|
||||
path: |
|
||||
/data/*
|
||||
|
||||
- name: Push To R2 Cloudflare
|
||||
run: |
|
||||
for file in /data/*; do
|
||||
aws s3 cp $file s3://$R2_BUCKET/archives/ --endpoint-url https://$R2_ACCOUNT_ID.r2.cloudflarestorage.com
|
||||
done
|
||||
182
.github/workflows/build-macos.yml
vendored
182
.github/workflows/build-macos.yml
vendored
|
|
@ -1,182 +0,0 @@
|
|||
name: Build MacOS Artifacts
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tags:
|
||||
description: "Version tags"
|
||||
|
||||
env:
|
||||
VERSION: 1.0.0
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
go: ["1.25"]
|
||||
runs-on: "macos-latest"
|
||||
steps:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm
|
||||
|
||||
- name: Setup Cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Checkout Kun
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: yaoapp/kun
|
||||
path: kun
|
||||
|
||||
- name: Checkout Xun
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: yaoapp/xun
|
||||
path: xun
|
||||
|
||||
- name: Checkout Gou
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: yaoapp/gou
|
||||
path: gou
|
||||
|
||||
- name: Checkout V8Go
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: yaoapp/v8go
|
||||
path: v8go
|
||||
|
||||
- name: Unzip libv8
|
||||
run: |
|
||||
files=$(find ./v8go -name "libv8*.zip")
|
||||
for file in $files; do
|
||||
dir=$(dirname "$file") # Get the directory where the ZIP file is located
|
||||
echo "Extracting $file to directory $dir"
|
||||
unzip -o -d $dir $file
|
||||
rm -rf $dir/__MACOSX
|
||||
done
|
||||
|
||||
- name: Checkout CUI v1.0
|
||||
# ** XGEN will be renamed to CUI in the feature. and move to the new repository. **
|
||||
# ** new repository: https://github.com/YaoApp/cui.git **
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: yaoapp/cui
|
||||
path: cui-v1.0
|
||||
|
||||
- name: Checkout Yao-Init
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: yaoapp/yao-init
|
||||
path: yao-init
|
||||
|
||||
- name: Move Kun, Xun, Gou, UI, V8Go
|
||||
run: |
|
||||
mv kun ../
|
||||
mv xun ../
|
||||
mv gou ../
|
||||
mv v8go ../
|
||||
mv cui-v1.0 ../
|
||||
mv yao-init ../
|
||||
rm -f ../cui-v1.0/packages/setup/vite.config.ts.*
|
||||
ls -l .
|
||||
ls -l ../
|
||||
ls -l ../cui-v1.0/packages/setup/
|
||||
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go ${{ matrix.go }}
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ matrix.go }}
|
||||
|
||||
- name: Setup Go Tools
|
||||
run: |
|
||||
make tools
|
||||
|
||||
- name: Get Version
|
||||
run: |
|
||||
echo VERSION=$(cat share/const.go |grep 'const VERSION' | awk '{print $4}' | sed "s/\"//g") >> $GITHUB_ENV
|
||||
|
||||
- name: Make Artifacts MacOS
|
||||
run: |
|
||||
make artifacts-macos
|
||||
|
||||
- name: Install Certificates
|
||||
env:
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
|
||||
run: |
|
||||
mkdir -p certs
|
||||
echo "${{ secrets.APPLE_DEVELOPERIDG2CA }}" | base64 --decode > certs/DeveloperIDG2CA.cer
|
||||
echo "${{ secrets.APPLE_DISTRIBUTION }}" | base64 --decode > certs/distribution.cer
|
||||
echo "${{ secrets.APPLE_PRIVATE_KEY }}" | base64 --decode > certs/private_key.p12
|
||||
security verify-cert -c certs/DeveloperIDG2CA.cer
|
||||
security verify-cert -c certs/distribution.cer
|
||||
|
||||
- name: Import Certificates
|
||||
run: |
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
|
||||
# create temporary keychain
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
|
||||
# import certificate to keychain
|
||||
security import ./certs/DeveloperIDG2CA.cer -k $KEYCHAIN_PATH -T /usr/bin/codesign
|
||||
security import ./certs/distribution.cer -k $KEYCHAIN_PATH -T /usr/bin/codesign
|
||||
|
||||
# import private key to keychain
|
||||
security import ./certs/private_key.p12 -k $KEYCHAIN_PATH -P "${{ secrets.APPLE_PRIVATE_KEY_PASSWORD }}" -T /usr/bin/codesign
|
||||
|
||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||
|
||||
- name: Sign Artifacts
|
||||
run: |
|
||||
codesign --deep --force --verbose --timestamp --sign "Developer ID Application: ${{ secrets.APPLE_SIGN }}" dist/release/yao-$VERSION-darwin-arm64
|
||||
codesign --deep --force --verbose --timestamp --sign "Developer ID Application: ${{ secrets.APPLE_SIGN }}" dist/release/yao-$VERSION-darwin-amd64
|
||||
codesign --deep --force --verbose --timestamp --sign "Developer ID Application: ${{ secrets.APPLE_SIGN }}" dist/release/yao-$VERSION-darwin-arm64-prod
|
||||
codesign --deep --force --verbose --timestamp --sign "Developer ID Application: ${{ secrets.APPLE_SIGN }}" dist/release/yao-$VERSION-darwin-amd64-prod
|
||||
|
||||
- name: Verify Signature
|
||||
run: |
|
||||
codesign --verify --deep --strict --verbose=2 dist/release/yao-$VERSION-darwin-arm64
|
||||
codesign --verify --deep --strict --verbose=2 dist/release/yao-$VERSION-darwin-amd64
|
||||
codesign --verify --deep --strict --verbose=2 dist/release/yao-$VERSION-darwin-arm64-prod
|
||||
codesign --verify --deep --strict --verbose=2 dist/release/yao-$VERSION-darwin-amd64-prod
|
||||
|
||||
- name: Send to Apple Notary Service
|
||||
run: |
|
||||
zip -r dist/release/yao-$VERSION-darwin-arm64.zip dist/release/yao-$VERSION-darwin-arm64
|
||||
zip -r dist/release/yao-$VERSION-darwin-amd64.zip dist/release/yao-$VERSION-darwin-amd64
|
||||
zip -r dist/release/yao-$VERSION-darwin-arm64-prod.zip dist/release/yao-$VERSION-darwin-arm64-prod
|
||||
zip -r dist/release/yao-$VERSION-darwin-amd64-prod.zip dist/release/yao-$VERSION-darwin-amd64-prod
|
||||
xcrun notarytool submit dist/release/yao-$VERSION-darwin-arm64.zip --apple-id "${{ secrets.APPLE_ID }}" --team-id "${{ secrets.APPLE_TEAME_ID }}" --password "${{ secrets.APPLE_APP_SPEC_PASS }}" --output-format json
|
||||
xcrun notarytool submit dist/release/yao-$VERSION-darwin-amd64.zip --apple-id "${{ secrets.APPLE_ID }}" --team-id "${{ secrets.APPLE_TEAME_ID }}" --password "${{ secrets.APPLE_APP_SPEC_PASS }}" --output-format json
|
||||
xcrun notarytool submit dist/release/yao-$VERSION-darwin-arm64-prod.zip --apple-id "${{ secrets.APPLE_ID }}" --team-id "${{ secrets.APPLE_TEAME_ID }}" --password "${{ secrets.APPLE_APP_SPEC_PASS }}" --output-format json
|
||||
xcrun notarytool submit dist/release/yao-$VERSION-darwin-amd64-prod.zip --apple-id "${{ secrets.APPLE_ID }}" --team-id "${{ secrets.APPLE_TEAME_ID }}" --password "${{ secrets.APPLE_APP_SPEC_PASS }}" --output-format json
|
||||
rm -f dist/release/yao-$VERSION-darwin-arm64.zip
|
||||
rm -f dist/release/yao-$VERSION-darwin-amd64.zip
|
||||
rm -f dist/release/yao-$VERSION-darwin-arm64-prod.zip
|
||||
rm -f dist/release/yao-$VERSION-darwin-amd64-prod.zip
|
||||
|
||||
- name: Archive production artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: yao-macos
|
||||
path: |
|
||||
dist/release/*
|
||||
25
.github/workflows/create-release.yml
vendored
25
.github/workflows/create-release.yml
vendored
|
|
@ -1,25 +0,0 @@
|
|||
name: Create Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
create:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create Draft Release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
VERSION="${TAG#v}"
|
||||
gh release create "$TAG" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--title "Yao v${VERSION}" \
|
||||
--generate-notes \
|
||||
--draft
|
||||
|
|
@ -6,19 +6,19 @@ on:
|
|||
# paths:
|
||||
# - ".github/workflows/docker.yml"
|
||||
workflow_run:
|
||||
workflows: ["Build Linux Artifacts"]
|
||||
workflows: ["Release Linux"]
|
||||
types:
|
||||
- completed
|
||||
|
||||
env:
|
||||
VERSION: 0.10.5
|
||||
VERSION: 0.9.0
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Get Version
|
||||
run: |
|
||||
|
|
@ -28,21 +28,19 @@ jobs:
|
|||
run: echo $VERSION
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build Development
|
||||
uses: docker/build-push-action@v6
|
||||
env:
|
||||
DOCKER_CONTENT_TRUST: 1
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: ./docker/development
|
||||
platforms: linux/amd64
|
||||
|
|
@ -53,9 +51,7 @@ jobs:
|
|||
tags: yaoapp/yao:${{ env.VERSION }}-amd64-dev
|
||||
|
||||
- name: Build Development Arm64
|
||||
uses: docker/build-push-action@v6
|
||||
env:
|
||||
DOCKER_CONTENT_TRUST: 1
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: ./docker/development
|
||||
platforms: linux/arm64
|
||||
|
|
@ -66,9 +62,7 @@ jobs:
|
|||
tags: yaoapp/yao:${{ env.VERSION }}-arm64-dev
|
||||
|
||||
- name: Build Production
|
||||
uses: docker/build-push-action@v6
|
||||
env:
|
||||
DOCKER_CONTENT_TRUST: 1
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: ./docker/production
|
||||
platforms: linux/amd64
|
||||
|
|
@ -79,9 +73,7 @@ jobs:
|
|||
tags: yaoapp/yao:${{ env.VERSION }}-amd64
|
||||
|
||||
- name: Build Production Arm64
|
||||
uses: docker/build-push-action@v6
|
||||
env:
|
||||
DOCKER_CONTENT_TRUST: 1
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: ./docker/production
|
||||
platforms: linux/arm64
|
||||
|
|
@ -90,29 +82,3 @@ jobs:
|
|||
ARCH=arm64
|
||||
push: true
|
||||
tags: yaoapp/yao:${{ env.VERSION }}-arm64
|
||||
|
||||
- name: Build Production Slim
|
||||
uses: docker/build-push-action@v6
|
||||
env:
|
||||
DOCKER_CONTENT_TRUST: 1
|
||||
with:
|
||||
context: ./docker/production-slim
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
VERSION=${{ env.VERSION }}
|
||||
ARCH=amd64
|
||||
push: true
|
||||
tags: yaoapp/yao:${{ env.VERSION }}-amd64-slim
|
||||
|
||||
- name: Build Production Slim Arm64
|
||||
uses: docker/build-push-action@v6
|
||||
env:
|
||||
DOCKER_CONTENT_TRUST: 1
|
||||
with:
|
||||
context: ./docker/production-slim
|
||||
platforms: linux/arm64
|
||||
build-args: |
|
||||
VERSION=${{ env.VERSION }}
|
||||
ARCH=arm64
|
||||
push: true
|
||||
tags: yaoapp/yao:${{ env.VERSION }}-arm64-slim
|
||||
211
.github/workflows/notarize-macos.yml
vendored
211
.github/workflows/notarize-macos.yml
vendored
|
|
@ -1,211 +0,0 @@
|
|||
name: Notarize macOS
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Release macOS"]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version (auto-detected from latest release if empty)"
|
||||
required: false
|
||||
run_id:
|
||||
description: "Release macOS workflow run ID (auto-detected if empty)"
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
|
||||
concurrency:
|
||||
group: notarize-${{ github.event.workflow_run.head_branch || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ===================================================================
|
||||
# Resolve version + macOS build run_id automatically
|
||||
# ===================================================================
|
||||
resolve:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event.workflow_run.conclusion == 'success' &&
|
||||
startsWith(github.event.workflow_run.head_branch, 'v'))
|
||||
outputs:
|
||||
version: ${{ steps.resolve.outputs.version }}
|
||||
run_id: ${{ steps.resolve.outputs.run_id }}
|
||||
steps:
|
||||
- name: Resolve version and run_id
|
||||
id: resolve
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
RUN_ID="${{ github.event.inputs.run_id }}"
|
||||
if [ -z "$VERSION" ]; then
|
||||
TAG=$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName -q '.tagName')
|
||||
VERSION="${TAG#v}"
|
||||
fi
|
||||
if [ -z "$RUN_ID" ]; then
|
||||
RUN_ID=$(gh run list --repo "$GITHUB_REPOSITORY" \
|
||||
--workflow="Release macOS" --branch="v${VERSION}" --limit=1 \
|
||||
--json databaseId,conclusion --jq '.[] | select(.conclusion=="success") | .databaseId')
|
||||
fi
|
||||
else
|
||||
TAG="${{ github.event.workflow_run.head_branch }}"
|
||||
VERSION="${TAG#v}"
|
||||
RUN_ID="${{ github.event.workflow_run.id }}"
|
||||
fi
|
||||
|
||||
if [ -z "$VERSION" ] || [ -z "$RUN_ID" ]; then
|
||||
echo "::error::Failed to resolve version='${VERSION}' run_id='${RUN_ID}'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "run_id=${RUN_ID}" >> $GITHUB_OUTPUT
|
||||
echo "Resolved: version=${VERSION} run_id=${RUN_ID}"
|
||||
|
||||
# ===================================================================
|
||||
# Notarize Yao binaries (arm64 + amd64)
|
||||
# ===================================================================
|
||||
notarize:
|
||||
needs: resolve
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
matrix:
|
||||
arch: [arm64, amd64]
|
||||
steps:
|
||||
- name: Download Yao Binary
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: yao-darwin-${{ matrix.arch }}
|
||||
path: bin
|
||||
run-id: ${{ needs.resolve.outputs.run_id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install Certificates
|
||||
env:
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
mkdir -p certs
|
||||
echo "${{ secrets.APPLE_DEVELOPERIDG2CA }}" | base64 --decode > certs/DeveloperIDG2CA.cer
|
||||
echo "${{ secrets.APPLE_DISTRIBUTION }}" | base64 --decode > certs/distribution.cer
|
||||
echo "${{ secrets.APPLE_PRIVATE_KEY }}" | base64 --decode > certs/private_key.p12
|
||||
security verify-cert -c certs/DeveloperIDG2CA.cer
|
||||
security verify-cert -c certs/distribution.cer
|
||||
|
||||
- name: Import Certificates
|
||||
env:
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security import ./certs/DeveloperIDG2CA.cer -k $KEYCHAIN_PATH -T /usr/bin/codesign
|
||||
security import ./certs/distribution.cer -k $KEYCHAIN_PATH -T /usr/bin/codesign
|
||||
security import ./certs/private_key.p12 -k $KEYCHAIN_PATH -P "${{ secrets.APPLE_PRIVATE_KEY_PASSWORD }}" -T /usr/bin/codesign
|
||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||
|
||||
- name: Verify Signature
|
||||
run: codesign --verify --deep --strict --verbose=2 bin/yao
|
||||
|
||||
- name: Notarize Yao ${{ matrix.arch }}
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_TEAME_ID: ${{ secrets.APPLE_TEAME_ID }}
|
||||
APPLE_APP_SPEC_PASS: ${{ secrets.APPLE_APP_SPEC_PASS }}
|
||||
run: |
|
||||
zip -j bin/yao.zip bin/yao
|
||||
|
||||
SUBMIT_OUT=$(xcrun notarytool submit bin/yao.zip \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--team-id "$APPLE_TEAME_ID" \
|
||||
--password "$APPLE_APP_SPEC_PASS" \
|
||||
--wait --timeout 10m --output-format json 2>&1) || true
|
||||
echo "$SUBMIT_OUT"
|
||||
|
||||
STATUS=$(echo "$SUBMIT_OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status',''))" 2>/dev/null || true)
|
||||
SUB_ID=$(echo "$SUBMIT_OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
|
||||
|
||||
if [ "$STATUS" != "Accepted" ]; then
|
||||
echo "::error::Yao ${{ matrix.arch }} notarization failed (status: $STATUS)"
|
||||
[ -n "$SUB_ID" ] && xcrun notarytool log "$SUB_ID" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--team-id "$APPLE_TEAME_ID" \
|
||||
--password "$APPLE_APP_SPEC_PASS" || true
|
||||
exit 1
|
||||
fi
|
||||
echo "Yao ${{ matrix.arch }} notarization accepted."
|
||||
|
||||
# ===================================================================
|
||||
# After both architectures finish: wait for Linux R2, then trigger CDN
|
||||
# ===================================================================
|
||||
finalize:
|
||||
needs: [resolve, notarize]
|
||||
runs-on: ubuntu-latest
|
||||
if: success()
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINTS: ${{ secrets.R2_ENDPOINTS }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET || 'releases' }}
|
||||
steps:
|
||||
- name: Checkout (for gh CLI context)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: .github
|
||||
|
||||
- name: Configure AWS CLI
|
||||
run: |
|
||||
aws configure set default.region us-east-1
|
||||
aws configure set default.s3.signature_version s3v4
|
||||
|
||||
- name: Wait for all platform assets on R2
|
||||
run: |
|
||||
VERSION="${{ needs.resolve.outputs.version }}"
|
||||
PREFIX="yao/${VERSION}"
|
||||
|
||||
PLATFORMS=(
|
||||
"darwin-arm64"
|
||||
"darwin-amd64"
|
||||
"linux-amd64"
|
||||
"linux-arm64"
|
||||
)
|
||||
|
||||
for ATTEMPT in $(seq 1 30); do
|
||||
MISSING=0
|
||||
for P in "${PLATFORMS[@]}"; do
|
||||
KEY="${PREFIX}/yao-${VERSION}-${P}"
|
||||
if ! aws s3 ls "s3://${R2_BUCKET}/${KEY}" --endpoint-url "$R2_ENDPOINTS" >/dev/null 2>&1; then
|
||||
MISSING=$((MISSING+1))
|
||||
fi
|
||||
if ! aws s3 ls "s3://${R2_BUCKET}/${KEY}.sha256" --endpoint-url "$R2_ENDPOINTS" >/dev/null 2>&1; then
|
||||
MISSING=$((MISSING+1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$MISSING" -eq 0 ]; then
|
||||
echo "All 4 platform assets verified on R2."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Attempt $ATTEMPT: $MISSING asset(s) still missing, waiting 30s..."
|
||||
sleep 30
|
||||
done
|
||||
|
||||
echo "::error::Timed out waiting for all platform assets on R2."
|
||||
exit 1
|
||||
|
||||
- name: Trigger CDN latest.json update
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VERSION="${{ needs.resolve.outputs.version }}"
|
||||
gh workflow run update-cdn-latest.yml \
|
||||
-f version="${VERSION}" \
|
||||
-f mark_latest="true"
|
||||
echo "Triggered update-cdn-latest.yml for ${VERSION}"
|
||||
5
.github/workflows/pr-receive.yml
vendored
5
.github/workflows/pr-receive.yml
vendored
|
|
@ -10,15 +10,14 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Save PR number
|
||||
run: |
|
||||
mkdir -p ./pr
|
||||
echo ${{ github.event.number }} > ./pr/NR
|
||||
echo ${{ github.event.pull_request.head.sha }} > ./pr/SHA
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: pr
|
||||
path: pr/
|
||||
|
|
|
|||
1875
.github/workflows/pr-test.yml
vendored
1875
.github/workflows/pr-test.yml
vendored
File diff suppressed because it is too large
Load diff
325
.github/workflows/release-linux.yml
vendored
325
.github/workflows/release-linux.yml
vendored
|
|
@ -1,245 +1,122 @@
|
|||
name: Release Linux
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tags:
|
||||
description: "Version tags"
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
IMAGE_NAME: yaoapp/yao
|
||||
branches: [main]
|
||||
paths:
|
||||
- "share/const.go"
|
||||
|
||||
jobs:
|
||||
# ===================================================================
|
||||
# Build Linux Binaries (amd64 + arm64)
|
||||
# ===================================================================
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: yaoapp/yao-build:1.0.0
|
||||
steps:
|
||||
- name: Build
|
||||
run: |
|
||||
export PATH=$PATH:/github/home/go/bin
|
||||
|
||||
# Clone dependencies
|
||||
cd /app
|
||||
git clone https://github.com/yaoapp/kun.git /app/kun
|
||||
git clone https://github.com/yaoapp/xun.git /app/xun
|
||||
git clone https://github.com/yaoapp/gou.git /app/gou
|
||||
git clone https://github.com/yaoapp/v8go.git /app/v8go
|
||||
git clone https://github.com/yaoapp/cui.git /app/cui-v1.0
|
||||
git clone https://github.com/yaoapp/yao-init.git /app/yao-init
|
||||
git clone https://github.com/yaoapp/yao.git /app/yao
|
||||
|
||||
# Extract libv8
|
||||
files=$(find /app/v8go -name "libv8*.zip")
|
||||
for file in $files; do
|
||||
dir=$(dirname "$file")
|
||||
echo "Extracting $file to directory $dir"
|
||||
unzip -o -d $dir $file
|
||||
rm -rf $dir/__MACOSX
|
||||
done
|
||||
|
||||
# Set VERSION from git tag (required)
|
||||
cd /app/yao
|
||||
if [[ "$GITHUB_REF" != refs/tags/v* ]]; then
|
||||
echo "::error::This workflow requires a tag (refs/tags/v*). Got: $GITHUB_REF"
|
||||
exit 1
|
||||
fi
|
||||
TAG_VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
echo "Setting VERSION to $TAG_VERSION"
|
||||
sed -i "s/const VERSION = \".*\"/const VERSION = \"${TAG_VERSION}\"/g" share/const.go
|
||||
grep 'const VERSION' share/const.go
|
||||
|
||||
make tools && make artifacts-linux
|
||||
mv /app/yao/dist/release/* /data/
|
||||
ls -l /data
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: yao-linux
|
||||
path: /data/*
|
||||
|
||||
# ===================================================================
|
||||
# Docker Images (multi-arch manifest: linux/amd64 + linux/arm64)
|
||||
# ===================================================================
|
||||
docker:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get Version
|
||||
id: version
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" != refs/tags/v* ]]; then
|
||||
echo "::error::This workflow requires a tag (refs/tags/v*). Got: $GITHUB_REF"
|
||||
exit 1
|
||||
fi
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "VERSION=${VERSION}"
|
||||
|
||||
- name: Download Linux Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: yao-linux
|
||||
path: artifacts
|
||||
|
||||
- name: Prepare Docker Contexts
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
ls -la artifacts/
|
||||
|
||||
# Development image uses dev (unstripped) binaries
|
||||
cp "artifacts/yao-${VERSION}-linux-amd64" docker/development/yao-amd64
|
||||
cp "artifacts/yao-${VERSION}-linux-arm64" docker/development/yao-arm64
|
||||
chmod +x docker/development/yao-*
|
||||
|
||||
# Production image uses prod (stripped) binaries
|
||||
cp "artifacts/yao-${VERSION}-linux-amd64-prod" docker/production/yao-amd64
|
||||
cp "artifacts/yao-${VERSION}-linux-arm64-prod" docker/production/yao-arm64
|
||||
chmod +x docker/production/yao-*
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Build & Push Development (multi-arch)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./docker/development
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-dev
|
||||
${{ env.IMAGE_NAME }}:dev
|
||||
|
||||
- name: Build & Push Production (multi-arch)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./docker/production
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}
|
||||
${{ env.IMAGE_NAME }}:latest
|
||||
|
||||
# ===================================================================
|
||||
# GitHub Release + R2 Upload (Linux binaries)
|
||||
# ===================================================================
|
||||
release:
|
||||
needs: build
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
go: [1.18.2]
|
||||
runs-on: "ubuntu-latest"
|
||||
steps:
|
||||
- name: Get Version
|
||||
id: version
|
||||
- name: Arm Build
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" != refs/tags/v* ]]; then
|
||||
echo "::error::This workflow requires a tag. Got: $GITHUB_REF"
|
||||
exit 1
|
||||
fi
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libc6-armel-cross libc6-dev-armel-cross binutils-arm-linux-gnueabi libncurses5-dev build-essential bison flex libssl-dev bc
|
||||
sudo apt-get install -y gcc-arm-linux-gnueabi g++-arm-linux-gnueabi
|
||||
sudo apt-get install -y gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf
|
||||
sudo apt-get install -y g++-aarch64-linux-gnu crossbuild-essential-arm64
|
||||
|
||||
- name: Download Linux Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
- name: Install coscmd
|
||||
run: sudo pip3 install coscmd
|
||||
|
||||
- name: Setup Cache
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
name: yao-linux
|
||||
path: artifacts
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Prepare Release Files
|
||||
- name: Checkout Kun
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
repository: yaoapp/kun
|
||||
path: kun
|
||||
|
||||
- name: Checkout Xun
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
repository: yaoapp/xun
|
||||
path: xun
|
||||
|
||||
- name: Checkout Gou
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
repository: yaoapp/gou
|
||||
path: gou
|
||||
|
||||
- name: Checkout V8Go
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
repository: rogchap/v8go
|
||||
ref: 5e91d3d9dcabd2986f901b6b31590e49fc3c4dd8
|
||||
path: v8go
|
||||
|
||||
- name: Checkout UI
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
repository: yaoapp/xgen
|
||||
path: ui
|
||||
|
||||
- name: Move Kun, Xun, Gou, UI, V8Go
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
mkdir -p release
|
||||
cp "artifacts/yao-${VERSION}-linux-amd64-prod" "release/yao-${VERSION}-linux-amd64"
|
||||
cp "artifacts/yao-${VERSION}-linux-arm64-prod" "release/yao-${VERSION}-linux-arm64"
|
||||
cp "artifacts/yao-${VERSION}-linux-amd64" "release/yao-${VERSION}-linux-amd64-dev"
|
||||
cp "artifacts/yao-${VERSION}-linux-arm64" "release/yao-${VERSION}-linux-arm64-dev"
|
||||
chmod +x release/yao-*
|
||||
mv kun ../
|
||||
mv xun ../
|
||||
mv gou ../
|
||||
mv v8go ../
|
||||
mv ui ../
|
||||
ls -l .
|
||||
ls -l ../
|
||||
|
||||
for ARCH in amd64 arm64; do
|
||||
sha256sum "release/yao-${VERSION}-linux-${ARCH}" | awk '{print $1}' > "release/yao-linux-${ARCH}-prod.sha256"
|
||||
sha256sum "release/yao-${VERSION}-linux-${ARCH}-dev" | awk '{print $1}' > "release/yao-linux-${ARCH}-dev.sha256"
|
||||
done
|
||||
ls -lh release/
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Wait for Draft Release
|
||||
- name: Setup Go ${{ matrix.go }}
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: ${{ matrix.go }}
|
||||
|
||||
- name: Setup Go Tools
|
||||
run: |
|
||||
make tools
|
||||
|
||||
- name: Make Artifacts Linux
|
||||
run: |
|
||||
make artifacts-linux
|
||||
|
||||
- name: Configure COS For Silicon Valley
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
SECRET_ID: ${{ secrets.COS_ID }}
|
||||
SECRET_KEY: ${{ secrets.COS_KEY }}
|
||||
BUCKET: release-sv-1252011659
|
||||
REGION: na-siliconvalley
|
||||
run: |
|
||||
TAG="${{ steps.version.outputs.tag }}"
|
||||
for i in $(seq 1 30); do
|
||||
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" &>/dev/null; then
|
||||
echo "Draft release found for $TAG."
|
||||
exit 0
|
||||
fi
|
||||
echo "Waiting for draft release... ($i/30)"
|
||||
sleep 10
|
||||
done
|
||||
echo "::error::Timed out waiting for draft release $TAG"
|
||||
exit 1
|
||||
coscmd config -a $SECRET_ID -s $SECRET_KEY -b $BUCKET -r $REGION
|
||||
|
||||
- name: Upload Assets to GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
- name: Push To Silicon Valley
|
||||
run: |
|
||||
TAG="${{ steps.version.outputs.tag }}"
|
||||
gh release upload "$TAG" release/* --repo "$GITHUB_REPOSITORY" --clobber
|
||||
for file in ./dist/release/*; do coscmd upload $file /archives/; done;
|
||||
|
||||
- name: Publish Release if Complete
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
TAG="${{ steps.version.outputs.tag }}"
|
||||
ASSET_COUNT=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets | length')
|
||||
echo "Current assets: $ASSET_COUNT / 16"
|
||||
if [ "$ASSET_COUNT" -ge 16 ]; then
|
||||
echo "All assets present, publishing release..."
|
||||
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false --latest
|
||||
else
|
||||
echo "Assets incomplete ($ASSET_COUNT/16), waiting for other workflow to publish."
|
||||
fi
|
||||
# - name: Configure COS For Beijing
|
||||
# env:
|
||||
# SECRET_ID: ${{ secrets.COS_ID }}
|
||||
# SECRET_KEY: ${{ secrets.COS_KEY }}
|
||||
# BUCKET: release-bj-1252011659
|
||||
# REGION: ap-beijing
|
||||
# run: |
|
||||
# coscmd config -a $SECRET_ID -s $SECRET_KEY -b $BUCKET -r $REGION
|
||||
|
||||
- name: Upload Linux binaries to R2
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINTS: ${{ secrets.R2_ENDPOINTS }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET || 'releases' }}
|
||||
run: |
|
||||
aws configure set default.region us-east-1
|
||||
aws configure set default.s3.signature_version s3v4
|
||||
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
PREFIX="yao/${VERSION}"
|
||||
|
||||
for PLATFORM in linux-amd64 linux-arm64; do
|
||||
FILE="release/yao-${VERSION}-${PLATFORM}"
|
||||
NAME="yao-${VERSION}-${PLATFORM}"
|
||||
sha256sum "$FILE" | awk '{print $1}' > "/tmp/${NAME}.sha256"
|
||||
|
||||
aws s3 cp "$FILE" "s3://${R2_BUCKET}/${PREFIX}/${NAME}" \
|
||||
--endpoint-url "$R2_ENDPOINTS" \
|
||||
--content-type "application/octet-stream"
|
||||
aws s3 cp "/tmp/${NAME}.sha256" "s3://${R2_BUCKET}/${PREFIX}/${NAME}.sha256" \
|
||||
--endpoint-url "$R2_ENDPOINTS" \
|
||||
--content-type "text/plain"
|
||||
echo "Uploaded: ${NAME} + ${NAME}.sha256"
|
||||
done
|
||||
# - name: Push To Beijing
|
||||
# run: |
|
||||
# for file in ./dist/release/*; do coscmd upload $file /archives/; done;
|
||||
|
|
|
|||
346
.github/workflows/release-macos.yml
vendored
346
.github/workflows/release-macos.yml
vendored
|
|
@ -1,30 +1,27 @@
|
|||
name: Release macOS
|
||||
name: Release MacOS
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tags:
|
||||
description: "Version tags"
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
branches: [main]
|
||||
paths:
|
||||
- "share/const.go"
|
||||
|
||||
jobs:
|
||||
# ===================================================================
|
||||
# Build Yao macOS binaries (arm64 + amd64) — one job, both arches
|
||||
# ===================================================================
|
||||
build:
|
||||
runs-on: macos-latest
|
||||
release:
|
||||
strategy:
|
||||
matrix:
|
||||
go: [1.18.2]
|
||||
runs-on: "macos-11"
|
||||
steps:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm
|
||||
- name: Install coscmd
|
||||
run: sudo pip3 install coscmd
|
||||
|
||||
- name: Setup Cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
|
|
@ -34,311 +31,84 @@ jobs:
|
|||
${{ runner.os }}-go-
|
||||
|
||||
- name: Checkout Kun
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
repository: yaoapp/kun
|
||||
path: kun
|
||||
|
||||
- name: Checkout Xun
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
repository: yaoapp/xun
|
||||
path: xun
|
||||
|
||||
- name: Checkout Gou
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
repository: yaoapp/gou
|
||||
path: gou
|
||||
|
||||
- name: Checkout V8Go
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
repository: yaoapp/v8go
|
||||
repository: rogchap/v8go
|
||||
ref: 5e91d3d9dcabd2986f901b6b31590e49fc3c4dd8
|
||||
path: v8go
|
||||
|
||||
- name: Unzip libv8
|
||||
run: |
|
||||
files=$(find ./v8go -name "libv8*.zip")
|
||||
for file in $files; do
|
||||
dir=$(dirname "$file")
|
||||
echo "Extracting $file to directory $dir"
|
||||
unzip -o -d $dir $file
|
||||
rm -rf $dir/__MACOSX
|
||||
done
|
||||
|
||||
- name: Checkout CUI v1.0
|
||||
uses: actions/checkout@v4
|
||||
- name: Checkout UI
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
repository: yaoapp/cui
|
||||
path: cui-v1.0
|
||||
repository: yaoapp/xgen
|
||||
path: ui
|
||||
|
||||
- name: Checkout Yao-Init
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: yaoapp/yao-init
|
||||
path: yao-init
|
||||
|
||||
- name: Move Dependencies
|
||||
- name: Move Kun, Xun, Gou, UI, V8Go
|
||||
run: |
|
||||
mv kun ../
|
||||
mv xun ../
|
||||
mv gou ../
|
||||
mv v8go ../
|
||||
mv cui-v1.0 ../
|
||||
mv yao-init ../
|
||||
rm -f ../cui-v1.0/packages/setup/vite.config.ts.*
|
||||
mv ui ../
|
||||
ls -l .
|
||||
ls -l ../
|
||||
|
||||
- name: Checkout Yao
|
||||
uses: actions/checkout@v4
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Set Version from Tag
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" != refs/tags/v* ]]; then
|
||||
echo "::error::This workflow requires a tag (refs/tags/v*). Got: $GITHUB_REF"
|
||||
exit 1
|
||||
fi
|
||||
TAG="${GITHUB_REF#refs/tags/v}"
|
||||
echo "Setting VERSION to $TAG"
|
||||
sed -i.bak "s/const VERSION = \".*\"/const VERSION = \"${TAG}\"/g" share/const.go
|
||||
rm -f share/const.go.bak
|
||||
grep 'const VERSION' share/const.go
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
- name: Setup Go ${{ matrix.go }}
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "1.25"
|
||||
go-version: ${{ matrix.go }}
|
||||
|
||||
- name: Setup Go Tools
|
||||
run: make tools
|
||||
|
||||
- name: Make Artifacts macOS
|
||||
run: make artifacts-macos
|
||||
|
||||
- name: Get Version
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(grep 'const VERSION =' share/const.go | awk '{print $4}' | sed 's/"//g')
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
make tools
|
||||
|
||||
- name: List Build Output
|
||||
run: ls -lh dist/release/
|
||||
- name: Make Artifacts MacOS
|
||||
run: |
|
||||
make artifacts-macos
|
||||
|
||||
- name: Install Certificates
|
||||
- name: Configure COS For Silicon Valley
|
||||
env:
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
SECRET_ID: ${{ secrets.COS_ID }}
|
||||
SECRET_KEY: ${{ secrets.COS_KEY }}
|
||||
BUCKET: release-sv-1252011659
|
||||
REGION: na-siliconvalley
|
||||
run: |
|
||||
mkdir -p certs
|
||||
echo "${{ secrets.APPLE_DEVELOPERIDG2CA }}" | base64 --decode > certs/DeveloperIDG2CA.cer
|
||||
echo "${{ secrets.APPLE_DISTRIBUTION }}" | base64 --decode > certs/distribution.cer
|
||||
echo "${{ secrets.APPLE_PRIVATE_KEY }}" | base64 --decode > certs/private_key.p12
|
||||
security verify-cert -c certs/DeveloperIDG2CA.cer
|
||||
security verify-cert -c certs/distribution.cer
|
||||
coscmd config -a $SECRET_ID -s $SECRET_KEY -b $BUCKET -r $REGION
|
||||
|
||||
- name: Import Certificates
|
||||
env:
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
- name: Push To Silicon Valley
|
||||
run: |
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security import ./certs/DeveloperIDG2CA.cer -k $KEYCHAIN_PATH -T /usr/bin/codesign
|
||||
security import ./certs/distribution.cer -k $KEYCHAIN_PATH -T /usr/bin/codesign
|
||||
security import ./certs/private_key.p12 -k $KEYCHAIN_PATH -P "${{ secrets.APPLE_PRIVATE_KEY_PASSWORD }}" -T /usr/bin/codesign
|
||||
security list-keychain -d user -s $KEYCHAIN_PATH
|
||||
for file in ./dist/release/*; do coscmd upload $file /archives/; done;
|
||||
|
||||
- name: Sign Yao Binaries
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
IDENTITY="Developer ID Application: ${{ secrets.APPLE_SIGN }}"
|
||||
for ARCH in arm64 amd64; do
|
||||
for SUFFIX in "" "-prod"; do
|
||||
BIN="dist/release/yao-${VERSION}-darwin-${ARCH}${SUFFIX}"
|
||||
codesign --force --verbose --timestamp --options runtime \
|
||||
--entitlements .github/codesign/entitlements.plist \
|
||||
--sign "$IDENTITY" "$BIN"
|
||||
codesign --verify --deep --strict --verbose=2 "$BIN"
|
||||
done
|
||||
done
|
||||
# - name: Configure COS For Beijing
|
||||
# env:
|
||||
# SECRET_ID: ${{ secrets.COS_ID }}
|
||||
# SECRET_KEY: ${{ secrets.COS_KEY }}
|
||||
# BUCKET: release-bj-1252011659
|
||||
# REGION: ap-beijing
|
||||
# run: |
|
||||
# coscmd config -a $SECRET_ID -s $SECRET_KEY -b $BUCKET -r $REGION
|
||||
|
||||
- name: Prepare Output and Checksums
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
for ARCH in arm64 amd64; do
|
||||
for VARIANT in dev prod; do
|
||||
if [ "$VARIANT" = "dev" ]; then
|
||||
SRC="dist/release/yao-${VERSION}-darwin-${ARCH}"
|
||||
else
|
||||
SRC="dist/release/yao-${VERSION}-darwin-${ARCH}-prod"
|
||||
fi
|
||||
DIR="/tmp/yao-output-${ARCH}-${VARIANT}"
|
||||
mkdir -p "$DIR"
|
||||
cp "$SRC" "$DIR/yao"
|
||||
chmod +x "$DIR/yao"
|
||||
done
|
||||
done
|
||||
|
||||
mkdir -p /tmp/checksums
|
||||
for ARCH in arm64 amd64; do
|
||||
for VARIANT in dev prod; do
|
||||
shasum -a 256 "/tmp/yao-output-${ARCH}-${VARIANT}/yao" | awk '{print $1" yao"}' > "/tmp/checksums/yao-darwin-${ARCH}-${VARIANT}.sha256"
|
||||
done
|
||||
done
|
||||
cat /tmp/checksums/*.sha256
|
||||
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: yao-darwin-arm64
|
||||
path: /tmp/yao-output-arm64-prod/yao
|
||||
|
||||
- name: Upload arm64 Dev Binary
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: yao-darwin-arm64-dev
|
||||
path: /tmp/yao-output-arm64-dev/yao
|
||||
|
||||
- name: Upload amd64 Binary
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: yao-darwin-amd64
|
||||
path: /tmp/yao-output-amd64-prod/yao
|
||||
|
||||
- name: Upload amd64 Dev Binary
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: yao-darwin-amd64-dev
|
||||
path: /tmp/yao-output-amd64-dev/yao
|
||||
|
||||
- name: Upload Checksums
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: yao-darwin-checksums
|
||||
path: /tmp/checksums/*.sha256
|
||||
|
||||
# ===================================================================
|
||||
# GitHub Release + R2 Upload (macOS binaries)
|
||||
# ===================================================================
|
||||
release:
|
||||
needs: build
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get Version
|
||||
id: version
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" != refs/tags/v* ]]; then
|
||||
echo "::error::This workflow requires a tag. Got: $GITHUB_REF"
|
||||
exit 1
|
||||
fi
|
||||
VERSION="${GITHUB_REF#refs/tags/v}"
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "tag=${TAG}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download macOS Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: yao-darwin-arm64
|
||||
path: artifacts/arm64-prod
|
||||
|
||||
- name: Download arm64 Dev
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: yao-darwin-arm64-dev
|
||||
path: artifacts/arm64-dev
|
||||
|
||||
- name: Download amd64 Prod
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: yao-darwin-amd64
|
||||
path: artifacts/amd64-prod
|
||||
|
||||
- name: Download amd64 Dev
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: yao-darwin-amd64-dev
|
||||
path: artifacts/amd64-dev
|
||||
|
||||
- name: Download Checksums
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: yao-darwin-checksums
|
||||
path: artifacts/checksums
|
||||
|
||||
- name: Prepare Release Files
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
mkdir -p release
|
||||
cp artifacts/arm64-prod/yao "release/yao-${VERSION}-darwin-arm64"
|
||||
cp artifacts/amd64-prod/yao "release/yao-${VERSION}-darwin-amd64"
|
||||
cp artifacts/arm64-dev/yao "release/yao-${VERSION}-darwin-arm64-dev"
|
||||
cp artifacts/amd64-dev/yao "release/yao-${VERSION}-darwin-amd64-dev"
|
||||
cp artifacts/checksums/*.sha256 release/ 2>/dev/null || true
|
||||
chmod +x release/yao-*
|
||||
ls -lh release/
|
||||
|
||||
- name: Wait for Draft Release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
TAG="${{ steps.version.outputs.tag }}"
|
||||
for i in $(seq 1 30); do
|
||||
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" &>/dev/null; then
|
||||
echo "Draft release found for $TAG."
|
||||
exit 0
|
||||
fi
|
||||
echo "Waiting for draft release... ($i/30)"
|
||||
sleep 10
|
||||
done
|
||||
echo "::error::Timed out waiting for draft release $TAG"
|
||||
exit 1
|
||||
|
||||
- name: Upload Assets to GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
TAG="${{ steps.version.outputs.tag }}"
|
||||
gh release upload "$TAG" release/* --repo "$GITHUB_REPOSITORY" --clobber
|
||||
|
||||
- name: Publish Release if Complete
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
TAG="${{ steps.version.outputs.tag }}"
|
||||
ASSET_COUNT=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets | length')
|
||||
echo "Current assets: $ASSET_COUNT / 16"
|
||||
if [ "$ASSET_COUNT" -ge 16 ]; then
|
||||
echo "All assets present, publishing release..."
|
||||
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false --latest
|
||||
else
|
||||
echo "Assets incomplete ($ASSET_COUNT/16), waiting for other workflow to publish."
|
||||
fi
|
||||
|
||||
- name: Upload macOS binaries to R2
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINTS: ${{ secrets.R2_ENDPOINTS }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET || 'releases' }}
|
||||
run: |
|
||||
aws configure set default.region us-east-1
|
||||
aws configure set default.s3.signature_version s3v4
|
||||
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
PREFIX="yao/${VERSION}"
|
||||
|
||||
for PLATFORM in darwin-arm64 darwin-amd64; do
|
||||
FILE="release/yao-${VERSION}-${PLATFORM}"
|
||||
NAME="yao-${VERSION}-${PLATFORM}"
|
||||
sha256sum "$FILE" | awk '{print $1}' > "/tmp/${NAME}.sha256"
|
||||
|
||||
aws s3 cp "$FILE" "s3://${R2_BUCKET}/${PREFIX}/${NAME}" \
|
||||
--endpoint-url "$R2_ENDPOINTS" \
|
||||
--content-type "application/octet-stream"
|
||||
aws s3 cp "/tmp/${NAME}.sha256" "s3://${R2_BUCKET}/${PREFIX}/${NAME}.sha256" \
|
||||
--endpoint-url "$R2_ENDPOINTS" \
|
||||
--content-type "text/plain"
|
||||
echo "Uploaded: ${NAME} + ${NAME}.sha256"
|
||||
done
|
||||
# - name: Push To Beijing
|
||||
# run: |
|
||||
# for file in ./dist/release/*; do coscmd upload $file /archives/; done;
|
||||
|
|
|
|||
534
.github/workflows/unit-test-v1.yml
vendored
534
.github/workflows/unit-test-v1.yml
vendored
|
|
@ -1,534 +0,0 @@
|
|||
name: Unit Test V1
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tags:
|
||||
description: "Version"
|
||||
|
||||
env:
|
||||
CI_VERSION: "1.0.0"
|
||||
REPO_KUN: ${{ github.repository_owner }}/kun
|
||||
REPO_XUN: ${{ github.repository_owner }}/xun
|
||||
REPO_GOU: ${{ github.repository_owner }}/gou
|
||||
|
||||
YAO_DEV: ${{ github.WORKSPACE }}
|
||||
YAO_ENV: development
|
||||
YAO_ROOT: ${{ github.WORKSPACE }}/../app
|
||||
YAO_HOST: 0.0.0.0
|
||||
YAO_PORT: 5099
|
||||
YAO_SESSION: "memory"
|
||||
YAO_LOG: "./logs/application.log"
|
||||
YAO_LOG_MODE: "TEXT"
|
||||
YAO_JWT_SECRET: "bLp@bi!oqo-2U+hoTRUG"
|
||||
YAO_DB_AESKEY: "ZLX=T&f6refeCh-ro*r@"
|
||||
|
||||
YAO_EXTENSION_ROOT: ${{ github.WORKSPACE }}/../extension
|
||||
YAO_TEST_APPLICATION: ${{ github.WORKSPACE }}/../app
|
||||
|
||||
YAO_RUNTIME_MIN: 3
|
||||
YAO_RUNTIME_MAX: 6
|
||||
YAO_RUNTIME_HEAP_LIMIT: 1500000000
|
||||
YAO_RUNTIME_HEAP_RELEASE: 10000000
|
||||
YAO_RUNTIME_HEAP_AVAILABLE: 550000000
|
||||
YAO_RUNTIME_PRECOMPILE: true
|
||||
|
||||
MYSQL_TEST_HOST: "127.0.0.1"
|
||||
MYSQL_TEST_PORT: "3308"
|
||||
MYSQL_TEST_USER: "test"
|
||||
MYSQL_TEST_PASS: "123456"
|
||||
|
||||
REDIS_TEST_HOST: "127.0.0.1"
|
||||
REDIS_TEST_PORT: "6379"
|
||||
REDIS_TEST_DB: "2"
|
||||
|
||||
MONGO_TEST_HOST: "127.0.0.1"
|
||||
MONGO_TEST_PORT: "27017"
|
||||
MONGO_TEST_USER: "root"
|
||||
MONGO_TEST_PASS: "123456"
|
||||
|
||||
PG_TEST_HOST: "127.0.0.1"
|
||||
PG_TEST_PORT: "5432"
|
||||
PG_TEST_USER: "test"
|
||||
PG_TEST_PASS: "123456"
|
||||
|
||||
jobs:
|
||||
# =============================================================================
|
||||
# Environment Setup & Verification
|
||||
# Build Yao, start services, connect Tai via gRPC tunnel, verify everything.
|
||||
# No tests are run — this job validates the CI environment is healthy.
|
||||
# =============================================================================
|
||||
setup-and-verify:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
mongodb:
|
||||
image: mongo:6.0
|
||||
ports:
|
||||
- 27017:27017
|
||||
env:
|
||||
MONGO_INITDB_ROOT_USERNAME: root
|
||||
MONGO_INITDB_ROOT_PASSWORD: 123456
|
||||
MONGO_INITDB_DATABASE: test
|
||||
|
||||
postgres:
|
||||
image: postgres:14
|
||||
ports:
|
||||
- 5432:5432
|
||||
env:
|
||||
POSTGRES_USER: test
|
||||
POSTGRES_PASSWORD: 123456
|
||||
POSTGRES_DB: test
|
||||
options: >-
|
||||
--health-cmd="pg_isready -U test"
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=5
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
go: ["1.25"]
|
||||
|
||||
steps:
|
||||
# ==== Phase 1: Checkout & Setup ====
|
||||
- name: Checkout Yao
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Build Environment
|
||||
uses: ./.github/actions/setup-yao
|
||||
with:
|
||||
repo-kun: ${{ env.REPO_KUN }}
|
||||
repo-xun: ${{ env.REPO_XUN }}
|
||||
repo-gou: ${{ env.REPO_GOU }}
|
||||
checkout-init: "true"
|
||||
apple-private-key: ${{ secrets.APPLE_PRIVATE_KEY_USER }}
|
||||
|
||||
- name: Load sandbox-v2 env
|
||||
run: grep -vE '^\s*#|^\s*$' .github/env/sandbox-v2.env >> $GITHUB_ENV
|
||||
|
||||
- name: Setup SQLite
|
||||
run: |
|
||||
mkdir -p ${{ github.WORKSPACE }}/../app/db
|
||||
echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV
|
||||
|
||||
- name: Start MySQL 8.0
|
||||
run: |
|
||||
docker run -d --name mysql \
|
||||
-e MYSQL_RANDOM_ROOT_PASSWORD=true \
|
||||
-e MYSQL_USER=${MYSQL_TEST_USER} \
|
||||
-e MYSQL_PASSWORD=${MYSQL_TEST_PASS} \
|
||||
-e MYSQL_DATABASE=test \
|
||||
-p ${MYSQL_TEST_PORT}:3306 \
|
||||
mysql:8.0 --port=3306 --sql-mode='' \
|
||||
--character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci
|
||||
|
||||
for i in $(seq 1 30); do
|
||||
if docker exec mysql mysqladmin ping -h 127.0.0.1 -u ${MYSQL_TEST_USER} -p${MYSQL_TEST_PASS} > /dev/null 2>&1; then
|
||||
echo "MySQL ready"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for MySQL... ($i/30)"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
- name: Start Redis
|
||||
run: docker run --name redis -d -p 6379:6379 redis:6
|
||||
|
||||
# ==== Phase 2: Code Quality & Build ====
|
||||
- name: Code Quality Check
|
||||
run: |
|
||||
make vet
|
||||
make fmt-check
|
||||
|
||||
- name: Build Yao
|
||||
run: go build -v -o $RUNNER_TEMP/yao .
|
||||
|
||||
- name: Build ci-token
|
||||
run: go build -tags ci -v -o $RUNNER_TEMP/ci-token ./cmd/ci-token
|
||||
|
||||
- name: Extract tai binary from image
|
||||
run: |
|
||||
CID=$(docker create yaoapp/tai:latest)
|
||||
docker cp "$CID":/usr/local/bin/tai $RUNNER_TEMP/tai
|
||||
docker rm "$CID"
|
||||
chmod +x $RUNNER_TEMP/tai
|
||||
$RUNNER_TEMP/tai version || echo "tai binary extracted"
|
||||
|
||||
# ==== Phase 3: Prepare & Start Yao ====
|
||||
- name: Prepare test app directory
|
||||
run: |
|
||||
cp -r ${{ github.WORKSPACE }}/../yao-init $RUNNER_TEMP/yao-test-app
|
||||
mkdir -p $RUNNER_TEMP/yao-test-app/db
|
||||
|
||||
- name: Start Yao server
|
||||
run: |
|
||||
cd $RUNNER_TEMP/yao-test-app
|
||||
YAO_ROOT=$(pwd) \
|
||||
YAO_HOST=0.0.0.0 \
|
||||
YAO_PORT=5099 \
|
||||
YAO_GRPC_HOST=0.0.0.0 \
|
||||
YAO_GRPC_PORT=9099 \
|
||||
YAO_DB_DRIVER=sqlite3 \
|
||||
YAO_DB_PRIMARY=$(pwd)/db/yao.db \
|
||||
YAO_SESSION=memory \
|
||||
YAO_ENV=development \
|
||||
YAO_JWT_SECRET="${{ env.YAO_JWT_SECRET }}" \
|
||||
YAO_DB_AESKEY="${{ env.YAO_DB_AESKEY }}" \
|
||||
$RUNNER_TEMP/yao start &
|
||||
|
||||
# Wait for Yao HTTP to be ready (up to 120s)
|
||||
for i in $(seq 1 60); do
|
||||
if curl -sf http://127.0.0.1:5099/.well-known/yao > /dev/null 2>&1; then
|
||||
echo "Yao HTTP ready"
|
||||
curl -s http://127.0.0.1:5099/.well-known/yao | jq .
|
||||
break
|
||||
fi
|
||||
echo "Waiting for Yao... ($i/60)"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
curl -sf http://127.0.0.1:5099/.well-known/yao > /dev/null 2>&1 || {
|
||||
echo "::error::Yao HTTP failed to start"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ==== Phase 4: Generate Tai credentials ====
|
||||
- name: Generate Tai credentials
|
||||
run: |
|
||||
gen_cred() {
|
||||
local CID=$1 TID=$2 OUT=$3
|
||||
local TOKEN
|
||||
TOKEN=$($RUNNER_TEMP/ci-token \
|
||||
--app $RUNNER_TEMP/yao-test-app \
|
||||
--client-id "$CID" \
|
||||
--subject "${YAO_CI_OAUTH_SUBJECT:-ci-tai}" \
|
||||
--user-id "${YAO_CI_OAUTH_USER_ID}" \
|
||||
--team-id "${YAO_CI_OAUTH_TEAM_ID}" \
|
||||
--scope "${YAO_CI_OAUTH_SCOPE:-tai:tunnel}" \
|
||||
--ttl "${YAO_CI_OAUTH_TTL:-24h}" 2>/dev/null | tail -1 | tr -d '[:space:]')
|
||||
|
||||
local JSON="{\"client_id\":\"$CID\",\"tai_id\":\"$TID\",\"machine_id\":\"ci-runner\",\"server\":\"http://127.0.0.1:${YAO_CI_HTTP_PORT}\",\"yao_grpc_addr\":\"127.0.0.1:${YAO_CI_GRPC_PORT}\",\"access_token\":\"$TOKEN\",\"scope\":\"${YAO_CI_OAUTH_SCOPE}\",\"expires_at\":\"2099-01-01T00:00:00Z\",\"registered\":true}"
|
||||
echo -n "$JSON" | base64 -w0 > "$OUT"
|
||||
echo ""
|
||||
echo "Credentials JSON (debug): $JSON" | head -c 200
|
||||
echo "..."
|
||||
echo "Generated credentials for $CID → $OUT"
|
||||
}
|
||||
|
||||
gen_cred tai-ci-local tai-local-001 $RUNNER_TEMP/tai-local-credentials
|
||||
gen_cred tai-ci-docker tai-docker-001 $RUNNER_TEMP/tai-docker-credentials
|
||||
gen_cred tai-ci-k8s tai-k8s-001 $RUNNER_TEMP/tai-k8s-credentials
|
||||
gen_cred tai-ci-hostexec tai-hostexec-001 $RUNNER_TEMP/tai-hostexec-credentials
|
||||
|
||||
# ==== Phase 5: Pull images & Setup K8s ====
|
||||
- name: Pull test images
|
||||
run: |
|
||||
docker pull yaoapp/tai-sandbox-test:latest || true
|
||||
docker pull yaoapp/tai:latest
|
||||
docker pull alpine:latest
|
||||
|
||||
- name: Install k3d & create cluster
|
||||
run: |
|
||||
curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash
|
||||
k3d cluster create tai-test --no-lb --wait --api-port ${YAO_CI_K3D_API_PORT}
|
||||
kubectl wait --for=condition=Ready node --all --timeout=60s
|
||||
k3d image import alpine:latest -c tai-test
|
||||
|
||||
- name: Generate kubeconfig
|
||||
run: |
|
||||
K3D_IP=$(docker inspect k3d-tai-test-server-0 | jq -r '.[0].NetworkSettings.Networks["k3d-tai-test"].IPAddress')
|
||||
echo "k3d server IP: ${K3D_IP}"
|
||||
|
||||
k3d kubeconfig get tai-test > /tmp/kubeconfig-k3d.yml
|
||||
|
||||
# For tai-k8s container (uses k3d internal IP)
|
||||
sed "s|server: .*|server: https://${K3D_IP}:6443|" /tmp/kubeconfig-k3d.yml \
|
||||
> /tmp/kubeconfig-tai-k8s.yml
|
||||
echo "Container kubeconfig server:"
|
||||
grep server: /tmp/kubeconfig-tai-k8s.yml
|
||||
|
||||
# For test runner (uses localhost via k3d port-mapped API)
|
||||
sed "s|server: .*|server: https://127.0.0.1:${YAO_CI_K3D_API_PORT}|" /tmp/kubeconfig-k3d.yml \
|
||||
> $RUNNER_TEMP/kubeconfig-tai.yml
|
||||
echo "Test runner kubeconfig server:"
|
||||
grep server: $RUNNER_TEMP/kubeconfig-tai.yml
|
||||
|
||||
# Export for later steps
|
||||
echo "TAI_TEST_KUBECONFIG=$RUNNER_TEMP/kubeconfig-tai.yml" >> $GITHUB_ENV
|
||||
echo "YAO_CI_TAI_KUBECONFIG=$RUNNER_TEMP/kubeconfig-tai.yml" >> $GITHUB_ENV
|
||||
|
||||
# ==== Phase 6: Start Tai instances (host processes) ====
|
||||
- name: Start tai-local (DIRECT mode, auto-detect Docker)
|
||||
run: |
|
||||
mkdir -p $RUNNER_TEMP/tai-local-data
|
||||
|
||||
TAI_CREDENTIALS=$RUNNER_TEMP/tai-local-credentials \
|
||||
TAI_YAO_SERVER=http://127.0.0.1:${YAO_CI_HTTP_PORT} \
|
||||
TAI_DATA_DIR=$RUNNER_TEMP/tai-local-data \
|
||||
$RUNNER_TEMP/tai server \
|
||||
--grpc 127.0.0.1:${YAO_CI_TAI_LOCAL_GRPC_PORT} \
|
||||
--http 127.0.0.1:${YAO_CI_TAI_LOCAL_HTTP_PORT} \
|
||||
--vnc 127.0.0.1:${YAO_CI_TAI_LOCAL_VNC_PORT} \
|
||||
--docker 127.0.0.1:${YAO_CI_TAI_LOCAL_DOCKER_PORT} \
|
||||
--direct \
|
||||
--host-exec --host-exec-full-access \
|
||||
--log-level debug &
|
||||
|
||||
echo $! > $RUNNER_TEMP/tai-local.pid
|
||||
echo "tai-local PID: $(cat $RUNNER_TEMP/tai-local.pid)"
|
||||
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://127.0.0.1:${YAO_CI_TAI_LOCAL_HTTP_PORT}/healthz > /dev/null 2>&1; then
|
||||
echo "tai-local HTTP ready"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for tai-local HTTP... ($i/30)"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Start tai-docker (TUNNEL mode, Docker API proxy)
|
||||
run: |
|
||||
mkdir -p $RUNNER_TEMP/tai-docker-data
|
||||
|
||||
TAI_CREDENTIALS=$RUNNER_TEMP/tai-docker-credentials \
|
||||
TAI_YAO_SERVER=http://127.0.0.1:${YAO_CI_HTTP_PORT} \
|
||||
TAI_DATA_DIR=$RUNNER_TEMP/tai-docker-data \
|
||||
$RUNNER_TEMP/tai server \
|
||||
--grpc 127.0.0.1:${YAO_CI_TAI_DOCKER_GRPC_PORT} \
|
||||
--http 127.0.0.1:${YAO_CI_TAI_DOCKER_HTTP_PORT} \
|
||||
--vnc 127.0.0.1:${YAO_CI_TAI_DOCKER_VNC_PORT} \
|
||||
--docker 127.0.0.1:${YAO_CI_TAI_DOCKER_API_PORT} \
|
||||
--host-exec --host-exec-full-access \
|
||||
--log-level debug &
|
||||
|
||||
echo $! > $RUNNER_TEMP/tai-docker.pid
|
||||
echo "tai-docker PID: $(cat $RUNNER_TEMP/tai-docker.pid)"
|
||||
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://127.0.0.1:${YAO_CI_TAI_DOCKER_HTTP_PORT}/healthz > /dev/null 2>&1; then
|
||||
echo "tai-docker HTTP ready"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for tai-docker HTTP... ($i/30)"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Start tai-k8s (TUNNEL mode, K8s API proxy)
|
||||
run: |
|
||||
mkdir -p $RUNNER_TEMP/tai-k8s-data
|
||||
|
||||
TAI_CREDENTIALS=$RUNNER_TEMP/tai-k8s-credentials \
|
||||
TAI_YAO_SERVER=http://127.0.0.1:${YAO_CI_HTTP_PORT} \
|
||||
TAI_DATA_DIR=$RUNNER_TEMP/tai-k8s-data \
|
||||
TAI_K8S_UPSTREAM="tcp://127.0.0.1:${YAO_CI_K3D_API_PORT}" \
|
||||
TAI_KUBECONFIG=$RUNNER_TEMP/kubeconfig-tai.yml \
|
||||
$RUNNER_TEMP/tai server \
|
||||
--grpc 127.0.0.1:${YAO_CI_TAI_K8S_GRPC_PORT} \
|
||||
--http 127.0.0.1:${YAO_CI_TAI_K8S_HTTP_PORT} \
|
||||
--vnc 127.0.0.1:${YAO_CI_TAI_K8S_VNC_PORT} \
|
||||
--k8s 127.0.0.1:${YAO_CI_TAI_K8S_API_PORT} \
|
||||
--docker="" \
|
||||
--host-exec --host-exec-full-access \
|
||||
--log-level debug &
|
||||
|
||||
echo $! > $RUNNER_TEMP/tai-k8s.pid
|
||||
echo "tai-k8s PID: $(cat $RUNNER_TEMP/tai-k8s.pid)"
|
||||
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://127.0.0.1:${YAO_CI_TAI_K8S_HTTP_PORT}/healthz > /dev/null 2>&1; then
|
||||
echo "tai-k8s HTTP ready"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for tai-k8s HTTP... ($i/30)"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Start tai-hostexec (TUNNEL mode, HostExec only, no runtime)
|
||||
run: |
|
||||
mkdir -p $RUNNER_TEMP/tai-hostexec-data
|
||||
|
||||
TAI_CREDENTIALS=$RUNNER_TEMP/tai-hostexec-credentials \
|
||||
TAI_YAO_SERVER=http://127.0.0.1:${YAO_CI_HTTP_PORT} \
|
||||
TAI_DATA_DIR=$RUNNER_TEMP/tai-hostexec-data \
|
||||
$RUNNER_TEMP/tai server \
|
||||
--grpc 127.0.0.1:${YAO_CI_TAI_HOSTEXEC_GRPC_PORT} \
|
||||
--http 127.0.0.1:${YAO_CI_TAI_HOSTEXEC_HTTP_PORT} \
|
||||
--vnc 127.0.0.1:${YAO_CI_TAI_HOSTEXEC_VNC_PORT} \
|
||||
--docker="" \
|
||||
--host-exec --host-exec-full-access \
|
||||
--log-level debug &
|
||||
|
||||
echo $! > $RUNNER_TEMP/tai-hostexec.pid
|
||||
echo "tai-hostexec PID: $(cat $RUNNER_TEMP/tai-hostexec.pid)"
|
||||
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://127.0.0.1:${YAO_CI_TAI_HOSTEXEC_HTTP_PORT}/healthz > /dev/null 2>&1; then
|
||||
echo "tai-hostexec HTTP ready"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for tai-hostexec HTTP... ($i/30)"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# ==== Phase 7: Environment Verification (fail fast) ====
|
||||
- name: Verify Environment
|
||||
run: |
|
||||
echo "CI Environment v${CI_VERSION}"
|
||||
echo ""
|
||||
|
||||
FAILED=0
|
||||
check() {
|
||||
local name=$1; shift
|
||||
if "$@" > /dev/null 2>&1; then
|
||||
echo " [PASS] $name"
|
||||
else
|
||||
echo " [FAIL] $name"
|
||||
FAILED=$((FAILED + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== Environment Verification ==="
|
||||
|
||||
# ── 1. Service Health ──
|
||||
echo ""
|
||||
echo "--- 1. Service Health ---"
|
||||
|
||||
echo "[Yao]"
|
||||
check "Yao HTTP (/.well-known/yao)" curl -sf http://127.0.0.1:5099/.well-known/yao
|
||||
check "Yao gRPC port" nc -z 127.0.0.1 9099
|
||||
|
||||
echo "[tai-local (DIRECT)]"
|
||||
check "tai-local process alive" kill -0 $(cat $RUNNER_TEMP/tai-local.pid 2>/dev/null || echo 0)
|
||||
check "tai-local HTTP (/healthz)" curl -sf http://127.0.0.1:${YAO_CI_TAI_LOCAL_HTTP_PORT}/healthz
|
||||
check "tai-local gRPC reachable (direct)" nc -z 127.0.0.1 ${YAO_CI_TAI_LOCAL_GRPC_PORT}
|
||||
|
||||
echo "[tai-docker (TUNNEL)]"
|
||||
check "tai-docker process alive" kill -0 $(cat $RUNNER_TEMP/tai-docker.pid 2>/dev/null || echo 0)
|
||||
check "tai-docker HTTP (/healthz)" curl -sf http://127.0.0.1:${YAO_CI_TAI_DOCKER_HTTP_PORT}/healthz
|
||||
check "tai-docker gRPC listener" nc -z 127.0.0.1 ${YAO_CI_TAI_DOCKER_GRPC_PORT}
|
||||
|
||||
echo "[tai-k8s (TUNNEL)]"
|
||||
check "tai-k8s process alive" kill -0 $(cat $RUNNER_TEMP/tai-k8s.pid 2>/dev/null || echo 0)
|
||||
check "tai-k8s HTTP (/healthz)" curl -sf http://127.0.0.1:${YAO_CI_TAI_K8S_HTTP_PORT}/healthz
|
||||
check "tai-k8s gRPC listener" nc -z 127.0.0.1 ${YAO_CI_TAI_K8S_GRPC_PORT}
|
||||
|
||||
echo "[tai-hostexec (TUNNEL)]"
|
||||
check "tai-hostexec process alive" kill -0 $(cat $RUNNER_TEMP/tai-hostexec.pid 2>/dev/null || echo 0)
|
||||
check "tai-hostexec HTTP (/healthz)" curl -sf http://127.0.0.1:${YAO_CI_TAI_HOSTEXEC_HTTP_PORT}/healthz
|
||||
check "tai-hostexec gRPC listener" nc -z 127.0.0.1 ${YAO_CI_TAI_HOSTEXEC_GRPC_PORT}
|
||||
|
||||
echo "[K8s (k3d via direct API)]"
|
||||
check "kubectl get nodes (k3d direct)" kubectl --kubeconfig=$RUNNER_TEMP/kubeconfig-tai.yml get nodes
|
||||
|
||||
echo "[Data Stores]"
|
||||
MONGO_CID=$(docker ps -qf "ancestor=mongo:6.0" | head -1)
|
||||
check "MongoDB ping" docker exec "$MONGO_CID" mongosh --quiet \
|
||||
-u ${MONGO_TEST_USER} -p ${MONGO_TEST_PASS} --authenticationDatabase admin \
|
||||
--eval "db.runCommand({ping:1})"
|
||||
|
||||
check "MySQL ping" docker exec mysql mysqladmin ping -h 127.0.0.1 \
|
||||
-u ${MYSQL_TEST_USER} -p${MYSQL_TEST_PASS}
|
||||
|
||||
PG_CID=$(docker ps -qf "ancestor=postgres:14" | head -1)
|
||||
check "PostgreSQL ping" docker exec "$PG_CID" pg_isready -U ${PG_TEST_USER}
|
||||
|
||||
check "Redis ping" docker exec redis redis-cli ping
|
||||
|
||||
# ── 2. Network Topology ──
|
||||
echo ""
|
||||
echo "--- 2. Network Topology ---"
|
||||
|
||||
BRIDGE_IP=${YAO_CI_BRIDGE_IP}
|
||||
|
||||
echo "[Host network basics]"
|
||||
check "docker0 bridge exists" ip addr show docker0
|
||||
check "Bridge IP reachable (${BRIDGE_IP})" ping -c1 -W2 ${BRIDGE_IP}
|
||||
check "Docker socket accessible" test -S /var/run/docker.sock
|
||||
check "tai binary on runner" test -x $RUNNER_TEMP/tai
|
||||
|
||||
echo "[Yao endpoints (all Tai instances need these)]"
|
||||
check "Yao HTTP :${YAO_CI_HTTP_PORT}" curl -sf http://127.0.0.1:${YAO_CI_HTTP_PORT}/.well-known/yao
|
||||
check "Yao gRPC :${YAO_CI_GRPC_PORT}" nc -z 127.0.0.1 ${YAO_CI_GRPC_PORT}
|
||||
|
||||
echo "[DIRECT path: Yao → tai-local]"
|
||||
check "Yao→tai-local gRPC :${YAO_CI_TAI_LOCAL_GRPC_PORT}" nc -z 127.0.0.1 ${YAO_CI_TAI_LOCAL_GRPC_PORT}
|
||||
check "Yao→tai-local HTTP :${YAO_CI_TAI_LOCAL_HTTP_PORT}" curl -sf http://127.0.0.1:${YAO_CI_TAI_LOCAL_HTTP_PORT}/healthz
|
||||
check "tai-local Docker API proxy :${YAO_CI_TAI_LOCAL_DOCKER_PORT}" nc -z 127.0.0.1 ${YAO_CI_TAI_LOCAL_DOCKER_PORT}
|
||||
|
||||
echo "[TUNNEL path: tai-docker → Yao gRPC (reverse tunnel)]"
|
||||
check "tai-docker→Yao gRPC :${YAO_CI_GRPC_PORT}" nc -z 127.0.0.1 ${YAO_CI_GRPC_PORT}
|
||||
check "tai-docker Docker API proxy :${YAO_CI_TAI_DOCKER_API_PORT}" nc -z 127.0.0.1 ${YAO_CI_TAI_DOCKER_API_PORT}
|
||||
check "Docker API via proxy" bash -c "curl -sf http://127.0.0.1:${YAO_CI_TAI_DOCKER_API_PORT}/version | jq -r .ApiVersion"
|
||||
|
||||
echo "[TUNNEL path: tai-k8s → Yao gRPC (reverse tunnel)]"
|
||||
check "tai-k8s→Yao gRPC :${YAO_CI_GRPC_PORT}" nc -z 127.0.0.1 ${YAO_CI_GRPC_PORT}
|
||||
check "tai-k8s K8s API proxy :${YAO_CI_TAI_K8S_API_PORT}" nc -z 127.0.0.1 ${YAO_CI_TAI_K8S_API_PORT}
|
||||
sed "s|server: .*|server: https://127.0.0.1:${YAO_CI_TAI_K8S_API_PORT}|" $RUNNER_TEMP/kubeconfig-tai.yml \
|
||||
> $RUNNER_TEMP/kubeconfig-tai-proxy.yml
|
||||
check "K8s API via tai-k8s proxy (kubectl)" kubectl --kubeconfig=$RUNNER_TEMP/kubeconfig-tai-proxy.yml --insecure-skip-tls-verify get nodes
|
||||
|
||||
echo "[TUNNEL path: tai-hostexec → Yao gRPC (reverse tunnel)]"
|
||||
check "tai-hostexec→Yao gRPC :${YAO_CI_GRPC_PORT}" nc -z 127.0.0.1 ${YAO_CI_GRPC_PORT}
|
||||
|
||||
echo "[Data Store connectivity from runner]"
|
||||
check "MongoDB :27017" nc -z 127.0.0.1 27017
|
||||
check "Redis :6379" nc -z 127.0.0.1 6379
|
||||
check "MySQL :${MYSQL_TEST_PORT}" nc -z 127.0.0.1 ${MYSQL_TEST_PORT}
|
||||
check "PostgreSQL :${PG_TEST_PORT}" nc -z 127.0.0.1 ${PG_TEST_PORT}
|
||||
|
||||
# ── 3. Connection Mode Verification ──
|
||||
echo ""
|
||||
echo "--- 3. Connection Modes ---"
|
||||
sleep 5
|
||||
|
||||
echo "[Credentials (4 tokens)]"
|
||||
check "tai-local credentials exist" test -f $RUNNER_TEMP/tai-local-credentials
|
||||
check "tai-docker credentials exist" test -f $RUNNER_TEMP/tai-docker-credentials
|
||||
check "tai-k8s credentials exist" test -f $RUNNER_TEMP/tai-k8s-credentials
|
||||
check "tai-hostexec credentials exist" test -f $RUNNER_TEMP/tai-hostexec-credentials
|
||||
|
||||
echo "[DIRECT: tai-local → Yao HTTP register → Yao dials tai-local gRPC]"
|
||||
echo " tai-local registers via POST /tai-nodes/register"
|
||||
echo " Yao dials back tai-local gRPC at 127.0.0.1:${YAO_CI_TAI_LOCAL_GRPC_PORT}"
|
||||
|
||||
echo "[TUNNEL: tai-docker → Yao gRPC :${YAO_CI_GRPC_PORT} (Register + Forward)]"
|
||||
echo " Sandbox connects Yao gRPC → Forward stream → tai-docker :${YAO_CI_TAI_DOCKER_GRPC_PORT}"
|
||||
|
||||
echo "[TUNNEL: tai-k8s → Yao gRPC :${YAO_CI_GRPC_PORT} (Register + Forward)]"
|
||||
echo " Sandbox connects Yao gRPC → Forward stream → tai-k8s :${YAO_CI_TAI_K8S_GRPC_PORT}"
|
||||
|
||||
echo "[TUNNEL: tai-hostexec → Yao gRPC :${YAO_CI_GRPC_PORT} (Register + Forward)]"
|
||||
echo " HostExec only, no container runtime"
|
||||
|
||||
WELL_KNOWN=$(curl -sf http://127.0.0.1:5099/.well-known/yao 2>/dev/null || echo "{}")
|
||||
echo ""
|
||||
echo " Yao .well-known/yao:"
|
||||
echo "$WELL_KNOWN" | jq . 2>/dev/null || echo " $WELL_KNOWN"
|
||||
|
||||
# ── 4. HostExec Readiness ──
|
||||
echo ""
|
||||
echo "--- 4. HostExec ---"
|
||||
|
||||
echo "[HostExec gRPC ports (all 4 instances)]"
|
||||
check "HostExec tai-local (direct, auto-Docker)" nc -z 127.0.0.1 ${YAO_CI_TAI_LOCAL_GRPC_PORT}
|
||||
check "HostExec tai-docker (tunnel, Docker proxy)" nc -z 127.0.0.1 ${YAO_CI_TAI_DOCKER_GRPC_PORT}
|
||||
check "HostExec tai-k8s (tunnel, K8s proxy)" nc -z 127.0.0.1 ${YAO_CI_TAI_K8S_GRPC_PORT}
|
||||
check "HostExec tai-hostexec (tunnel, no runtime)" nc -z 127.0.0.1 ${YAO_CI_TAI_HOSTEXEC_GRPC_PORT}
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
if [ $FAILED -gt 0 ]; then
|
||||
echo "::error::$FAILED verification check(s) FAILED"
|
||||
echo ""
|
||||
echo "=== Diagnostic Info ==="
|
||||
echo "--- Docker containers ---"
|
||||
docker ps -a
|
||||
echo ""
|
||||
echo "--- Processes (yao + tai) ---"
|
||||
ps aux | grep -E "yao|tai" | grep -v grep || true
|
||||
echo ""
|
||||
echo "--- Listening ports ---"
|
||||
ss -tlnp | grep -E "5099|9099|19100|19101|19102|19103|8099|8100|8101|8102|12375|12376|16443|16444" || true
|
||||
exit 1
|
||||
else
|
||||
echo "All verification checks PASSED"
|
||||
fi
|
||||
1413
.github/workflows/unit-test.yml
vendored
1413
.github/workflows/unit-test.yml
vendored
File diff suppressed because it is too large
Load diff
121
.github/workflows/update-cdn-latest.yml
vendored
121
.github/workflows/update-cdn-latest.yml
vendored
|
|
@ -1,121 +0,0 @@
|
|||
name: Update CDN latest.json
|
||||
|
||||
# Assembles yao/latest.json after all platform binaries are on R2.
|
||||
#
|
||||
# Normally triggered automatically by notarize-macos.yml's finalize job after
|
||||
# notarization completes. Can also be triggered manually as a fallback.
|
||||
#
|
||||
# Prerequisites: release-linux.yml and release-macos.yml must have uploaded
|
||||
# all 4 platform binaries to R2.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Engine version to publish (e.g. 1.0.0 or 1.0.0-alpha)"
|
||||
required: true
|
||||
mark_latest:
|
||||
description: "Also update yao/latest.json (set false for pre-releases you want on CDN but not as latest)"
|
||||
required: false
|
||||
default: "true"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish-latest:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINTS: ${{ secrets.R2_ENDPOINTS }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET || 'releases' }}
|
||||
CDN_BASE: https://get.yaoapps.com
|
||||
steps:
|
||||
- name: Configure AWS CLI
|
||||
run: |
|
||||
aws configure set default.region us-east-1
|
||||
aws configure set default.s3.signature_version s3v4
|
||||
|
||||
- name: Verify platform assets exist
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
PREFIX="yao/${VERSION}"
|
||||
|
||||
PLATFORMS=(
|
||||
"darwin-arm64"
|
||||
"darwin-amd64"
|
||||
"linux-amd64"
|
||||
"linux-arm64"
|
||||
)
|
||||
|
||||
MISSING=0
|
||||
for P in "${PLATFORMS[@]}"; do
|
||||
KEY="${PREFIX}/yao-${VERSION}-${P}"
|
||||
echo "Checking s3://${R2_BUCKET}/${KEY}"
|
||||
if ! aws s3 ls "s3://${R2_BUCKET}/${KEY}" --endpoint-url "$R2_ENDPOINTS" >/dev/null 2>&1; then
|
||||
echo "::warning::Missing asset: ${KEY}"
|
||||
MISSING=$((MISSING+1))
|
||||
fi
|
||||
if ! aws s3 ls "s3://${R2_BUCKET}/${KEY}.sha256" --endpoint-url "$R2_ENDPOINTS" >/dev/null 2>&1; then
|
||||
echo "::warning::Missing sha256: ${KEY}.sha256"
|
||||
MISSING=$((MISSING+1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$MISSING" -gt 0 ]; then
|
||||
echo "::error::$MISSING required asset(s) are missing on R2. Run platform CI workflows first."
|
||||
exit 1
|
||||
fi
|
||||
echo "All platform assets verified."
|
||||
|
||||
- name: Build latest.json
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
RELEASED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
python3 <<PY > /tmp/latest.json
|
||||
import json
|
||||
version = "${VERSION}"
|
||||
base = "${CDN_BASE}/yao/${VERSION}"
|
||||
assets = {
|
||||
"darwin-arm64": f"{base}/yao-{version}-darwin-arm64",
|
||||
"darwin-amd64": f"{base}/yao-{version}-darwin-amd64",
|
||||
"linux-amd64": f"{base}/yao-{version}-linux-amd64",
|
||||
"linux-arm64": f"{base}/yao-{version}-linux-arm64",
|
||||
}
|
||||
sha256 = {
|
||||
"darwin-arm64": f"{base}/yao-{version}-darwin-arm64.sha256",
|
||||
"darwin-amd64": f"{base}/yao-{version}-darwin-amd64.sha256",
|
||||
"linux-amd64": f"{base}/yao-{version}-linux-amd64.sha256",
|
||||
"linux-arm64": f"{base}/yao-{version}-linux-arm64.sha256",
|
||||
}
|
||||
data = {
|
||||
"version": version,
|
||||
"released_at": "${RELEASED_AT}",
|
||||
"assets": assets,
|
||||
"sha256": sha256,
|
||||
}
|
||||
print(json.dumps(data, indent=2, ensure_ascii=False))
|
||||
PY
|
||||
|
||||
cat /tmp/latest.json
|
||||
|
||||
- name: Upload versioned latest.json
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
aws s3 cp /tmp/latest.json \
|
||||
"s3://${R2_BUCKET}/yao/${VERSION}/latest.json" \
|
||||
--endpoint-url "$R2_ENDPOINTS" \
|
||||
--content-type "application/json" \
|
||||
--cache-control "public, max-age=60"
|
||||
|
||||
- name: Promote to yao/latest.json
|
||||
if: ${{ github.event.inputs.mark_latest != 'false' }}
|
||||
run: |
|
||||
aws s3 cp /tmp/latest.json \
|
||||
"s3://${R2_BUCKET}/yao/latest.json" \
|
||||
--endpoint-url "$R2_ENDPOINTS" \
|
||||
--content-type "application/json" \
|
||||
--cache-control "public, max-age=60"
|
||||
echo "Promoted to yao/latest.json"
|
||||
62
.gitignore
vendored
62
.gitignore
vendored
|
|
@ -11,8 +11,6 @@
|
|||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
*.log
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
.DS_Store
|
||||
|
|
@ -21,8 +19,7 @@
|
|||
dist
|
||||
ui
|
||||
^ui/index.html
|
||||
tests/data/*
|
||||
!tests/data/assets
|
||||
tests/data/*/*
|
||||
debug.log
|
||||
yao-test
|
||||
dev.sh
|
||||
|
|
@ -30,60 +27,3 @@ logs
|
|||
logs/application.log
|
||||
yao-arm64
|
||||
tests/db/yao.db
|
||||
env.*.sh
|
||||
xgen/v0.9/*
|
||||
xgen/v1.0/*
|
||||
!xgen/v0.9/index.html
|
||||
!xgen/v1.0/index.html
|
||||
!xgen/v1.0/umi.js
|
||||
!xgen/v1.0/layouts__index.async.js
|
||||
!pipe/ui
|
||||
*-unit-test
|
||||
docker/build/test
|
||||
db
|
||||
!agent/search/handlers/db
|
||||
*.sh
|
||||
data/bindata.go.bak
|
||||
share/const.go.bak
|
||||
share/const.goe
|
||||
.cursor
|
||||
openapi/*.md
|
||||
coverage.html
|
||||
agent/assistant/hook/*.test.md
|
||||
agent/search/TODO.md
|
||||
agent/search/job-logs.txt
|
||||
agent/test/MULTI_TURN_DESIGN.md
|
||||
agent/test/UPGRADE_PLAN.md
|
||||
introduction/*
|
||||
!sandbox/docker/build.sh
|
||||
!sandbox/docker/vnc/*.sh
|
||||
!sandbox/docker/desktop/config/*.sh
|
||||
sandbox/docker/yao-bridge-*
|
||||
sandbox/docker/claude-proxy-*
|
||||
sandbox/docker/claude/claude-proxy-*
|
||||
sandbox/proxy/claude-proxy-linux-*
|
||||
release/*
|
||||
sandbox/TODO-VNC.md
|
||||
sandbox/docker/chrome/PLAN.md
|
||||
sandbox/DESIGN-REMOTE.md
|
||||
event/DESIGN.md
|
||||
event/TODO.md
|
||||
agent/robot/DESIGN-V2.md
|
||||
tg-session.json
|
||||
tg-login
|
||||
tg-send
|
||||
registry/data/
|
||||
registry/manager/DESIGN*.md
|
||||
tai/testdata/
|
||||
agent/sandbox/docs/*.md
|
||||
tai/docs/refactor-registration.md
|
||||
agent/robot/ROBOT-WATCHER-IMPROVEMENT.md
|
||||
agent/robot/ROBOT-IM-INTEGRATION-IMPROVEMENT.md
|
||||
agent/robot/ROBOT-CACHE-IMPROVEMENT.md
|
||||
sandbox/v2/PID-KILL-UPGRADE.md
|
||||
sandbox/v2/*.md
|
||||
POSTGRESQL_COMPAT.md
|
||||
openapi/setting/*.md
|
||||
agent/docs/design/*.md
|
||||
tools/README.md
|
||||
tools/TOOL-REGISTRATION.md
|
||||
|
|
|
|||
209
LICENSE
209
LICENSE
|
|
@ -1,30 +1,201 @@
|
|||
# Open Source License
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
Yao Engine is licensed under a modified version of the Apache License 2.0, with the following additional conditions:
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Commercial Usage Terms:
|
||||
Yao Engine may be utilized commercially, A commercial license from the producer is required if:
|
||||
1. Definitions.
|
||||
|
||||
a. Trademark and Branding Requirements
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
- The Yao Engine / Yao Agents / Tai / Tai Link console/application logo and copyright information must not be removed or modified
|
||||
- Logo and copyright information can only be changed with an authorization certificate issued through Yao Developer Certificate
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
b. Authorization Verification Requirements
|
||||
"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.
|
||||
|
||||
- The Yao certificate verification logic, processes, and related pages (marked in code comments) must be preserved
|
||||
- The complete Yao certificate verification system must be maintained regardless of usage purpose
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
c. Enterprise Scale Requirements
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
- Organizations with 50 or more employees, or with annual revenue exceeding USD 1,000,000, must obtain a commercial license from Infinite Wisdom Software.
|
||||
- To obtain a commercial license, please contact us at https://yaoagents.com/enterprise
|
||||
"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.
|
||||
|
||||
2. Contributor Agreement:
|
||||
As a contributor, you should agree that:
|
||||
a. Infinite Wisdom Software can adjust the open-source agreement to be more strict or relaxed as deemed necessary.
|
||||
b. Your contributed code may be used for commercial purposes, including but not limited to its cloud business operations.
|
||||
"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).
|
||||
|
||||
All other rights and restrictions follow the Apache License 2.0 (http://www.apache.org/licenses/LICENSE-2.0).
|
||||
"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.
|
||||
|
||||
© 2026 Infinite Wisdom Software.
|
||||
"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 [yyyy] [name of copyright owner]
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
# 开源许可证
|
||||
|
||||
Yao Engine 基于修改版 Apache License 2.0 授权,并附加以下额外条款:
|
||||
|
||||
1. 商业使用条款:
|
||||
Yao Engine 可用于商业用途,但在以下情况下须向 Infinite Wisdom Software 获取商业授权许可:
|
||||
|
||||
a. 商标与品牌要求
|
||||
|
||||
- 不得删除或修改 Yao Engine / Yao Agents / Tai / Tai Link 控制台/应用程序的徽标及版权信息
|
||||
- 徽标和版权信息仅可在持有通过 Yao 开发者证书颁发的授权证书时方可更改
|
||||
|
||||
b. 授权验证要求
|
||||
|
||||
- 必须保留 Yao 证书验证逻辑、流程及相关页面(已在代码注释中标注)
|
||||
- 无论使用目的如何,必须维持完整的 Yao 证书验证系统
|
||||
|
||||
c. 企业规模要求
|
||||
|
||||
- 员工人数达到 50 人及以上,或年收入超过 100 万美元的企业,须向 Infinite Wisdom Software 购买商业授权许可。
|
||||
- 如需获取商业授权,请访问 https://yaoagents.com/enterprise 联系我们。
|
||||
|
||||
2. 贡献者协议:
|
||||
作为贡献者,您需同意以下条款:
|
||||
a. Infinite Wisdom Software 可视需要对本开源协议进行更严格或更宽松的调整。
|
||||
b. 您贡献的代码可被用于商业用途,包括但不限于云服务业务运营。
|
||||
|
||||
其他所有权利与限制遵循 Apache License 2.0(http://www.apache.org/licenses/LICENSE-2.0)。
|
||||
|
||||
© 2026 Infinite Wisdom Software.
|
||||
776
Makefile
776
Makefile
|
|
@ -6,48 +6,21 @@ VETPACKAGES ?= $(shell $(GO) list ./... | grep -v /examples/)
|
|||
GOFILES := $(shell find . -name "*.go")
|
||||
VERSION := $(shell grep 'const VERSION =' share/const.go |awk '{print $$4}' |sed 's/\"//g')
|
||||
COMMIT := $(shell git log | head -n 1 | awk '{print substr($$2, 0, 12)}')
|
||||
NOW := $(shell date +"%FT%T%z")
|
||||
OS := $(shell uname)
|
||||
|
||||
# ROOT_DIR := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST))))
|
||||
TESTFOLDER := $(shell $(GO) list ./... | grep -vE 'examples|openai|aigc|neo|twilio|share*|registry|agent/sandbox/v2' | awk '!/\/tests\// || /openapi\/tests/' | grep -vE 'openapi/tests/(nodes|sandbox|workspace)')
|
||||
# Sandbox setting tests (openapi/tests/setting/sandbox_test.go) require Docker + Tai — skipped in CI, run locally only
|
||||
# Core tests (exclude AI-related: agent, aigc, openai, KB, sandbox, registry, grpc, and integrations which require external services)
|
||||
TESTFOLDER_CORE := $(shell $(GO) list ./... | grep -vE 'examples|openai|aigc|neo|twilio|share*|agent|kb|sandbox|integrations|registry|tai|grpc' | awk '!/\/tests\// || /openapi\/tests/' | grep -vE 'openapi/tests/(nodes|sandbox|workspace)')
|
||||
# Agent tests (agent, aigc) - exclude agent/search/handlers/web (requires external API keys), robot packages (tested in robot job), and agent/sandbox/v2 (WIP, has its own job)
|
||||
TESTFOLDER_AGENT := $(shell $(GO) list ./agent/... ./aigc/... | grep -vE 'agent/search/handlers/web|agent/robot/|agent/sandbox/v2')
|
||||
# KB tests (kb)
|
||||
TESTFOLDER_KB := $(shell $(GO) list ./kb/...)
|
||||
# Robot tests (agent/robot/... packages, excluding events/integrations which require Telegram etc.)
|
||||
TESTFOLDER_ROBOT := $(shell $(GO) list ./agent/robot/... | grep -vE 'agent/robot/events')
|
||||
# Sandbox tests (requires Docker) — excludes sandbox/v2 (has its own job)
|
||||
TESTFOLDER_SANDBOX := $(shell $(GO) list ./sandbox/... | grep -v 'sandbox/v2')
|
||||
# Tai SDK tests (requires Tai container with Docker socket)
|
||||
TESTFOLDER_TAI := $(shell $(GO) list ./tai/...)
|
||||
# Workspace tests (requires Tai for remote mode)
|
||||
TESTFOLDER_WORKSPACE := $(shell $(GO) list ./workspace/...)
|
||||
# gRPC tests
|
||||
TESTFOLDER_GRPC := $(shell $(GO) list ./grpc/...)
|
||||
TESTFOLDER := $(shell $(GO) list ./... | grep -vE 'examples|tests*|config')
|
||||
TESTTAGS ?= ""
|
||||
|
||||
# TESTWIDGETS := $(shell $(GO) list ./widgets/...)
|
||||
|
||||
# Unit Test (all tests)
|
||||
.PHONY: unit-test
|
||||
unit-test:
|
||||
# Unit Test
|
||||
.PHONY: test
|
||||
test:
|
||||
echo "mode: count" > coverage.out
|
||||
for d in $(TESTFOLDER); do \
|
||||
$(GO) test -tags $(TESTTAGS) -v -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") -skip='TestMemoryLeak|TestIsolateDisposal|TestLeak_|TestScenario_|TestSandbox' $$d > tmp.out; \
|
||||
$(GO) test -tags $(TESTTAGS) -v -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") $$d > tmp.out; \
|
||||
cat tmp.out; \
|
||||
if grep -q "^--- FAIL" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "^FAIL" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "^panic:" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "build failed" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
|
|
@ -64,380 +37,6 @@ unit-test:
|
|||
fi; \
|
||||
done
|
||||
|
||||
# Core Unit Test (exclude AI-related tests)
|
||||
.PHONY: unit-test-core
|
||||
unit-test-core:
|
||||
echo "mode: count" > coverage.out
|
||||
for d in $(TESTFOLDER_CORE); do \
|
||||
$(GO) test -tags $(TESTTAGS) -v -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") -skip='TestMemoryLeak|TestIsolateDisposal|TestLeak_|TestScenario_|TestSandbox' $$d > tmp.out; \
|
||||
cat tmp.out; \
|
||||
if grep -q "^--- FAIL" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "^FAIL" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "^panic:" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "build failed" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "setup failed" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "runtime error" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
if [ -f profile.out ]; then \
|
||||
cat profile.out | grep -v "mode:" >> coverage.out; \
|
||||
rm profile.out; \
|
||||
fi; \
|
||||
done
|
||||
|
||||
# Agent Unit Test (agent, aigc) - excludes robot packages (tested in unit-test-robot) and TestE2E*
|
||||
.PHONY: unit-test-agent
|
||||
unit-test-agent:
|
||||
echo "mode: count" > coverage.out
|
||||
for d in $(TESTFOLDER_AGENT); do \
|
||||
$(GO) test -tags $(TESTTAGS) -v -timeout=50m -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") -skip='TestMemoryLeak|TestIsolateDisposal|TestE2E' $$d > tmp.out; \
|
||||
cat tmp.out; \
|
||||
if grep -q "^--- FAIL" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "^FAIL" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "^panic:" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "build failed" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "setup failed" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "runtime error" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
if [ -f profile.out ]; then \
|
||||
cat profile.out | grep -v "mode:" >> coverage.out; \
|
||||
rm profile.out; \
|
||||
fi; \
|
||||
done
|
||||
|
||||
# KB Unit Test (kb)
|
||||
.PHONY: unit-test-kb
|
||||
unit-test-kb:
|
||||
echo "mode: count" > coverage.out
|
||||
for d in $(TESTFOLDER_KB); do \
|
||||
$(GO) test -tags $(TESTTAGS) -v -timeout=20m -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") -skip='TestMemoryLeak|TestIsolateDisposal|TestSearchCleanup' $$d > tmp.out; \
|
||||
cat tmp.out; \
|
||||
if grep -q "^--- FAIL" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "^FAIL" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "^panic:" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "build failed" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "setup failed" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "runtime error" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
if [ -f profile.out ]; then \
|
||||
cat profile.out | grep -v "mode:" >> coverage.out; \
|
||||
rm profile.out; \
|
||||
fi; \
|
||||
done
|
||||
|
||||
# Robot Test (all agent/robot/... packages) - runs ALL tests (unit + E2E) with real LLM calls
|
||||
# These tests require: LLM API keys, database, and longer timeout
|
||||
.PHONY: unit-test-robot
|
||||
unit-test-robot:
|
||||
echo "mode: count" > coverage.out
|
||||
for d in $(TESTFOLDER_ROBOT); do \
|
||||
$(GO) test -tags $(TESTTAGS) -v -timeout=50m -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") -skip='TestMemoryLeak|TestIsolateDisposal' $$d > tmp.out; \
|
||||
cat tmp.out; \
|
||||
if grep -q "^--- FAIL" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "^FAIL" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "^panic:" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "build failed" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "setup failed" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "runtime error" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
if [ -f profile.out ]; then \
|
||||
cat profile.out | grep -v "mode:" >> coverage.out; \
|
||||
rm profile.out; \
|
||||
fi; \
|
||||
done
|
||||
|
||||
# Registry Client Test (requires Yao Registry service)
|
||||
.PHONY: unit-test-registry
|
||||
unit-test-registry:
|
||||
echo "mode: count" > coverage.out
|
||||
$(GO) test -v -p 1 -timeout=5m -covermode=count -coverprofile=profile.out ./registry/... > tmp.out; \
|
||||
cat tmp.out; \
|
||||
if grep -q "^--- FAIL" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "^FAIL" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "^panic:" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
if [ -f profile.out ]; then \
|
||||
cat profile.out | grep -v "mode:" >> coverage.out; \
|
||||
rm profile.out; \
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sandbox V2 CI Test (tai SDK + workspace only)
|
||||
# Full sandbox/v2 integration tests (multi-pool, K8s, etc.) are run locally.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
.PHONY: unit-test-sandbox-v2
|
||||
unit-test-sandbox-v2: unit-test-tai unit-test-workspace
|
||||
@echo ""
|
||||
@echo "============================================="
|
||||
@echo "All Sandbox V2 CI tests passed (tai + workspace)"
|
||||
@echo "============================================="
|
||||
|
||||
# Workspace Unit Test (requires Tai for remote mode)
|
||||
.PHONY: unit-test-workspace
|
||||
unit-test-workspace:
|
||||
@echo ""
|
||||
@echo "============================================="
|
||||
@echo "Running Workspace Tests..."
|
||||
@echo "============================================="
|
||||
echo "mode: count" > coverage.out
|
||||
for d in $(TESTFOLDER_WORKSPACE); do \
|
||||
$(GO) test -tags $(TESTTAGS) -v -timeout=10m -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") $$d > tmp.out; \
|
||||
cat tmp.out; \
|
||||
if grep -q "^--- FAIL" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "^FAIL" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "^panic:" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "build failed" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "setup failed" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "runtime error" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
if [ -f profile.out ]; then \
|
||||
cat profile.out | grep -v "mode:" >> coverage.out; \
|
||||
rm profile.out; \
|
||||
fi; \
|
||||
done
|
||||
@echo ""
|
||||
@echo "============================================="
|
||||
@echo "All workspace tests passed"
|
||||
@echo "============================================="
|
||||
|
||||
# Sandbox Unit Test (requires Docker)
|
||||
.PHONY: unit-test-sandbox
|
||||
unit-test-sandbox:
|
||||
@echo ""
|
||||
@echo "============================================="
|
||||
@echo "Running Sandbox Tests (requires Docker)..."
|
||||
@echo "============================================="
|
||||
@echo "Pulling sandbox test images..."
|
||||
docker pull alpine:latest || true
|
||||
docker pull yaoapp/sandbox-base:latest || true
|
||||
docker pull yaoapp/sandbox-claude:latest || true
|
||||
@echo ""
|
||||
echo "mode: count" > coverage.out
|
||||
for d in $(TESTFOLDER_SANDBOX); do \
|
||||
$(GO) test -tags $(TESTTAGS) -v -timeout=10m -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") -skip='TestMemoryLeak|TestIsolateDisposal' $$d > tmp.out; \
|
||||
cat tmp.out; \
|
||||
if grep -q "^--- FAIL" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "^FAIL" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "^panic:" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "build failed" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "setup failed" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "runtime error" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
if [ -f profile.out ]; then \
|
||||
cat profile.out | grep -v "mode:" >> coverage.out; \
|
||||
rm profile.out; \
|
||||
fi; \
|
||||
done
|
||||
@echo ""
|
||||
@echo "============================================="
|
||||
@echo "✅ All sandbox tests passed"
|
||||
@echo "============================================="
|
||||
|
||||
# Tai SDK Test (requires Tai container with Docker socket)
|
||||
.PHONY: unit-test-tai
|
||||
unit-test-tai:
|
||||
@echo ""
|
||||
@echo "============================================="
|
||||
@echo "Running Tai SDK Tests (requires Tai container)..."
|
||||
@echo "============================================="
|
||||
echo "mode: count" > coverage.out
|
||||
for d in $(TESTFOLDER_TAI); do \
|
||||
$(GO) test -tags $(TESTTAGS) -v -timeout=5m -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") $$d > tmp.out; \
|
||||
cat tmp.out; \
|
||||
if grep -q "^--- FAIL" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "^FAIL" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "^panic:" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "build failed" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "setup failed" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "runtime error" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
if [ -f profile.out ]; then \
|
||||
cat profile.out | grep -v "mode:" >> coverage.out; \
|
||||
rm profile.out; \
|
||||
fi; \
|
||||
done
|
||||
@echo ""
|
||||
@echo "============================================="
|
||||
@echo "All Tai SDK tests passed"
|
||||
@echo "============================================="
|
||||
|
||||
# Proto codegen
|
||||
.PHONY: proto
|
||||
proto:
|
||||
protoc --go_out=. --go_opt=paths=source_relative \
|
||||
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
|
||||
grpc/pb/yao.proto
|
||||
|
||||
# gRPC Unit Test
|
||||
.PHONY: unit-test-grpc
|
||||
unit-test-grpc:
|
||||
echo "mode: count" > coverage.out
|
||||
for d in $(TESTFOLDER_GRPC); do \
|
||||
$(GO) test -tags $(TESTTAGS) -v -timeout=10m \
|
||||
-covermode=count -coverprofile=profile.out \
|
||||
-coverpkg=$$(echo $$d | sed "s/\/test$$//g") \
|
||||
-skip='TestMemoryLeak|TestIsolateDisposal|TestLeak_|TestScenario_' \
|
||||
$$d > tmp.out; \
|
||||
cat tmp.out; \
|
||||
if grep -q "^--- FAIL" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "^FAIL" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "^panic:" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "build failed" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "setup failed" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
elif grep -q "runtime error" tmp.out; then \
|
||||
rm tmp.out; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
if [ -f profile.out ]; then \
|
||||
cat profile.out | grep -v "mode:" >> coverage.out; \
|
||||
rm profile.out; \
|
||||
fi; \
|
||||
done
|
||||
|
||||
# Benchmark Test
|
||||
.PHONY: benchmark
|
||||
benchmark:
|
||||
@echo ""
|
||||
@echo "============================================="
|
||||
@echo "Running Benchmark Tests (agent, trace, event)..."
|
||||
@echo "============================================="
|
||||
@for d in $$($(GO) list ./agent/... ./trace/... ./event/...); do \
|
||||
if $(GO) test -list=Benchmark $$d 2>/dev/null | grep -q "^Benchmark"; then \
|
||||
echo ""; \
|
||||
echo "📊 Benchmarking: $$d"; \
|
||||
echo "---------------------------------------------"; \
|
||||
$(GO) test -bench=. -benchmem -benchtime=100x -run='^$$' $$d || true; \
|
||||
fi; \
|
||||
done
|
||||
@echo ""
|
||||
@echo "============================================="
|
||||
@echo "✅ All benchmarks completed"
|
||||
@echo "============================================="
|
||||
|
||||
# Memory Leak Detection Test
|
||||
.PHONY: memory-leak
|
||||
memory-leak:
|
||||
@echo ""
|
||||
@echo "============================================="
|
||||
@echo "Running Memory Leak Detection (agent, trace, event)..."
|
||||
@echo "============================================="
|
||||
@for d in $$($(GO) list ./agent/... ./trace/... ./event/...); do \
|
||||
if $(GO) test -list='TestMemoryLeak|TestIsolateDisposal|TestGoroutineLeak|TestLeak_|TestScenario_' $$d 2>/dev/null | grep -qE "^Test(MemoryLeak|IsolateDisposal|GoroutineLeak|Leak_|Scenario_)"; then \
|
||||
echo ""; \
|
||||
echo "🔍 Memory Leak Detection: $$d"; \
|
||||
echo "---------------------------------------------"; \
|
||||
$(GO) test -run='TestMemoryLeak|TestIsolateDisposal|TestGoroutineLeak|TestLeak_|TestScenario_' -v -timeout=5m $$d || exit 1; \
|
||||
fi; \
|
||||
done
|
||||
@echo ""
|
||||
@echo "============================================="
|
||||
@echo "✅ All memory leak tests passed"
|
||||
@echo "============================================="
|
||||
|
||||
# Run all tests (unit + benchmark + memory leak)
|
||||
.PHONY: test
|
||||
test: unit-test benchmark memory-leak
|
||||
|
||||
.PHONY: fmt
|
||||
fmt:
|
||||
$(GOFMT) -w $(GOFILES)
|
||||
|
|
@ -452,6 +51,8 @@ fmt-check:
|
|||
fi;
|
||||
|
||||
vet:
|
||||
$(GO) env -w GONOPROXY=github.com/yaoapp/gou
|
||||
$(GO) env -w GOPRIVATE=github.com/yaoapp/gou
|
||||
$(GO) vet $(VETPACKAGES)
|
||||
|
||||
.PHONY: lint
|
||||
|
|
@ -509,88 +110,36 @@ pack: bindata fmt
|
|||
|
||||
.PHONY: bindata
|
||||
bindata:
|
||||
|
||||
# Setup Workdir
|
||||
rm -rf .tmp/data
|
||||
rm -rf .tmp/yao-init
|
||||
mkdir -p .tmp/data
|
||||
|
||||
# Checkout init
|
||||
git clone https://github.com/YaoApp/yao-init.git .tmp/yao-init
|
||||
rm -rf .tmp/yao-init/.git
|
||||
rm -rf .tmp/yao-init/.gitignore
|
||||
rm -rf .tmp/yao-init/LICENSE
|
||||
# rm -rf .tmp/yao-init/README.md
|
||||
|
||||
# Copy Files
|
||||
cp -r .tmp/yao-init .tmp/data/init
|
||||
cp -r ui .tmp/data/
|
||||
cp -r ui .tmp/data/public
|
||||
cp -r cui .tmp/data/
|
||||
cp -r yao .tmp/data/
|
||||
cp -r sui/libsui .tmp/data/
|
||||
find .tmp/data -name ".DS_Store" -type f -delete
|
||||
go-bindata -fs -pkg data -o data/bindata.go -prefix ".tmp/data/" .tmp/data/...
|
||||
rm -rf .tmp/data
|
||||
rm -rf .tmp/yao-init
|
||||
|
||||
# make artifacts-linux
|
||||
.PHONY: artifacts-linux
|
||||
artifacts-linux: clean
|
||||
mkdir -p dist/release
|
||||
|
||||
# Building CUI v1.0
|
||||
export NODE_ENV=production
|
||||
# rm -f ../cui-v1.0/pnpm-lock.yaml
|
||||
echo "BASE=__yao_admin_root" > ../cui-v1.0/packages/cui/.env
|
||||
cd ../cui-v1.0 && pnpm install --no-frozen-lockfile && pnpm run build
|
||||
|
||||
# Init Application
|
||||
cd ../yao-init && rm -rf .git
|
||||
cd ../yao-init && rm -rf .gitignore
|
||||
cd ../yao-init && rm -rf LICENSE
|
||||
# cd ../yao-init rm -rf README.md
|
||||
|
||||
# Switch .env login URLs from dev mode (__yao_admin_root) to release mode (dashboard)
|
||||
sed -i.bak 's|AFTER_LOGIN_SUCCESS_URL="/__yao_admin_root/|# AFTER_LOGIN_SUCCESS_URL="/__yao_admin_root/|g' ../yao-init/.env
|
||||
sed -i.bak 's|AFTER_LOGIN_FAILURE_URL="/__yao_admin_root/|# AFTER_LOGIN_FAILURE_URL="/__yao_admin_root/|g' ../yao-init/.env
|
||||
sed -i.bak 's|# AFTER_LOGIN_SUCCESS_URL="/dashboard/|AFTER_LOGIN_SUCCESS_URL="/dashboard/|g' ../yao-init/.env
|
||||
sed -i.bak 's|# AFTER_LOGIN_FAILURE_URL="/dashboard/|AFTER_LOGIN_FAILURE_URL="/dashboard/|g' ../yao-init/.env
|
||||
rm -f ../yao-init/.env.bak
|
||||
|
||||
# Yao Builder
|
||||
# Remove Yao Builder - DUI PageBuilder component will provide online design for pure HTML pages or SUI pages in the future.
|
||||
# mkdir -p .tmp/data/builder
|
||||
# curl -o .tmp/yao-builder-latest.tar.gz https://release-sv.yaoapps.com/archives/yao-builder-latest.tar.gz
|
||||
# tar -zxvf .tmp/yao-builder-latest.tar.gz -C .tmp/data/builder
|
||||
# rm -rf .tmp/yao-builder-latest.tar.gz
|
||||
# Building UI
|
||||
sed -ie "s/url('\/icon/url('\/xiang\/icon/g" ../ui/public/icon/md_icon.css
|
||||
cd ../ui && npm install && npm run build
|
||||
|
||||
# Packing
|
||||
# ** CUI will be renamed to CUI in the feature. and move to the new repository. **
|
||||
# ** new repository: https://github.com/YaoApp/cui.git **
|
||||
mkdir -p .tmp/data/cui
|
||||
cp -r ./ui .tmp/data/ui
|
||||
cp -r ../cui-v1.0/packages/cui/dist .tmp/data/cui/v1.0
|
||||
cp -r ../yao-init .tmp/data/init
|
||||
mkdir -p .tmp/data
|
||||
cp -r ../ui/dist .tmp/data/ui
|
||||
cp -r yao .tmp/data/
|
||||
cp -r sui/libsui .tmp/data/
|
||||
go-bindata -fs -pkg data -o data/bindata.go -prefix ".tmp/data/" .tmp/data/...
|
||||
rm -rf .tmp/data
|
||||
rm -rf .tmp/ui
|
||||
|
||||
# Replace PRVERSION
|
||||
sed -ie "s/const PRVERSION = \"DEV\"/const PRVERSION = \"${COMMIT}-${NOW}\"/g" share/const.go
|
||||
@CUI_COMMIT=$$(cd ../cui-v1.0 && git log | head -n 1 | awk '{print substr($$2, 0, 12)}') && \
|
||||
sed -ie "s/const PRCUI = \"DEV\"/const PRCUI = \"$$CUI_COMMIT-${NOW}\"/g" share/const.go
|
||||
sed -ie "s/const PRVERSION = \"DEV\"/const PRVERSION = \"${COMMIT}\"/g" share/const.go
|
||||
|
||||
# Making artifacts - dev builds (full debug symbols, ~158M)
|
||||
# Making artifacts
|
||||
mkdir -p dist
|
||||
CGO_ENABLED=1 CGO_LDFLAGS="-static" GOOS=linux GOARCH=amd64 go build -v -o dist/yao-${VERSION}-linux-amd64
|
||||
CGO_ENABLED=1 CGO_LDFLAGS="-static" LD_LIBRARY_PATH=/usr/lib/gcc-cross/aarch64-linux-gnu/13 GOOS=linux GOARCH=arm64 CC=aarch64-linux-gnu-gcc-13 CXX=aarch64-linux-gnu-g++-13 go build -v -o dist/yao-${VERSION}-linux-arm64
|
||||
|
||||
# Making artifacts - prod builds (stripped, ~111M)
|
||||
sed -i.tmp 's/const BUILDOPTIONS = ""/const BUILDOPTIONS = "-s -w (production, stripped)"/g' share/const.go && rm -f share/const.go.tmp
|
||||
CGO_ENABLED=1 CGO_LDFLAGS="-static" GOOS=linux GOARCH=amd64 go build -v -ldflags="-s -w" -o dist/yao-${VERSION}-linux-amd64-prod
|
||||
CGO_ENABLED=1 CGO_LDFLAGS="-static" LD_LIBRARY_PATH=/usr/lib/gcc-cross/aarch64-linux-gnu/13 GOOS=linux GOARCH=arm64 CC=aarch64-linux-gnu-gcc-13 CXX=aarch64-linux-gnu-g++-13 go build -v -ldflags="-s -w" -o dist/yao-${VERSION}-linux-arm64-prod
|
||||
CGO_ENABLED=1 CGO_LDFLAGS="-static" GOOS=linux GOARCH=arm64 CC=aarch64-linux-gnu-gcc CXX=aarch64-linux-gnu-g++ go build -v -o dist/yao-${VERSION}-linux-arm64
|
||||
|
||||
mkdir -p dist/release
|
||||
mv dist/yao-*-* dist/release/
|
||||
|
|
@ -605,74 +154,52 @@ artifacts-linux: clean
|
|||
# make artifacts-macos
|
||||
.PHONY: artifacts-macos
|
||||
artifacts-macos: clean
|
||||
|
||||
mkdir -p dist/release
|
||||
|
||||
# Building CUI v1.0
|
||||
export NODE_ENV=production
|
||||
# rm -f ../cui-v1.0/pnpm-lock.yaml
|
||||
echo "BASE=__yao_admin_root" > ../cui-v1.0/packages/cui/.env
|
||||
cd ../cui-v1.0 && pnpm install --no-frozen-lockfile && pnpm run build
|
||||
|
||||
# Init Application
|
||||
cd ../yao-init && rm -rf .git
|
||||
cd ../yao-init && rm -rf .gitignore
|
||||
cd ../yao-init && rm -rf LICENSE
|
||||
# cd ../yao-init && rm -rf README.md
|
||||
|
||||
# Switch .env login URLs from dev mode (__yao_admin_root) to release mode (dashboard)
|
||||
sed -i.bak 's|AFTER_LOGIN_SUCCESS_URL="/__yao_admin_root/|# AFTER_LOGIN_SUCCESS_URL="/__yao_admin_root/|g' ../yao-init/.env
|
||||
sed -i.bak 's|AFTER_LOGIN_FAILURE_URL="/__yao_admin_root/|# AFTER_LOGIN_FAILURE_URL="/__yao_admin_root/|g' ../yao-init/.env
|
||||
sed -i.bak 's|# AFTER_LOGIN_SUCCESS_URL="/dashboard/|AFTER_LOGIN_SUCCESS_URL="/dashboard/|g' ../yao-init/.env
|
||||
sed -i.bak 's|# AFTER_LOGIN_FAILURE_URL="/dashboard/|AFTER_LOGIN_FAILURE_URL="/dashboard/|g' ../yao-init/.env
|
||||
rm -f ../yao-init/.env.bak
|
||||
# Building UI
|
||||
sed -ie "s/url('\/icon/url('\/xiang\/icon/g" ../ui/public/icon/md_icon.css
|
||||
cd ../ui && npm install && npm run build
|
||||
|
||||
# Packing
|
||||
mkdir -p .tmp/data/cui
|
||||
cp -r ./ui .tmp/data/ui
|
||||
cp -r ../cui-v1.0/packages/cui/dist .tmp/data/cui/v1.0
|
||||
cp -r ../yao-init .tmp/data/init
|
||||
mkdir -p .tmp/data
|
||||
cp -r ../ui/dist .tmp/data/ui
|
||||
cp -r yao .tmp/data/
|
||||
cp -r sui/libsui .tmp/data/
|
||||
go-bindata -fs -pkg data -o data/bindata.go -prefix ".tmp/data/" .tmp/data/...
|
||||
rm -rf .tmp/data
|
||||
rm -rf .tmp/ui
|
||||
|
||||
# Replace PRVERSION
|
||||
sed -ie "s/const PRVERSION = \"DEV\"/const PRVERSION = \"${COMMIT}-${NOW}\"/g" share/const.go
|
||||
@CUI_COMMIT=$$(cd ../cui-v1.0 && git log | head -n 1 | awk '{print substr($$2, 0, 12)}') && \
|
||||
sed -ie "s/const PRCUI = \"DEV\"/const PRCUI = \"$$CUI_COMMIT-${NOW}\"/g" share/const.go
|
||||
sed -ie "s/const PRVERSION = \"DEV\"/const PRVERSION = \"${COMMIT}\"/g" share/const.go
|
||||
|
||||
# Making artifacts - dev builds (full debug symbols)
|
||||
# Making artifacts
|
||||
mkdir -p dist
|
||||
CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -v -o dist/yao-${VERSION}-darwin-amd64
|
||||
CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -v -o dist/yao-${VERSION}-darwin-arm64
|
||||
|
||||
# Making artifacts - prod builds (stripped, no UPX on macOS)
|
||||
sed -i.tmp 's/const BUILDOPTIONS = ""/const BUILDOPTIONS = "-s -w (production, stripped)"/g' share/const.go && rm -f share/const.go.tmp
|
||||
CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -v -ldflags="-s -w" -o dist/yao-${VERSION}-darwin-amd64-prod
|
||||
CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -v -ldflags="-s -w" -o dist/yao-${VERSION}-darwin-arm64-prod
|
||||
|
||||
mkdir -p dist/release
|
||||
mv dist/yao-*-* dist/release/
|
||||
chmod +x dist/release/yao-*-*
|
||||
ls -l dist/release/
|
||||
dist/release/yao-${VERSION}-darwin-amd64 version
|
||||
|
||||
# Reset const
|
||||
# cp -f share/const.goe share/const.go
|
||||
# rm -f share/const.goe
|
||||
|
||||
.PHONY: debug
|
||||
debug: clean
|
||||
mkdir -p dist/release
|
||||
|
||||
# Packing
|
||||
# mkdir -p .tmp/data
|
||||
# cp -r ui .tmp/data/ui
|
||||
# cp -r yao .tmp/data/
|
||||
# go-bindata -fs -pkg data -o data/bindata.go -prefix ".tmp/data/" .tmp/data/...
|
||||
# rm -rf .tmp/data
|
||||
mkdir -p .tmp/data
|
||||
cp -r ui .tmp/data/ui
|
||||
cp -r yao .tmp/data/
|
||||
go-bindata -fs -pkg data -o data/bindata.go -prefix ".tmp/data/" .tmp/data/...
|
||||
rm -rf .tmp/data
|
||||
|
||||
|
||||
# Replace PRVERSION
|
||||
sed -ie "s/const PRVERSION = \"DEV\"/const PRVERSION = \"${COMMIT}-${NOW}-debug\"/g" share/const.go
|
||||
sed -ie "s/const PRVERSION = \"DEV\"/const PRVERSION = \"${COMMIT}-debug\"/g" share/const.go
|
||||
|
||||
# Making artifacts
|
||||
mkdir -p dist
|
||||
|
|
@ -683,235 +210,53 @@ debug: clean
|
|||
cp -f share/const.goe share/const.go
|
||||
rm -f share/const.goe
|
||||
|
||||
# make prepare (build CUI, yao-init, bindata - shared by release and prod)
|
||||
.PHONY: prepare
|
||||
prepare: clean
|
||||
.PHONY: release
|
||||
release: clean
|
||||
mkdir -p dist/release
|
||||
mkdir .tmp
|
||||
|
||||
# Building CUI v0.9
|
||||
mkdir -p .tmp/cui/v0.9/dist
|
||||
echo "CUI v0.9" > .tmp/cui/v0.9/dist/index.html
|
||||
|
||||
# Building CUI v1.0
|
||||
# ** CUI will be renamed to CUI in the feature. and move to the new repository. **
|
||||
# ** new repository: https://github.com/YaoApp/cui.git **
|
||||
export NODE_ENV=production
|
||||
git clone https://github.com/YaoApp/cui.git .tmp/cui/v1.0
|
||||
# cd .tmp/cui/v1.0 && git checkout 5002c3fded585aaa69a4366135b415ea3234964e
|
||||
echo "BASE=__yao_admin_root" > .tmp/cui/v1.0/packages/cui/.env
|
||||
cd .tmp/cui/v1.0 && pnpm install --no-frozen-lockfile && pnpm run build
|
||||
CUI_COMMIT=$$(cd .tmp/cui/v1.0 && git rev-parse --short HEAD)
|
||||
|
||||
# Checkout init
|
||||
git clone https://github.com/YaoApp/yao-init.git .tmp/yao-init
|
||||
rm -rf .tmp/yao-init/.git
|
||||
rm -rf .tmp/yao-init/.gitignore
|
||||
rm -rf .tmp/yao-init/LICENSE
|
||||
rm -rf .tmp/yao-init/README.md
|
||||
|
||||
# Switch .env login URLs from dev mode (__yao_admin_root) to release mode (dashboard)
|
||||
sed -i.bak 's|AFTER_LOGIN_SUCCESS_URL="/__yao_admin_root/|# AFTER_LOGIN_SUCCESS_URL="/__yao_admin_root/|g' .tmp/yao-init/.env
|
||||
sed -i.bak 's|AFTER_LOGIN_FAILURE_URL="/__yao_admin_root/|# AFTER_LOGIN_FAILURE_URL="/__yao_admin_root/|g' .tmp/yao-init/.env
|
||||
sed -i.bak 's|# AFTER_LOGIN_SUCCESS_URL="/dashboard/|AFTER_LOGIN_SUCCESS_URL="/dashboard/|g' .tmp/yao-init/.env
|
||||
sed -i.bak 's|# AFTER_LOGIN_FAILURE_URL="/dashboard/|AFTER_LOGIN_FAILURE_URL="/dashboard/|g' .tmp/yao-init/.env
|
||||
rm -f .tmp/yao-init/.env.bak
|
||||
|
||||
# Yao Builder
|
||||
# Remove Yao Builder - DUI PageBuilder component will provide online design for pure HTML pages or SUI pages in the future.
|
||||
# mkdir -p .tmp/data/builder
|
||||
# curl -o .tmp/yao-builder-latest.tar.gz https://release-sv.yaoapps.com/archives/yao-builder-latest.tar.gz
|
||||
# tar -zxvf .tmp/yao-builder-latest.tar.gz -C .tmp/data/builder
|
||||
# rm -rf .tmp/yao-builder-latest.tar.gz
|
||||
# Building UI
|
||||
git clone https://github.com/YaoApp/xgen.git .tmp/ui
|
||||
sed -ie "s/url('\/icon/url('\/xiang\/icon/g" .tmp/ui/public/icon/md_icon.css
|
||||
cd .tmp/ui && cnpm install && npm run build
|
||||
|
||||
# Packing
|
||||
cp -f data/bindata.go data/bindata.go.bak
|
||||
mkdir -p .tmp/data/cui
|
||||
cp -r ./ui .tmp/data/ui
|
||||
cp -r ./yao .tmp/data/yao
|
||||
cp -r ./sui/libsui .tmp/data/libsui
|
||||
cp -r .tmp/cui/v0.9/dist .tmp/data/cui/v0.9
|
||||
cp -r .tmp/cui/v1.0/packages/cui/dist .tmp/data/cui/v1.0
|
||||
cp -r .tmp/yao-init .tmp/data/init
|
||||
mkdir -p .tmp/data
|
||||
cp -r .tmp/ui/dist .tmp/data/ui
|
||||
cp -r yao .tmp/data/
|
||||
go-bindata -fs -pkg data -o data/bindata.go -prefix ".tmp/data/" .tmp/data/...
|
||||
rm -rf .tmp/data
|
||||
rm -rf .tmp/ui
|
||||
|
||||
# Replace PRVERSION
|
||||
cp -f share/const.go share/const.go.bak
|
||||
sed -ie "s/const PRVERSION = \"DEV\"/const PRVERSION = \"${COMMIT}-${NOW}\"/g" share/const.go
|
||||
@CUI_COMMIT=$$(cd .tmp/cui/v1.0 && git log | head -n 1 | awk '{print substr($$2, 0, 12)}') && \
|
||||
sed -ie "s/const PRCUI = \"DEV\"/const PRCUI = \"$$CUI_COMMIT-${NOW}\"/g" share/const.go
|
||||
sed -ie "s/const PRVERSION = \"DEV\"/const PRVERSION = \"${COMMIT}\"/g" share/const.go
|
||||
|
||||
# make release (development build only, ~158M)
|
||||
.PHONY: release
|
||||
release: prepare
|
||||
# Making artifacts - dev build
|
||||
# Making artifacts
|
||||
mkdir -p dist
|
||||
CGO_ENABLED=1 go build -v -o dist/release/yao
|
||||
chmod +x dist/release/yao
|
||||
|
||||
# Clean up and restore bindata.go and const.go
|
||||
cp data/bindata.go.bak data/bindata.go
|
||||
cp share/const.go.bak share/const.go
|
||||
rm data/bindata.go.bak
|
||||
rm share/const.go.bak
|
||||
rm -rf .tmp
|
||||
|
||||
# MacOS Application Signing
|
||||
@if [ "$(OS)" = "Darwin" ]; then \
|
||||
codesign --deep --force --verbose --timestamp --options runtime \
|
||||
--entitlements .github/codesign/entitlements.plist \
|
||||
--sign "${APPLE_SIGN}" dist/release/yao ; \
|
||||
fi
|
||||
|
||||
# make prod (production build only, ~111M on macOS)
|
||||
.PHONY: prod
|
||||
prod: prepare
|
||||
# Set BUILDOPTIONS
|
||||
@if [ "$$(uname)" = "Linux" ]; then \
|
||||
sed -i.tmp 's/const BUILDOPTIONS = ""/const BUILDOPTIONS = "-s -w +upx (production, compressed)"/g' share/const.go && rm -f share/const.go.tmp; \
|
||||
else \
|
||||
sed -i.tmp 's/const BUILDOPTIONS = ""/const BUILDOPTIONS = "-s -w (production, stripped)"/g' share/const.go && rm -f share/const.go.tmp; \
|
||||
fi
|
||||
|
||||
# Making artifacts - prod build
|
||||
mkdir -p dist
|
||||
CGO_ENABLED=1 go build -v -ldflags="-s -w" -o dist/release/yao-prod
|
||||
chmod +x dist/release/yao-prod
|
||||
|
||||
# UPX compression (Linux only)
|
||||
@if [ "$$(uname)" = "Linux" ]; then \
|
||||
echo "Compressing with UPX..."; \
|
||||
if command -v upx > /dev/null 2>&1; then \
|
||||
upx --best dist/release/yao-prod; \
|
||||
else \
|
||||
echo "WARNING: UPX not found. Install with: apt install upx"; \
|
||||
echo "Skipping compression."; \
|
||||
fi; \
|
||||
else \
|
||||
echo "Note: UPX compression skipped on macOS (not supported)"; \
|
||||
fi
|
||||
|
||||
# Clean up and restore bindata.go and const.go
|
||||
cp data/bindata.go.bak data/bindata.go
|
||||
cp share/const.go.bak share/const.go
|
||||
rm data/bindata.go.bak
|
||||
rm share/const.go.bak
|
||||
rm -rf .tmp
|
||||
|
||||
# MacOS Application Signing
|
||||
@if [ "$(OS)" = "Darwin" ]; then \
|
||||
codesign --deep --force --verbose --timestamp --options runtime \
|
||||
--entitlements .github/codesign/entitlements.plist \
|
||||
--sign "${APPLE_SIGN}" dist/release/yao-prod ; \
|
||||
fi
|
||||
|
||||
@echo ""
|
||||
@echo "Done! Production binary:"
|
||||
@ls -lh dist/release/yao-prod
|
||||
@echo ""
|
||||
@echo "Test with: dist/release/yao-prod version --all"
|
||||
|
||||
# make release-all (build both dev and prod in one go)
|
||||
.PHONY: release-all
|
||||
release-all: prepare
|
||||
# Making artifacts - dev build (~158M)
|
||||
@echo "Building dev binary..."
|
||||
mkdir -p dist
|
||||
CGO_ENABLED=1 go build -v -o dist/release/yao
|
||||
chmod +x dist/release/yao
|
||||
|
||||
# Making artifacts - prod build (~111M on macOS)
|
||||
@echo "Building prod binary..."
|
||||
@if [ "$$(uname)" = "Linux" ]; then \
|
||||
sed -i.tmp 's/const BUILDOPTIONS = ""/const BUILDOPTIONS = "-s -w +upx (production, compressed)"/g' share/const.go && rm -f share/const.go.tmp; \
|
||||
else \
|
||||
sed -i.tmp 's/const BUILDOPTIONS = ""/const BUILDOPTIONS = "-s -w (production, stripped)"/g' share/const.go && rm -f share/const.go.tmp; \
|
||||
fi
|
||||
CGO_ENABLED=1 go build -v -ldflags="-s -w" -o dist/release/yao-prod
|
||||
chmod +x dist/release/yao-prod
|
||||
|
||||
# UPX compression (Linux only)
|
||||
@if [ "$$(uname)" = "Linux" ]; then \
|
||||
echo "Compressing with UPX..."; \
|
||||
if command -v upx > /dev/null 2>&1; then \
|
||||
upx --best dist/release/yao-prod; \
|
||||
else \
|
||||
echo "WARNING: UPX not found. Install with: apt install upx"; \
|
||||
echo "Skipping compression."; \
|
||||
fi; \
|
||||
else \
|
||||
echo "Note: UPX compression skipped on macOS (not supported)"; \
|
||||
fi
|
||||
|
||||
# Clean up and restore bindata.go and const.go
|
||||
cp data/bindata.go.bak data/bindata.go
|
||||
cp share/const.go.bak share/const.go
|
||||
rm data/bindata.go.bak
|
||||
rm share/const.go.bak
|
||||
rm -rf .tmp
|
||||
|
||||
# MacOS Application Signing
|
||||
@if [ "$(OS)" = "Darwin" ]; then \
|
||||
codesign --deep --force --verbose --timestamp --options runtime \
|
||||
--entitlements .github/codesign/entitlements.plist \
|
||||
--sign "${APPLE_SIGN}" dist/release/yao ; \
|
||||
codesign --deep --force --verbose --timestamp --options runtime \
|
||||
--entitlements .github/codesign/entitlements.plist \
|
||||
--sign "${APPLE_SIGN}" dist/release/yao-prod ; \
|
||||
fi
|
||||
|
||||
@echo ""
|
||||
@echo "Done! Binaries:"
|
||||
@ls -lh dist/release/yao dist/release/yao-prod
|
||||
@echo ""
|
||||
@echo "Test with:"
|
||||
@echo " dist/release/yao version --all"
|
||||
@echo " dist/release/yao-prod version --all"
|
||||
|
||||
# Reset const
|
||||
cp -f share/const.goe share/const.go
|
||||
rm share/const.goe
|
||||
|
||||
.PHONY: linux-release
|
||||
linux-release: clean
|
||||
mkdir -p dist/release
|
||||
mkdir .tmp
|
||||
|
||||
# Building CUI v1.0
|
||||
# ** CUI will be renamed to CUI in the feature. and move to the new repository. **
|
||||
# ** new repository: https://github.com/YaoApp/cui.git **
|
||||
export NODE_ENV=production
|
||||
git clone https://github.com/YaoApp/cui.git .tmp/cui/v1.0
|
||||
rm -f .tmp/cui/v1.0/pnpm-lock.yaml
|
||||
echo "BASE=__yao_admin_root" > .tmp/cui/v1.0/packages/cui/.env
|
||||
cd .tmp/cui/v1.0 && pnpm install --no-frozen-lockfile && pnpm run build
|
||||
|
||||
# Setup UI
|
||||
cd .tmp/cui/v1.0/packages/setup && pnpm install --no-frozen-lockfile && pnpm run build
|
||||
|
||||
|
||||
# Checkout init
|
||||
git clone https://github.com/YaoApp/yao-init.git .tmp/yao-init
|
||||
rm -rf .tmp/yao-init/.git
|
||||
rm -rf .tmp/yao-init/.gitignore
|
||||
rm -rf .tmp/yao-init/LICENSE
|
||||
rm -rf .tmp/yao-init/README.md
|
||||
|
||||
# Yao Builder
|
||||
# Remove Yao Builder - DUI PageBuilder component will provide online design for pure HTML pages or SUI pages in the future.
|
||||
# mkdir -p .tmp/data/builder
|
||||
# curl -o .tmp/yao-builder-latest.tar.gz https://release-sv.yaoapps.com/archives/yao-builder-latest.tar.gz
|
||||
# tar -zxvf .tmp/yao-builder-latest.tar.gz -C .tmp/data/builder
|
||||
# rm -rf .tmp/yao-builder-latest.tar.gz
|
||||
# Building UI
|
||||
git clone https://github.com/YaoApp/xgen.git .tmp/ui
|
||||
sed -ie "s/url('\/icon/url('\/xiang\/icon/g" .tmp/ui/public/icon/md_icon.css
|
||||
cd .tmp/ui && yarn install && yarn build
|
||||
|
||||
# Packing
|
||||
mkdir -p .tmp/data/cui
|
||||
cp -r ./ui .tmp/data/ui
|
||||
cp -r ./yao .tmp/data/yao
|
||||
cp -r .tmp/cui/v0.9/dist .tmp/data/cui/v0.9
|
||||
cp -r .tmp/cui/v1.0/packages/setup/build .tmp/data/cui/setup
|
||||
cp -r .tmp/cui/v1.0/packages/cui/dist .tmp/data/cui/v1.0
|
||||
cp -r .tmp/yao-init .tmp/data/init
|
||||
mkdir -p .tmp/data
|
||||
cp -r .tmp/ui/dist .tmp/data/ui
|
||||
cp -r yao .tmp/data/
|
||||
go-bindata -fs -pkg data -o data/bindata.go -prefix ".tmp/data/" .tmp/data/...
|
||||
rm -rf .tmp/data
|
||||
rm -rf .tmp/cui
|
||||
rm -rf .tmp/ui
|
||||
|
||||
# Making artifacts
|
||||
mkdir -p dist
|
||||
|
|
@ -923,4 +268,9 @@ linux-release: clean
|
|||
clean:
|
||||
rm -rf ./tmp
|
||||
rm -rf .tmp
|
||||
rm -rf dist
|
||||
rm -rf dist
|
||||
|
||||
# make migrate ( for unit test)
|
||||
.PHONY: migrate
|
||||
migrate:
|
||||
$(GO) test -tags $(TESTTAGS) -run TestCommandMigrate$
|
||||
143
README.md
143
README.md
|
|
@ -1,71 +1,124 @@
|
|||
# Yao — App Runtime for the AI Era
|
||||
<p align="center">
|
||||
<h1 align="center">YAO</h1>
|
||||
</p>
|
||||
|
||||
Yao is an open-source runtime for building AI agents and web applications — shipped as a single binary.
|
||||
<p align="center">
|
||||
<a aria-label="website" href="https://yaoapps.com" target="_blank">
|
||||
Website
|
||||
</a>
|
||||
·
|
||||
<a aria-label="producthunt" href="https://www.producthunt.com/posts/yao-app-engine" target="_blank">
|
||||
Producthunt
|
||||
</a>
|
||||
·
|
||||
<a aria-label="twitter" href="https://twitter.com/YaoApp" target="_blank">
|
||||
Twitter
|
||||
</a>
|
||||
·
|
||||
<a aria-label="slack" href="https://join.slack.com/t/yaoapps/shared_invite/zt-13dm0cwvo-R9Q8xFGbrLZUffeygm9tXQ" target="_blank">
|
||||
Slack
|
||||
</a>
|
||||
</p>
|
||||
|
||||

|
||||
<p align="center">
|
||||
<a aria-label="UnitTest" href="https://github.com/YaoApp/yao/actions/workflows/unit-test.yml" target="_blank">
|
||||
<img src="https://github.com/YaoApp/yao/actions/workflows/unit-test.yml/badge.svg">
|
||||
</a>
|
||||
<a aria-label="codecov" href="https://codecov.io/gh/YaoApp/yao" target="_blank">
|
||||
<img src="https://codecov.io/gh/YaoApp/yao/branch/main/graph/badge.svg?token=294Y05U71J">
|
||||
</a>
|
||||
<a aria-label="Go Report Card" href="https://goreportcard.com/report/github.com/yaoapp/yao" target="_blank">
|
||||
<img src="https://goreportcard.com/badge/github.com/yaoapp/yao">
|
||||
</a>
|
||||
<a aria-label="Go Reference" href="https://pkg.go.dev/github.com/yaoapp/yao" target="_blank">
|
||||
<img src="https://pkg.go.dev/badge/github.com/yaoapp/yao.svg">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
**🏠 Homepage:** [https://yaoagents.com](https://yaoagents.com)
|
||||

|
||||
|
||||
**📚 Docs:** [https://yaoagents.com/docs](https://yaoagents.com/docs)
|
||||
[中文介绍](README.zh-CN.md)
|
||||
|
||||
**🖥️ Yao Desktop:** [https://yaoagents.com/download](https://yaoagents.com/download)
|
||||
## Demo
|
||||
|
||||
---
|
||||
### Customer Relationship Management System
|
||||
|
||||
## How It Works
|
||||
A Customer Relationship Management System developed by Yao.
|
||||
|
||||
Think of Yao Agent as a **cage, not an animal**. What you put inside determines the behavior; the cage keeps it controlled.
|
||||
[https://demo-crm.yaoapps.com](https://demo-crm.yaoapps.com/xiang/login/admin?autoLogin=true)
|
||||
|
||||
Every request flows through the same pipeline:
|
||||
### Intelligent warehouse management system
|
||||
|
||||

|
||||
An example of cloud + edge IoT application, an unattended intelligent warehouse management system that supports face recognition and RFID.
|
||||
|
||||
`Create Hook` runs before the executor — inject context, enforce constraints, route requests.
|
||||
`Next Hook` runs after — validate output, trigger downstream actions, drive multi-step loops.
|
||||
**The AI does the heavy lifting. You define the boundaries.**
|
||||
[https://demo-wms.yaoapps.com](https://demo-wms.yaoapps.com/xiang/login/admin?autoLogin=true)
|
||||
|
||||
### Three Modes
|
||||
## Introduce
|
||||
|
||||
| Mode | Executor | When to use |
|
||||
|------|----------|-------------|
|
||||
| **LLM** | OpenAI, Anthropic, etc. | Conversational assistants, Q&A, content generation |
|
||||
| **CLI Agent** | OpenCode, Claude Code, Codex in a container | Computer use, sandbox isolation, SKILL ecosystem |
|
||||
| **Pure Hook** | Your own TypeScript code | Deterministic logic, routing, menu flows — no AI needed |
|
||||
**Yao allows developers to create web services by processes.** Yao is a low-code engine that creates a database model, writes API services, and describes dashboard interface just by JSON for web & hardware, no code, and 10x productivity.
|
||||
|
||||
All three share the same Hook interface. You can mix them freely — route some requests through the LLM, handle others with pure code, all inside a single `Create Hook`.
|
||||
Yao is based on the **flow-based** programming idea, developed in the **Go** language, and supports multiple ways to expand the data stream processor. This makes Yao extremely versatile, which can replace programming languages in most scenarios, and is 10 times more efficient than traditional programming languages in terms of reusability and coding efficiency; application performance and resource ratio Better than **PHP**, **JAVA** and other languages.
|
||||
|
||||
---
|
||||
Yao has a built-in data management system. By writing **JSON** to describe the interface layout, 90% of the common interface interaction functions can be realized. It is especially suitable for quickly making various management background, CRM, ERP and other internal enterprise systems. Special interactive functions can also be implemented by writing extension components or HTML pages. The built-in management system is not coupled with Yao, and any front-end technologies such as **VUE** and **React** can be used to implement the management interface.
|
||||
|
||||
## Features
|
||||
## Install
|
||||
|
||||
### Agent Framework
|
||||
Run the script under terminal: (MacOS/Linux)
|
||||
|
||||
- **TypeScript Hooks** — `Create` and `Next` hooks intercept every request; built-in V8 engine
|
||||
- **Native MCP Support** — Connect tools via process, SSE, or STDIO transport
|
||||
- **Memory API** — Four scopes: request-level, session, user, team
|
||||
- **Multi-Agent** — Delegate to specialist agents or call agents in parallel
|
||||
- **CLI Agent / Sandbox** — Run Claude Code (or other CLI runners) in an isolated container with VNC desktop support
|
||||
- **Skills Ecosystem** — Drop reusable capability packs (`SKILL.md`) into any CLI Agent
|
||||
```bash
|
||||
curl -fsSL https://website.yaoapps.com/install.sh | bash
|
||||
```
|
||||
|
||||
### Full-Stack Runtime
|
||||
For Windows users, please refer to the Installation and Debugging chapter: [Installation and debugging](https://yaoapps.com/en-US/doc/a.Introduction/b.Install)
|
||||
|
||||
Everything in a single executable:
|
||||
## Getting Started
|
||||
|
||||
- **Data Models** — Define database tables and relations in JSON/YAML
|
||||
- **REST APIs** — Map routes to model queries or TypeScript processors
|
||||
- **SUI Pages** — Component-based web UI with server-side rendering
|
||||
- **Chat UI (CUI)** — Built-in conversation interface for agents
|
||||
- **TypeScript** — Built-in V8 engine; no Node.js required
|
||||
- **Single Binary** — Runs on ARM64/x64; no Python, Node, or containers needed on the host
|
||||
### Step 1: Create a project
|
||||
|
||||
### Built-in Search
|
||||
Create a new project directory, enter the project directory, and run the `yao init` command to create a blank Yao application.
|
||||
|
||||
- **Vector Search** — Embeddings with OpenAI or FastEmbed
|
||||
- **Knowledge Graph** — Entity-relationship retrieval
|
||||
- **GraphRAG** — Hybrid vector + graph search
|
||||
```bash
|
||||
mkdir -p /data/crm # create project directory
|
||||
cd /data/crm # Enter the project directory
|
||||
yao init # run the initializer
|
||||
```
|
||||
|
||||
---
|
||||
After the command runs successfully, the `app.json file` , `db`, `ui` , `data` and other directories will be created
|
||||
|
||||
## About the Name
|
||||
```bash
|
||||
├── data # Used to store files generated by the application, such as pictures, PDFs, etc.
|
||||
├── db # Used to store SQLite database files
|
||||
│ └── yao.db
|
||||
└── ui # The static file server file directory, where custom front-end products can be placed. The files in this directory can be accessed through http://host:port/filename .
|
||||
└── app.json # Application configuration file, used to define the application name, etc.
|
||||
```
|
||||
|
||||
Yao (爻, yáo) is the fundamental symbol in the I Ching — the building block of the eight trigrams. Like a binary digit, it has two states. Their combinations describe the patterns of everything.
|
||||
### Step 2: Create the data table
|
||||
|
||||
Use the `yao migrate` command to create the data table, open the command line terminal, **run in the project root directory**:
|
||||
|
||||
```bash
|
||||
yao migrate
|
||||
```
|
||||
|
||||
initialization menu
|
||||
|
||||
```bash
|
||||
yao run flows.setmenu
|
||||
```
|
||||
|
||||
### Step 3: Start the service
|
||||
|
||||
Open a command line terminal, **run in the project root directory**:
|
||||
|
||||
```bash
|
||||
yao start
|
||||
```
|
||||
|
||||
1. Open a browser, visit `https://127.0.0.1:5099/xiang/login/admin`,
|
||||
|
||||
2. Enter the default username: `xiang@iqka.com`, password: `A123456p+`
|
||||
|
||||
## About Yao
|
||||
|
||||
Yao's name is derived from the Chinese character **yao (yáo)**, the basic symbol that makes up the Eight Trigrams. The Eight Trigrams is a symbol system created by the ancient god Fuxi after observing and summarizing the laws of nature, which can refer to everything. Yao has two states of yin and yang, like 0 and 1. The transformation of yin and yang of Yao drives the replacement of Eight Trigrams, so as to summarize and record the development law of things.
|
||||
|
|
|
|||
119
README.zh-CN.md
119
README.zh-CN.md
|
|
@ -1,73 +1,98 @@
|
|||
# Yao — AI 时代的应用运行时
|
||||
# Yao
|
||||
|
||||
Yao 是一个开源的 AI Agent 和 Web 应用运行时,以单一二进制的形式发布,下载即用。
|
||||
[](https://github.com/YaoApp/yao/actions/workflows/unit-test.yml)
|
||||
[](https://codecov.io/gh/YaoApp/yao)
|
||||
|
||||

|
||||

|
||||
|
||||
**🏠 官网:** [https://yaoagents.com](https://yaoagents.com)
|
||||
|
||||
**📚 文档:** [https://yaoagents.com/docs](https://yaoagents.com/docs)
|
||||
|
||||
**🖥️ Yao Desktop:** [https://yaoagents.com/download](https://yaoagents.com/download)
|
||||
Yao 是一款支持快速创建 Web 服务和管理后台的开源低代码应用引擎。
|
||||
|
||||
[English](README.md)
|
||||
|
||||
---
|
||||
官网: [https://yaoapps.com](https://yaoapps.com)
|
||||
|
||||
## 工作原理
|
||||
文档: [https://yaoapps.com/doc](https://yaoapps.com/doc)
|
||||
|
||||
Yao Agent 本质上是一个**笼子,而不是动物**。放进去的东西决定行为,笼子保证可控。
|
||||
## 演示
|
||||
|
||||
每个请求都经过同一套管道:
|
||||
### 客户管理系统
|
||||
|
||||

|
||||
使用 Yao 搭建的一套通用 CRM 管理系统。
|
||||
|
||||
`Create Hook` 在执行器前运行 —— 注入上下文、施加约束、路由请求。
|
||||
`Next Hook` 在执行器后运行 —— 校验输出、触发下游动作、驱动多步循环。
|
||||
**AI 负责干活,你来划定边界。**
|
||||
[https://demo-crm.yaoapps.com](https://demo-crm.yaoapps.com/xiang/login/admin?autoLogin=true)
|
||||
|
||||
### 三种模式
|
||||
### 智能仓库管理系统
|
||||
|
||||
| 模式 | 执行器 | 适用场景 |
|
||||
|------|--------|---------|
|
||||
| **LLM** | OpenAI、Anthropic 等 | 对话助手、问答、内容生成 |
|
||||
| **CLI Agent** | 容器中的 OpenCode、Claude Code、Codex | Computer Use、沙箱隔离、SKILL 生态 |
|
||||
| **纯 Hook** | 你自己的 TypeScript 代码 | 确定性逻辑、菜单路由、无需 AI 的业务流程 |
|
||||
使用 Yao 搭建的云+边物联网应用,支持人脸识别、RFID 的无人值守智能仓库管理系统。
|
||||
|
||||
三种模式共享同一套 Hook 接口,可以自由混合 —— 在一个 `Create Hook` 里,部分请求走 LLM,部分用纯代码处理。
|
||||
[https://demo-wms.yaoapps.com](https://demo-crm.yaoapps.com/xiang/login/admin?autoLogin=true)
|
||||
|
||||
---
|
||||
## 介绍
|
||||
|
||||
## 功能特性
|
||||
Yao 是一个只需使用 JSON 即可创建数据库模型、编写 API 接口、描述管理后台界面的低代码引擎,使用 Yao 构建的应用可运行在云端或物联网设备上。 开发者不需要写一行代码,就可以拥有 10 倍生产力。
|
||||
|
||||
### Agent 框架
|
||||
Yao 基于 **flow-based** 编程思想,采用 **Go** 语言开发,支持多种方式扩展数据流处理器。这使得 Yao 具有极好的**通用性**,大部分场景下可以代替编程语言, 在复用性和编码效率上是传统编程语言的 **10 倍**;应用性能和资源占比上优于 **PHP**, **JAVA** 等语言。
|
||||
|
||||
- **TypeScript Hook** — `Create` 和 `Next` 两个钩子拦截每一次请求;内置 V8 引擎
|
||||
- **原生 MCP 支持** — 通过 process、SSE 或 STDIO 传输协议接入工具
|
||||
- **Memory API** — 四个作用域:请求级、会话级、用户级、团队级
|
||||
- **多 Agent 协作** — 委派给专属 Agent 或并行调用多个 Agent
|
||||
- **CLI Agent / 沙箱** — 在隔离容器中运行 Claude Code 等 CLI 程序,支持 VNC 桌面
|
||||
- **Skills 生态** — 将可复用的能力包(`SKILL.md`)挂载到任意 CLI Agent
|
||||
Yao 内置了一套数据管理系统,通过编写 **JSON** 描述界面布局,即可实现 90% 常见界面交互功能,特别适合快速制作各类管理后台、CRM、ERP 等企业内部系统。对于特殊交互功能亦可通过编写扩展组件或 HTML 页面的方式实现。内置管理系统与 Yao 并不耦合,亦可采用 **VUE**, **React** 等任意前端技术实现管理界面。
|
||||
|
||||
### 全栈运行时
|
||||
## Install
|
||||
|
||||
一个二进制文件包含所有能力:
|
||||
在终端下运行脚本: ( MacOS / Linux )
|
||||
|
||||
- **数据模型** — 用 JSON/YAML 定义数据库表和关联关系
|
||||
- **REST API** — 将路由映射到模型查询或 TypeScript 处理器
|
||||
- **SUI 页面** — 组件化 Web UI,支持服务端渲染
|
||||
- **Chat UI(CUI)** — 内置对话界面,开箱即用
|
||||
- **TypeScript** — 内置 V8 引擎,不依赖 Node.js
|
||||
- **单一二进制** — 支持 ARM64/x64,宿主机无需 Python、Node 或容器
|
||||
```bash
|
||||
curl -fsSL https://website.yaoapps.com/install.sh | bash
|
||||
```
|
||||
|
||||
### 内置搜索
|
||||
Windows 用户请参考安装调试章节: [安装调试](https://yaoapps.com/doc/a.介绍/b.安装调试)
|
||||
|
||||
- **向量搜索** — 支持 OpenAI 或 FastEmbed 嵌入模型
|
||||
- **知识图谱** — 实体关系检索
|
||||
- **GraphRAG** — 向量 + 图谱混合搜索
|
||||
## 入门指南
|
||||
|
||||
---
|
||||
### Step 1: 创建项目
|
||||
|
||||
## 关于名字
|
||||
新建一个项目目录,进入项目目录,运行 `yao init` 命令,创建一个空白的 Yao 应用。
|
||||
|
||||
Yao 的名字源于汉字**爻(yáo)**,是构成八卦的基本符号。八卦,是上古大神伏羲观测自然规律后创造的符号体系。爻有阴阳两种状态,就像 0 和 1。爻的阴阳转换,驱动八卦更替,记录事物的发展规律。
|
||||
```bash
|
||||
mkdir -p /data/crm # 创建项目目录
|
||||
cd /data/crm # 进入项目目录
|
||||
yao init # 运行初始化程序
|
||||
```
|
||||
|
||||
命令运行成功后,将创建 `app.json文件` , `db`, `ui` , `data` 等目录
|
||||
|
||||
```bash
|
||||
├── data # 用于存放应用产生的文件,如图片,PDF等
|
||||
├── db # 用于存放 SQLite 数据库文件
|
||||
│ └── yao.db
|
||||
└── ui # 静态文件服务器文件目录,可以放置自定义前端制品,该目录下文件可通过 http://host:port/文件名称 访问。
|
||||
└── app.json # 应用配置文件, 用来定义应用名称等
|
||||
```
|
||||
|
||||
### Step 2: Create the data table
|
||||
|
||||
使用 `yao migrate` 命令创建数据表,打开命令行终端,**在项目根录下运行**:
|
||||
|
||||
```bash
|
||||
yao migrate
|
||||
```
|
||||
|
||||
初始化菜单
|
||||
|
||||
```bash
|
||||
yao run flows.setmenu
|
||||
```
|
||||
|
||||
### Step 3: Start the service
|
||||
|
||||
打开命令行终端,**在项目根录下运行**:
|
||||
|
||||
```bash
|
||||
yao start
|
||||
```
|
||||
|
||||
1. 打开浏览器, 访问 `https://127.0.0.1:5099/xiang/login/admin`,
|
||||
|
||||
2. 输入默认用户名: `xiang@iqka.com`, 密码: `A123456p+`
|
||||
|
||||
## 关于 Yao
|
||||
|
||||
Yao 的名字源于汉字**爻(yáo)**,是构成八卦的基本符号。八卦,是上古大神伏羲观测总结自然规律后,创造的一个可以指代万事万物的符号体系。爻,有阴阳两种状态,就像 0 和 1。爻的阴阳转换,驱动八卦更替,以此来总结记录事物的发展规律。
|
||||
|
|
|
|||
259
agent/README.md
259
agent/README.md
|
|
@ -1,259 +0,0 @@
|
|||
# Yao Agent
|
||||
|
||||
A powerful AI assistant framework for building intelligent conversational agents with tool integration, knowledge base search, and multi-agent orchestration.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Create an Assistant
|
||||
|
||||
```
|
||||
assistants/
|
||||
└── my-assistant/
|
||||
├── package.yao # Configuration
|
||||
├── prompts.yml # System prompts
|
||||
└── locales/
|
||||
└── en-us.yml # Translations
|
||||
```
|
||||
|
||||
**package.yao**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "{{ name }}",
|
||||
"connector": "gpt-4o",
|
||||
"description": "{{ description }}",
|
||||
"placeholder": {
|
||||
"title": "{{ chat.title }}",
|
||||
"prompts": ["{{ chat.prompts.0 }}"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**prompts.yml**
|
||||
|
||||
```yaml
|
||||
- role: system
|
||||
content: |
|
||||
You are a helpful assistant.
|
||||
```
|
||||
|
||||
**locales/en-us.yml**
|
||||
|
||||
```yaml
|
||||
name: My Assistant
|
||||
description: A helpful AI assistant
|
||||
chat:
|
||||
title: New Chat
|
||||
prompts:
|
||||
- How can I help you today?
|
||||
```
|
||||
|
||||
### 2. Add Hooks (Optional)
|
||||
|
||||
Create `src/index.ts` for custom logic:
|
||||
|
||||
```typescript
|
||||
import { agent } from "@yao/runtime";
|
||||
|
||||
function Create(ctx: agent.Context, messages: agent.Message[]): agent.Create {
|
||||
// Preprocess messages before LLM call
|
||||
return { messages };
|
||||
}
|
||||
|
||||
function Next(ctx: agent.Context, payload: agent.Payload): agent.Next {
|
||||
// Post-process LLM response
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Test (Optional)
|
||||
|
||||
```bash
|
||||
# Run tests
|
||||
yao agent test -i "Hello, how are you?"
|
||||
|
||||
# Run tests from JSONL file
|
||||
yao agent test -i tests/inputs.jsonl -v
|
||||
|
||||
# Extract results for review
|
||||
yao agent extract output-*.jsonl
|
||||
```
|
||||
|
||||
### 4. Run
|
||||
|
||||
```bash
|
||||
yao start
|
||||
```
|
||||
|
||||
Access via API: `POST /v1/chat/completions`
|
||||
|
||||
## Examples
|
||||
|
||||
### Hook: Route to Specialist
|
||||
|
||||
```typescript
|
||||
// src/index.ts
|
||||
function Create(ctx: agent.Context, messages: agent.Message[]): agent.Create {
|
||||
const last = messages[messages.length - 1]?.content || "";
|
||||
if (last.includes("refund")) {
|
||||
return { delegate: { agent_id: "refund-specialist", messages } };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
### Database Query
|
||||
|
||||
```json
|
||||
// package.yao - Enable auto DB search
|
||||
{ "db": { "models": ["orders", "products"] } }
|
||||
```
|
||||
|
||||
```bash
|
||||
# Test: Agent auto-generates QueryDSL and searches database
|
||||
yao agent test -i "Find orders over $1000 from last month"
|
||||
```
|
||||
|
||||
### MCP Tools (Process Transport)
|
||||
|
||||
```json
|
||||
// mcps/tools.mcp.yao - Define MCP server with Yao Processes
|
||||
{
|
||||
"label": "Tools",
|
||||
"transport": "process",
|
||||
"tools": {
|
||||
"search_orders": "models.order.Paginate",
|
||||
"create_order": "models.order.Create"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
// mcps/mapping/tools/schemes/search_orders.in.yao - Input schema
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"keyword": { "type": "string" },
|
||||
"page": { "type": "integer" }
|
||||
},
|
||||
"x-process-args": [":arguments"]
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
// package.yao
|
||||
{ "mcp": { "servers": [{ "server_id": "tools" }] } }
|
||||
```
|
||||
|
||||
### Sidebar Page (Display Data)
|
||||
|
||||
Pages render in the right sidebar during conversation to display structured data:
|
||||
|
||||
```html
|
||||
<!-- pages/result/result.html - Display query results -->
|
||||
<div class="result-panel">
|
||||
<h3>{{ title }}</h3>
|
||||
<table s:if="{{ rows.length > 0 }}">
|
||||
<tr s:for="{{ rows }}" s:for-item="row">
|
||||
<td>{{ row.name }}</td>
|
||||
<td>{{ row.value }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
```
|
||||
|
||||
```bash
|
||||
yao sui build agent # Build pages
|
||||
```
|
||||
|
||||
```javascript
|
||||
// In hook: send action to open page in sidebar
|
||||
ctx.Send({
|
||||
type: "action",
|
||||
props: {
|
||||
name: "navigate",
|
||||
payload: {
|
||||
route: "/agents/my-assistant/result",
|
||||
title: "Query Results",
|
||||
query: { id: "123" }, // Passed as $query in page
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Configuration](docs/configuration.md) - Assistant settings, connectors, options
|
||||
- [Prompts](docs/prompts.md) - System prompts and prompt presets
|
||||
- [Hooks](docs/hooks.md) - Create/Next hooks and agent lifecycle
|
||||
- [Context API](docs/context-api.md) - Messaging, memory, trace, MCP
|
||||
- [MCP Integration](docs/mcp.md) - Tool servers and resources
|
||||
- [Models](docs/models.md) - Assistant-scoped data models
|
||||
- [Search](docs/search.md) - Web, knowledge base, and database search
|
||||
- [Pages](docs/pages.md) - Web UI for agents (SUI framework)
|
||||
- [Iframe Integration](docs/iframe.md) - Iframe communication with CUI
|
||||
- [Internationalization](docs/i18n.md) - Multi-language support
|
||||
- [Testing](docs/testing.md) - Agent testing framework
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Request
|
||||
A[User Request]
|
||||
end
|
||||
|
||||
subgraph Create["Create Hook"]
|
||||
B1[Preprocess Messages]
|
||||
B2[Configure LLM]
|
||||
B3[Delegate to Agent]
|
||||
end
|
||||
|
||||
subgraph LLM["LLM Call"]
|
||||
C1[Load Prompts]
|
||||
C2[Generate Response]
|
||||
end
|
||||
|
||||
subgraph Tools["Tool Execution"]
|
||||
D1[MCP Tools]
|
||||
D2[Search]
|
||||
D3[Memory]
|
||||
end
|
||||
|
||||
subgraph Next["Next Hook"]
|
||||
E1[Process Results]
|
||||
E2[Transform Output]
|
||||
E3[Delegate to Agent]
|
||||
end
|
||||
|
||||
subgraph Response
|
||||
F[Stream Response]
|
||||
end
|
||||
|
||||
A --> Create
|
||||
Create --> LLM
|
||||
LLM --> Tools
|
||||
Tools --> Next
|
||||
Next --> Response
|
||||
Next -.->|Continue| LLM
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
OpenAPI endpoints (base URL: `/v1`):
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
| -------------------------------------- | ------ | --------------------- |
|
||||
| `/v1/chat/completions` | POST | Chat with assistant |
|
||||
| `/v1/chat/sessions` | GET | List chat sessions |
|
||||
| `/v1/chat/sessions/:chat_id` | GET | Get chat session |
|
||||
| `/v1/chat/sessions/:chat_id/messages` | GET | Get messages |
|
||||
| `/v1/agent/assistants` | GET | List assistants |
|
||||
| `/v1/agent/assistants/:id` | GET | Get assistant details |
|
||||
| `/v1/file/:uploaderID` | POST | Upload files |
|
||||
| `/v1/file/:uploaderID/:fileID` | GET | Get file info |
|
||||
| `/v1/file/:uploaderID/:fileID/content` | GET | Download file |
|
||||
|
||||
## License
|
||||
|
||||
This project is part of the Yao App Engine and follows the [Yao Open Source License](../LICENSE).
|
||||
|
|
@ -1,327 +0,0 @@
|
|||
package agent
|
||||
|
||||
// type customResponseRecorder struct {
|
||||
// *httptest.ResponseRecorder
|
||||
// closeChannel chan bool
|
||||
// }
|
||||
|
||||
// func (r *customResponseRecorder) CloseNotify() <-chan bool {
|
||||
// return r.closeChannel
|
||||
// }
|
||||
|
||||
// func newCustomResponseRecorder() *customResponseRecorder {
|
||||
// return &customResponseRecorder{
|
||||
// ResponseRecorder: httptest.NewRecorder(),
|
||||
// closeChannel: make(chan bool, 1),
|
||||
// }
|
||||
// }
|
||||
|
||||
// func TestDSL_Prompts(t *testing.T) {
|
||||
// test.Prepare(t, config.Conf)
|
||||
// defer Test_clean(t)
|
||||
|
||||
// resetDB()
|
||||
// agent := &DSL{
|
||||
// Prompts: []Prompt{
|
||||
// {Role: "system", Content: "You are a helpful assistant", Name: "ai"},
|
||||
// {Role: "user", Content: "Hello", Name: "user"},
|
||||
// },
|
||||
// ConversationSetting: conversation.Setting{
|
||||
// Connector: "default",
|
||||
// Table: "chat_messages",
|
||||
// },
|
||||
// }
|
||||
// err := agent.newConversation()
|
||||
// assert.NoError(t, err)
|
||||
|
||||
// prompts := agent.prompts()
|
||||
// assert.Equal(t, 2, len(prompts))
|
||||
// assert.Equal(t, "system", prompts[0]["role"])
|
||||
// assert.Equal(t, "You are a helpful assistant", prompts[0]["content"])
|
||||
// assert.Equal(t, "ai", prompts[0]["name"])
|
||||
// }
|
||||
|
||||
// func TestDSL_ChatMessages(t *testing.T) {
|
||||
// test.Prepare(t, config.Conf)
|
||||
// defer Test_clean(t)
|
||||
|
||||
// resetDB()
|
||||
// agent := &DSL{
|
||||
// Prompts: []Prompt{
|
||||
// {Role: "system", Content: "You are a helpful assistant"},
|
||||
// },
|
||||
// ConversationSetting: conversation.Setting{
|
||||
// Connector: "default",
|
||||
// Table: "chat_messages",
|
||||
// },
|
||||
// }
|
||||
|
||||
// err := agent.newConversation()
|
||||
// assert.NoError(t, err)
|
||||
|
||||
// ctx := Context{
|
||||
// Sid: "test-session",
|
||||
// ChatID: "test-chat",
|
||||
// }
|
||||
|
||||
// messages, err := agent.chatMessages(ctx, "Hello AI")
|
||||
// assert.NoError(t, err)
|
||||
// assert.Equal(t, 2, len(messages))
|
||||
// assert.Equal(t, "system", messages[0]["role"])
|
||||
// assert.Equal(t, "user", messages[1]["role"])
|
||||
// assert.Equal(t, "Hello AI", messages[1]["content"])
|
||||
// }
|
||||
|
||||
// func TestDSL_Answer(t *testing.T) {
|
||||
// test.Prepare(t, config.Conf)
|
||||
// defer Test_clean(t)
|
||||
|
||||
// gin.SetMode(gin.TestMode)
|
||||
// w := newCustomResponseRecorder()
|
||||
// c, _ := gin.CreateTestContext(w)
|
||||
|
||||
// ctx := Context{
|
||||
// Sid: "test-session",
|
||||
// ChatID: "test-chat",
|
||||
// Context: context.Background(),
|
||||
// }
|
||||
|
||||
// resetDB()
|
||||
// agent := &DSL{
|
||||
// Connector: "gpt-3_5-turbo",
|
||||
// Option: map[string]interface{}{
|
||||
// "temperature": 0.7,
|
||||
// "max_tokens": 150,
|
||||
// },
|
||||
// Prompts: []Prompt{
|
||||
// {Role: "system", Content: "You are a helpful assistant"},
|
||||
// },
|
||||
// ConversationSetting: conversation.Setting{
|
||||
// Connector: "default",
|
||||
// Table: "chat_messages",
|
||||
// },
|
||||
// }
|
||||
|
||||
// err := agent.newAI()
|
||||
// assert.NoError(t, err)
|
||||
|
||||
// err = agent.newConversation()
|
||||
// assert.NoError(t, err)
|
||||
|
||||
// c.Request = httptest.NewRequest("POST", "/chat", nil)
|
||||
|
||||
// agent.AI = &mockAI{}
|
||||
|
||||
// err = agent.Answer(ctx, "Hello AI", c)
|
||||
// assert.NoError(t, err)
|
||||
// }
|
||||
|
||||
// // func TestDSL_NewAI(t *testing.T) {
|
||||
// // test.Prepare(t, config.Conf)
|
||||
// // defer Test_clean(t)
|
||||
|
||||
// // tests := []struct {
|
||||
// // name string
|
||||
// // connector string
|
||||
// // wantErr string
|
||||
// // }{
|
||||
// // {
|
||||
// // name: "Mock AI",
|
||||
// // connector: "mock",
|
||||
// // wantErr: "",
|
||||
// // },
|
||||
// // {
|
||||
// // name: "Specific mock model",
|
||||
// // connector: "mock:gpt-4",
|
||||
// // wantErr: "",
|
||||
// // },
|
||||
// // {
|
||||
// // name: "Invalid connector",
|
||||
// // connector: "invalid-connector",
|
||||
// // wantErr: "AI connector invalid-connector not found",
|
||||
// // },
|
||||
// // }
|
||||
|
||||
// // for _, tt := range tests {
|
||||
// // t.Run(tt.name, func(t *testing.T) {
|
||||
// // agent := &DSL{
|
||||
// // Connector: tt.connector,
|
||||
// // }
|
||||
// // agent.newConversation()
|
||||
|
||||
// // assert.Panics(t, func() {
|
||||
// // agent.newAI()
|
||||
// // })
|
||||
|
||||
// // })
|
||||
// // }
|
||||
// // }
|
||||
|
||||
// func TestDSL_Select(t *testing.T) {
|
||||
// test.Prepare(t, config.Conf)
|
||||
// defer Test_clean(t)
|
||||
|
||||
// resetDB()
|
||||
// agent := &DSL{
|
||||
// ConversationSetting: conversation.Setting{
|
||||
// Connector: "default",
|
||||
// Table: "chat_messages",
|
||||
// },
|
||||
// }
|
||||
|
||||
// err := agent.newConversation()
|
||||
// assert.NoError(t, err)
|
||||
|
||||
// err = agent.Select("invalid-model")
|
||||
// assert.Error(t, err)
|
||||
|
||||
// // err = agent.Select("gpt-3_5-turbo")
|
||||
// // assert.NoError(t, err)
|
||||
// // assert.NotNil(t, agent.AI)
|
||||
|
||||
// }
|
||||
|
||||
// // func TestDSL_NewConversation(t *testing.T) {
|
||||
// // test.Prepare(t, config.Conf)
|
||||
// // defer Test_clean(t)
|
||||
|
||||
// // tests := []struct {
|
||||
// // name string
|
||||
// // connector string
|
||||
// // wantErr bool
|
||||
// // }{
|
||||
// // {
|
||||
// // name: "Default connector",
|
||||
// // connector: "default",
|
||||
// // wantErr: false,
|
||||
// // },
|
||||
// // {
|
||||
// // name: "Empty connector",
|
||||
// // connector: "",
|
||||
// // wantErr: false,
|
||||
// // },
|
||||
// // {
|
||||
// // name: "Invalid connector",
|
||||
// // connector: "invalid-connector",
|
||||
// // wantErr: true,
|
||||
// // },
|
||||
// // }
|
||||
|
||||
// // for _, tt := range tests {
|
||||
// // t.Run(tt.name, func(t *testing.T) {
|
||||
// // agent := &DSL{
|
||||
// // ConversationSetting: conversation.Setting{
|
||||
// // Connector: tt.connector,
|
||||
// // },
|
||||
// // }
|
||||
// // assert.Panics(t, func() {
|
||||
// // agent.newConversation()
|
||||
// // })
|
||||
// // })
|
||||
// // }
|
||||
// // }
|
||||
|
||||
// func TestDSL_SaveHistory(t *testing.T) {
|
||||
// test.Prepare(t, config.Conf)
|
||||
// defer Test_clean(t)
|
||||
|
||||
// agent := &DSL{
|
||||
// ConversationSetting: conversation.Setting{
|
||||
// Connector: "default",
|
||||
// Table: "chat_messages",
|
||||
// },
|
||||
// }
|
||||
|
||||
// resetDB()
|
||||
// err := agent.newConversation()
|
||||
// assert.NoError(t, err)
|
||||
|
||||
// messages := []map[string]interface{}{
|
||||
// {
|
||||
// "role": "user",
|
||||
// "content": "Hello",
|
||||
// "name": "test-user",
|
||||
// },
|
||||
// }
|
||||
|
||||
// content := []byte("Hi there!")
|
||||
// agent.saveHistory("test-session", "test-chat", content, messages)
|
||||
|
||||
// // Verify the history was saved
|
||||
// history, err := agent.Conversation.GetHistory("test-session", "test-chat")
|
||||
// assert.NoError(t, err)
|
||||
// assert.NotEmpty(t, history)
|
||||
// }
|
||||
|
||||
// func TestDSL_Send(t *testing.T) {
|
||||
// test.Prepare(t, config.Conf)
|
||||
// defer Test_clean(t)
|
||||
|
||||
// gin.SetMode(gin.TestMode)
|
||||
// w := httptest.NewRecorder()
|
||||
// c, _ := gin.CreateTestContext(w)
|
||||
|
||||
// resetDB()
|
||||
// agent := &DSL{
|
||||
// ConversationSetting: conversation.Setting{
|
||||
// Connector: "default",
|
||||
// Table: "chat_messages",
|
||||
// },
|
||||
// }
|
||||
|
||||
// err := agent.newConversation()
|
||||
// assert.NoError(t, err)
|
||||
// ctx := Context{
|
||||
// Sid: "test-session",
|
||||
// ChatID: "test-chat",
|
||||
// }
|
||||
|
||||
// msg := &message.JSON{
|
||||
// Message: &message.Message{Text: "Test message"},
|
||||
// }
|
||||
// messages := []map[string]interface{}{
|
||||
// {"role": "user", "content": "Hello"},
|
||||
// }
|
||||
// content := []byte("Test content")
|
||||
|
||||
// err = agent.send(ctx, msg, messages, content, c)
|
||||
// assert.NoError(t, err)
|
||||
// }
|
||||
|
||||
// func Test_clean(t *testing.T) {
|
||||
// defer test.Clean()
|
||||
|
||||
// }
|
||||
|
||||
// func resetDB() {
|
||||
// sch := capsule.Global.Schema()
|
||||
// sch.DropTable("chat_messages")
|
||||
// }
|
||||
|
||||
// type mockAI struct{}
|
||||
|
||||
// func (m *mockAI) ChatCompletionsWith(ctx context.Context, messages []map[string]interface{}, options map[string]interface{}, callback func([]byte) int) (interface{}, *exception.Exception) {
|
||||
// callback([]byte(`{"choices":[{"delta":{"content":"Mock response"}}]}`))
|
||||
// callback([]byte(`{"choices":[{"finish_reason":"stop"}]}`))
|
||||
// return nil, nil
|
||||
// }
|
||||
|
||||
// func (m *mockAI) ChatCompletions(messages []map[string]interface{}, options map[string]interface{}, callback func([]byte) int) (interface{}, *exception.Exception) {
|
||||
// return nil, nil
|
||||
// }
|
||||
|
||||
// func (m *mockAI) GetContent(response interface{}) (string, *exception.Exception) {
|
||||
// return "Mock content", nil
|
||||
// }
|
||||
|
||||
// func (m *mockAI) Embeddings(input interface{}, user string) (interface{}, *exception.Exception) {
|
||||
// return nil, nil
|
||||
// }
|
||||
|
||||
// func (m *mockAI) Tiktoken(input string) (int, error) {
|
||||
// return 0, nil
|
||||
// }
|
||||
|
||||
// func (m *mockAI) MaxToken() int {
|
||||
// return 4096
|
||||
// }
|
||||
|
|
@ -1,864 +0,0 @@
|
|||
package assistant
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
"github.com/yaoapp/gou/connector"
|
||||
goullm "github.com/yaoapp/gou/llm"
|
||||
"github.com/yaoapp/kun/log"
|
||||
"github.com/yaoapp/yao/agent/assistant/handlers"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/i18n"
|
||||
"github.com/yaoapp/yao/agent/llm"
|
||||
"github.com/yaoapp/yao/agent/output/message"
|
||||
agentsandbox "github.com/yaoapp/yao/agent/sandbox"
|
||||
"github.com/yaoapp/yao/llmprovider"
|
||||
)
|
||||
|
||||
// Stream stream the agent
|
||||
// handler is optional, if not provided, a default handler will be used
|
||||
func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Message, options ...*context.Options) (*context.Response, error) {
|
||||
|
||||
// Update logger with assistant ID and start logging
|
||||
ctx.Logger.SetAssistantID(ast.ID)
|
||||
ctx.Logger.Start()
|
||||
|
||||
// Validate user permissions
|
||||
var err error
|
||||
err = ast.checkPermissions(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Start stream time
|
||||
streamStartTime := time.Now()
|
||||
|
||||
// Set up interrupt handler if interrupt controller is available
|
||||
// InterruptController handles user interrupt signals (stop button) for appending messages
|
||||
// HTTP context cancellation is handled naturally by LLM/Agent layers
|
||||
if ctx.Interrupt != nil {
|
||||
ctx.Interrupt.SetHandler(func(c *context.Context, signal *context.InterruptSignal) error {
|
||||
return ast.handleInterrupt(c, signal)
|
||||
})
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// Initialize
|
||||
// ================================================
|
||||
ctx.Logger.Phase("Initialize")
|
||||
|
||||
// Get or create options
|
||||
var opts *context.Options
|
||||
if len(options) > 0 && options[0] != nil {
|
||||
opts = options[0]
|
||||
} else {
|
||||
opts = &context.Options{}
|
||||
}
|
||||
|
||||
// Merge caller-provided metadata into ctx so sub-agent hooks can read it via ctx.metadata
|
||||
ctx.MergeMetadata(opts.Metadata)
|
||||
|
||||
// Initialize stack and auto-handle completion/failure/restore
|
||||
_, _, done := context.EnterStack(ctx, ast.ID, opts)
|
||||
defer done()
|
||||
|
||||
// Auto-skip history for forked Agent-to-Agent calls (ctx.agent.Call/All/Any/Race)
|
||||
// This ensures forked A2A messages don't pollute chat history.
|
||||
// Delegate calls (RefererAgent) still save history as they are part of the main conversation flow.
|
||||
// Note: Output is NOT skipped - sub-agents output normally with ThreadID for UI separation.
|
||||
if ctx.IsForkedA2ACall() {
|
||||
if opts == nil {
|
||||
opts = &context.Options{}
|
||||
}
|
||||
opts.ForceA2A()
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// Initialize Chat Buffer (for root stack only)
|
||||
// Buffer is flushed in defer block at the end
|
||||
// ================================================
|
||||
ast.InitBuffer(ctx)
|
||||
|
||||
// Track final status for buffer flush
|
||||
var finalStatus = context.StepStatusCompleted
|
||||
var finalError error
|
||||
|
||||
// Defer buffer flush - always executes on exit (success, error, interrupt, panic)
|
||||
defer func() {
|
||||
// Handle panic recovery for status tracking
|
||||
if r := recover(); r != nil {
|
||||
finalStatus = context.ResumeStatusFailed
|
||||
if e, ok := r.(error); ok {
|
||||
finalError = e
|
||||
} else {
|
||||
finalError = fmt.Errorf("panic: %v", r)
|
||||
}
|
||||
ctx.Logger.Error("Panic recovered in Stream: %v", r)
|
||||
// Re-panic after flush to preserve original behavior
|
||||
defer panic(r)
|
||||
}
|
||||
|
||||
// Flush buffer to database
|
||||
ast.FlushBuffer(ctx, finalStatus, finalError)
|
||||
|
||||
// Log end of request
|
||||
ctx.Logger.End(finalStatus == context.StepStatusCompleted, finalError)
|
||||
ctx.Logger.RestoreAssistantID()
|
||||
}()
|
||||
|
||||
// Determine stream handler
|
||||
streamHandler := ast.getStreamHandler(ctx, opts)
|
||||
|
||||
// Get connector and capabilities early (before sending stream_start)
|
||||
// so that output adapters can use them when converting stream_start event
|
||||
err = ast.initializeCapabilities(ctx, opts)
|
||||
if err != nil {
|
||||
finalStatus = context.ResumeStatusFailed
|
||||
finalError = err
|
||||
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Send ChunkStreamStart only for root stack (agent-level stream start)
|
||||
// Now ctx.Capabilities is set, so output adapters can use it
|
||||
ast.sendAgentStreamStart(ctx, streamHandler, streamStartTime)
|
||||
|
||||
// Initialize chat, prepare kb collection (optional) etc.
|
||||
// Use async version to not block the main flow
|
||||
ast.InitializeConversationAsync(ctx, opts)
|
||||
|
||||
ctx.Logger.PhaseComplete("Initialize")
|
||||
|
||||
// Ensure chat session exists
|
||||
ast.EnsureChat(ctx)
|
||||
|
||||
// Initialize agent trace node
|
||||
agentNode := ast.initAgentTraceNode(ctx, inputMessages)
|
||||
|
||||
// ================================================
|
||||
// Get Full Messages with chat history
|
||||
// ================================================
|
||||
ctx.Logger.Phase("History")
|
||||
historyResult, err := ast.WithHistory(ctx, inputMessages, agentNode, opts)
|
||||
if err != nil {
|
||||
ast.traceAgentFail(agentNode, err)
|
||||
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
|
||||
return nil, err
|
||||
}
|
||||
fullMessages := historyResult.FullMessages
|
||||
|
||||
// Buffer user input messages (use cleaned input without overlap)
|
||||
// Skip if History is disabled in options (for internal calls like needsearch)
|
||||
// Note: For A2A calls, ForceA2A() sets skip.history = true, so this will be skipped
|
||||
if opts == nil || opts.Skip == nil || !opts.Skip.History {
|
||||
ast.BufferUserInput(ctx, historyResult.InputMessages)
|
||||
}
|
||||
ctx.Logger.PhaseComplete("History")
|
||||
|
||||
// ================================================
|
||||
// Initialize Sandbox (if configured)
|
||||
// ================================================
|
||||
// Sandbox must be created BEFORE hooks so that hooks can access ctx.sandbox
|
||||
var sandboxExecutor agentsandbox.Executor
|
||||
var sandboxCleanup func()
|
||||
var sandboxLoadingMsgID string
|
||||
|
||||
// V2 sandbox state
|
||||
var v2Init *sandboxV2InitResult
|
||||
if ast.HasSandboxV2() {
|
||||
ctx.Logger.Phase("Sandbox V2")
|
||||
var err error
|
||||
v2Init, err = ast.initSandboxV2(ctx, opts)
|
||||
if err != nil {
|
||||
ast.traceAgentFail(agentNode, err)
|
||||
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
|
||||
return nil, err
|
||||
}
|
||||
sandboxCleanup = v2Init.Cleanup
|
||||
ctx.Logger.PhaseComplete("Sandbox V2")
|
||||
if v2Init.Computer != nil {
|
||||
ci := v2Init.Computer.ComputerInfo()
|
||||
ctx.Logger.Trace("Node: %s (%s)", ci.NodeID, ci.Kind)
|
||||
if ci.BoxID != "" {
|
||||
ctx.Logger.Trace("Computer: %s", ci.BoxID)
|
||||
}
|
||||
ctx.Logger.Trace("Workspace: %s", v2Init.Config.WorkspaceID)
|
||||
if conn, _, err := ast.GetConnector(ctx, opts); err == nil && conn != nil {
|
||||
ctx.Logger.Trace("Connector: %s", conn.ID())
|
||||
}
|
||||
}
|
||||
} else if ast.HasSandbox() {
|
||||
ctx.Logger.Phase("Sandbox")
|
||||
var err error
|
||||
sandboxExecutor, sandboxCleanup, sandboxLoadingMsgID, err = ast.initSandbox(ctx, opts)
|
||||
if err != nil {
|
||||
ast.traceAgentFail(agentNode, err)
|
||||
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
|
||||
return nil, err
|
||||
}
|
||||
// Set sandbox executor in context so hooks can access ctx.sandbox
|
||||
// The executor implements both agentsandbox.Executor and context.SandboxExecutor
|
||||
ctx.SetSandboxExecutor(sandboxExecutor)
|
||||
ctx.Logger.PhaseComplete("Sandbox")
|
||||
}
|
||||
// Ensure sandbox cleanup on exit
|
||||
defer func() {
|
||||
if sandboxCleanup != nil {
|
||||
sandboxCleanup()
|
||||
}
|
||||
}()
|
||||
|
||||
// ================================================
|
||||
// Standalone Workspace Loading (no sandbox required)
|
||||
// ================================================
|
||||
// When no sandbox is configured but the user selected a workspace,
|
||||
// load the workspace FS into context so hooks can access ctx.workspace.
|
||||
if !ctx.HasWorkspace() {
|
||||
ast.initStandaloneWorkspace(ctx)
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// Execute Create Hook
|
||||
// ================================================
|
||||
// Request Create hook ( Optional )
|
||||
var createResponse *context.HookCreateResponse
|
||||
if ast.HookScript != nil {
|
||||
ctx.Logger.HookStart("Create")
|
||||
// Begin step tracking for hook_create
|
||||
ast.BeginStep(ctx, context.StepTypeHookCreate, map[string]interface{}{
|
||||
"messages": fullMessages,
|
||||
})
|
||||
|
||||
var err error
|
||||
createResponse, opts, err = ast.HookScript.Create(ctx, fullMessages, opts)
|
||||
if err != nil {
|
||||
finalStatus = context.ResumeStatusFailed
|
||||
finalError = err
|
||||
ast.traceAgentFail(agentNode, err)
|
||||
// Send error stream_end for root stack
|
||||
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Complete step
|
||||
ast.CompleteStep(ctx, map[string]interface{}{
|
||||
"response": createResponse,
|
||||
})
|
||||
|
||||
// Log the create response
|
||||
ast.traceCreateHook(agentNode, createResponse)
|
||||
ctx.Logger.HookComplete("Create")
|
||||
|
||||
// Check if Create hook wants to delegate to another agent
|
||||
// This allows early routing to sub-agents without LLM call
|
||||
if createResponse != nil && createResponse.Delegate != nil {
|
||||
ctx.Logger.Debug("Create hook delegating to agent: %s", createResponse.Delegate.AgentID)
|
||||
|
||||
// Delegate to target agent (reuse existing delegation logic from next.go)
|
||||
// Note: User input is already buffered by root agent, delegated agent will skip buffering
|
||||
delegateResponse, err := ast.handleDelegation(ctx, createResponse.Delegate, streamHandler)
|
||||
if err != nil {
|
||||
finalStatus = context.ResumeStatusFailed
|
||||
finalError = err
|
||||
ast.traceAgentFail(agentNode, err)
|
||||
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// For root stack, send stream_end and close output
|
||||
// (delegated agent handles its own stream events, but root needs to close)
|
||||
if ctx.Stack != nil && ctx.Stack.IsRoot() {
|
||||
ast.sendAgentStreamEnd(ctx, streamHandler, streamStartTime, "completed", nil, nil)
|
||||
if err := ctx.CloseOutput(); err != nil {
|
||||
if trace, _ := ctx.Trace(); trace != nil {
|
||||
trace.Error(i18n.Tr(ast.ID, ctx.Locale, "assistant.agent.stream.close_error"), map[string]any{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return delegated response directly (skip LLM call and Next hook)
|
||||
return delegateResponse, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// Execute LLM Call Stream
|
||||
// ================================================
|
||||
// LLM Call Stream ( Optional )
|
||||
var completionResponse *context.CompletionResponse
|
||||
var completionMessages []context.Message
|
||||
var completionOptions *context.CompletionOptions
|
||||
if ast.Prompts != nil || ast.MCP != nil {
|
||||
ctx.Logger.Phase("LLM")
|
||||
|
||||
// Build the LLM request first (use fullMessages which includes history)
|
||||
// Note: completionMessages here are still in original format (with __yao.attachment:// URLs)
|
||||
// Content conversion (BuildContent) happens inside executeLLMStream, right before LLM call
|
||||
// This ensures autoSearch and delegate receive original messages, not converted ones
|
||||
completionMessages, completionOptions, err = ast.BuildRequest(ctx, fullMessages, createResponse)
|
||||
if err != nil {
|
||||
finalStatus = context.ResumeStatusFailed
|
||||
finalError = err
|
||||
ast.traceAgentFail(agentNode, err)
|
||||
// Send error stream_end for root stack
|
||||
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// Execute Auto Search (if enabled)
|
||||
// ================================================
|
||||
if intent := ast.shouldAutoSearch(ctx, completionMessages, createResponse, opts); intent != nil {
|
||||
refCtx := ast.executeAutoSearch(ctx, completionMessages, createResponse, intent, opts)
|
||||
if refCtx != nil && len(refCtx.References) > 0 {
|
||||
completionMessages = ast.injectSearchContext(completionMessages, refCtx)
|
||||
}
|
||||
}
|
||||
|
||||
// Begin step tracking for LLM call
|
||||
ast.BeginStep(ctx, context.StepTypeLLM, map[string]interface{}{
|
||||
"messages": completionMessages,
|
||||
})
|
||||
|
||||
// Execute the LLM streaming call
|
||||
// Choose between sandbox execution or direct LLM execution
|
||||
if ast.HasSandboxV2() && v2Init != nil && v2Init.Runner != nil && v2Init.Computer != nil && v2Init.Runner.Name() != "yao" {
|
||||
// V2 Sandbox execution path (non-yao runners replace LLM.Stream)
|
||||
completionResponse, err = ast.executeSandboxV2Stream(ctx, &sandboxV2StreamParams{
|
||||
Messages: completionMessages,
|
||||
AgentNode: agentNode,
|
||||
Handler: streamHandler,
|
||||
Runner: v2Init.Runner,
|
||||
Computer: v2Init.Computer,
|
||||
Config: v2Init.Config,
|
||||
LoadingMsgID: v2Init.LoadingMsgID,
|
||||
Options: opts,
|
||||
Roles: v2Init.Roles,
|
||||
})
|
||||
} else if ast.HasSandboxV2() && v2Init != nil && v2Init.Runner != nil && v2Init.Runner.Name() == "yao" {
|
||||
// V2 yao runner: Prepare is done, close loading, fall through to LLM
|
||||
if v2Init.LoadingMsgID != "" {
|
||||
closeLoadingV2(ctx, v2Init.LoadingMsgID, "")
|
||||
}
|
||||
completionResponse, err = ast.executeLLMStream(ctx, completionMessages, completionOptions, agentNode, streamHandler, opts)
|
||||
} else if ast.HasSandbox() {
|
||||
// V1 Sandbox execution path (Claude CLI, Cursor CLI, etc.)
|
||||
completionResponse, err = ast.executeSandboxStream(ctx, completionMessages, agentNode, streamHandler, sandboxExecutor, sandboxLoadingMsgID)
|
||||
} else {
|
||||
// Direct LLM execution path
|
||||
completionResponse, err = ast.executeLLMStream(ctx, completionMessages, completionOptions, agentNode, streamHandler, opts)
|
||||
}
|
||||
if err != nil {
|
||||
finalStatus = context.ResumeStatusFailed
|
||||
finalError = err
|
||||
ast.traceAgentFail(agentNode, err)
|
||||
// Send error stream_end for root stack
|
||||
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Complete LLM step
|
||||
ast.CompleteStep(ctx, map[string]interface{}{
|
||||
"content": completionResponse.Content,
|
||||
"tool_calls": completionResponse.ToolCalls,
|
||||
})
|
||||
|
||||
hasToolCalls := completionResponse != nil && completionResponse.ToolCalls != nil && len(completionResponse.ToolCalls) > 0
|
||||
tokens := 0
|
||||
if completionResponse != nil && completionResponse.Usage != nil {
|
||||
tokens = completionResponse.Usage.TotalTokens
|
||||
}
|
||||
ctx.Logger.LLMComplete(tokens, hasToolCalls)
|
||||
ctx.Logger.PhaseComplete("LLM")
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// Execute tool calls with retry
|
||||
// ================================================
|
||||
// Note: Skip MCP tool calls execution for sandbox mode - Claude CLI handles them internally
|
||||
var toolCallResponses []context.ToolCallResponse = nil
|
||||
if completionResponse != nil && completionResponse.ToolCalls != nil && !ast.HasSandbox() {
|
||||
|
||||
maxToolRetries := 3
|
||||
currentMessages := completionMessages
|
||||
currentResponse := completionResponse
|
||||
|
||||
for attempt := 0; attempt < maxToolRetries; attempt++ {
|
||||
|
||||
// Begin step tracking for tool calls
|
||||
ast.BeginStep(ctx, context.StepTypeTool, map[string]interface{}{
|
||||
"tool_calls": currentResponse.ToolCalls,
|
||||
"attempt": attempt,
|
||||
})
|
||||
|
||||
// Execute all tool calls
|
||||
toolResults, hasErrors := ast.executeToolCalls(ctx, currentResponse.ToolCalls, attempt)
|
||||
|
||||
// Build a map of tool call ID to arguments for quick lookup
|
||||
toolCallArgsMap := make(map[string]interface{})
|
||||
for _, tc := range currentResponse.ToolCalls {
|
||||
toolCallArgsMap[tc.ID] = tc.Function.Arguments
|
||||
}
|
||||
|
||||
// Convert toolResults to toolCallResponses
|
||||
toolCallResponses = make([]context.ToolCallResponse, len(toolResults))
|
||||
for i, result := range toolResults {
|
||||
parsedContent, _ := result.ParsedContent()
|
||||
toolCallResponses[i] = context.ToolCallResponse{
|
||||
ToolCallID: result.ToolCallID,
|
||||
Server: result.Server(),
|
||||
Tool: result.Tool(),
|
||||
Arguments: toolCallArgsMap[result.ToolCallID],
|
||||
Result: parsedContent,
|
||||
Error: "",
|
||||
}
|
||||
if result.Error != nil {
|
||||
toolCallResponses[i].Error = result.Error.Error()
|
||||
}
|
||||
}
|
||||
|
||||
// If all successful, complete step and break out
|
||||
if !hasErrors {
|
||||
ast.CompleteStep(ctx, map[string]interface{}{
|
||||
"results": toolCallResponses,
|
||||
})
|
||||
ctx.Logger.Debug("All tool calls succeeded (attempt %d)", attempt)
|
||||
break
|
||||
}
|
||||
|
||||
// Check if any errors are retryable (parameter/validation issues)
|
||||
hasRetryableErrors := false
|
||||
for _, result := range toolResults {
|
||||
if result.Error != nil && result.IsRetryableError {
|
||||
hasRetryableErrors = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If no retryable errors, don't retry (MCP internal issues)
|
||||
if !hasRetryableErrors {
|
||||
err := fmt.Errorf("tool calls failed with non-retryable errors (MCP internal issues)")
|
||||
finalStatus = context.ResumeStatusFailed
|
||||
finalError = err
|
||||
ctx.Logger.Error("Tool calls failed: %v", err)
|
||||
ast.traceAgentFail(agentNode, err)
|
||||
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If it's the last attempt, return error
|
||||
if attempt == maxToolRetries-1 {
|
||||
err := fmt.Errorf("tool calls failed after %d attempts", maxToolRetries)
|
||||
finalStatus = context.ResumeStatusFailed
|
||||
finalError = err
|
||||
ctx.Logger.Error("Tool calls failed: %v", err)
|
||||
ast.traceAgentFail(agentNode, err)
|
||||
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Complete current step (with partial results)
|
||||
ast.CompleteStep(ctx, map[string]interface{}{
|
||||
"results": toolCallResponses,
|
||||
"has_errors": true,
|
||||
})
|
||||
|
||||
// Build retry messages with tool call results (including errors)
|
||||
retryMessages := ast.buildToolRetryMessages(currentMessages, currentResponse, toolResults)
|
||||
|
||||
// Begin LLM retry step
|
||||
ast.BeginStep(ctx, context.StepTypeLLM, map[string]interface{}{
|
||||
"messages": retryMessages,
|
||||
"retry_attempt": attempt + 1,
|
||||
})
|
||||
|
||||
// Retry LLM call (streaming to keep user informed)
|
||||
ctx.Logger.Debug("Retrying LLM for tool call correction (attempt %d/%d)", attempt+1, maxToolRetries-1)
|
||||
currentResponse, err = ast.executeLLMForToolRetry(ctx, retryMessages, completionOptions, agentNode, streamHandler, opts)
|
||||
if err != nil {
|
||||
finalStatus = context.ResumeStatusFailed
|
||||
finalError = err
|
||||
ctx.Logger.Error("LLM retry failed: %v", err)
|
||||
ast.traceAgentFail(agentNode, err)
|
||||
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If LLM didn't return tool calls, it might have given up
|
||||
if currentResponse.ToolCalls == nil {
|
||||
err := fmt.Errorf("LLM did not return tool calls in retry attempt %d", attempt+1)
|
||||
finalStatus = context.ResumeStatusFailed
|
||||
finalError = err
|
||||
ctx.Logger.Error("LLM did not return tool calls: %v", err)
|
||||
ast.traceAgentFail(agentNode, err)
|
||||
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Complete LLM retry step
|
||||
ast.CompleteStep(ctx, map[string]interface{}{
|
||||
"content": currentResponse.Content,
|
||||
"tool_calls": currentResponse.ToolCalls,
|
||||
})
|
||||
|
||||
// Update messages for next iteration
|
||||
currentMessages = retryMessages
|
||||
}
|
||||
|
||||
// Update completionResponse with the final successful response
|
||||
completionResponse = currentResponse
|
||||
}
|
||||
|
||||
// ================================================
|
||||
// Execute Next Hook and Process Response
|
||||
// ================================================
|
||||
var finalResponse *context.Response
|
||||
var nextResponse *context.NextHookResponse = nil
|
||||
|
||||
if ast.HookScript != nil {
|
||||
ctx.Logger.HookStart("Next")
|
||||
|
||||
// Begin step tracking for hook_next
|
||||
ast.BeginStep(ctx, context.StepTypeHookNext, map[string]interface{}{
|
||||
"messages": fullMessages,
|
||||
"completion": completionResponse,
|
||||
"tools": toolCallResponses,
|
||||
})
|
||||
|
||||
var err error
|
||||
nextResponse, opts, err = ast.HookScript.Next(ctx, &context.NextHookPayload{
|
||||
Messages: fullMessages,
|
||||
Completion: completionResponse,
|
||||
Tools: toolCallResponses,
|
||||
}, opts)
|
||||
if err != nil {
|
||||
finalStatus = context.ResumeStatusFailed
|
||||
finalError = err
|
||||
ast.traceAgentFail(agentNode, err)
|
||||
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Complete hook_next step
|
||||
ast.CompleteStep(ctx, map[string]interface{}{
|
||||
"response": nextResponse,
|
||||
})
|
||||
|
||||
ctx.Logger.HookComplete("Next")
|
||||
|
||||
// Process Next hook response
|
||||
finalResponse, err = ast.processNextResponse(&NextProcessContext{
|
||||
Context: ctx,
|
||||
NextResponse: nextResponse,
|
||||
CompletionResponse: completionResponse,
|
||||
FullMessages: fullMessages,
|
||||
ToolCallResponses: toolCallResponses,
|
||||
StreamHandler: streamHandler,
|
||||
CreateResponse: createResponse,
|
||||
})
|
||||
if err != nil {
|
||||
finalStatus = context.ResumeStatusFailed
|
||||
finalError = err
|
||||
ast.traceAgentFail(agentNode, err)
|
||||
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
|
||||
return nil, err
|
||||
}
|
||||
} else if len(toolCallResponses) > 0 && !ast.HasSandbox() && !ast.isToolLoopDisabled() {
|
||||
// No Next hook + has tool results + not sandbox → tool loop
|
||||
ctx.Logger.Debug("Entering tool loop for tool result processing")
|
||||
loopResponse, loopCompletion, loopTools, err := ast.executeToolLoop(ctx, &ToolLoopParams{
|
||||
CompletionMessages: completionMessages,
|
||||
CompletionOptions: completionOptions,
|
||||
CompletionResponse: completionResponse,
|
||||
ToolCallResponses: toolCallResponses,
|
||||
FullMessages: fullMessages,
|
||||
AgentNode: agentNode,
|
||||
StreamHandler: streamHandler,
|
||||
CreateResponse: createResponse,
|
||||
Opts: opts,
|
||||
})
|
||||
if err != nil {
|
||||
// Fallback to __yao.loop_fallback delegation
|
||||
ctx.Logger.Warn("Tool loop failed: %v, falling back to loop_fallback", err)
|
||||
fallbackDelegate := ast.buildLoopFallbackDelegate(ctx, fullMessages, completionResponse, toolCallResponses)
|
||||
delegateResponse, delegateErr := ast.handleDelegation(ctx, fallbackDelegate, streamHandler)
|
||||
if delegateErr != nil {
|
||||
ctx.Logger.Warn("loop_fallback also failed: %v, using standard response", delegateErr)
|
||||
finalResponse = ast.buildStandardResponse(&NextProcessContext{
|
||||
Context: ctx,
|
||||
CompletionResponse: completionResponse,
|
||||
FullMessages: fullMessages,
|
||||
ToolCallResponses: toolCallResponses,
|
||||
StreamHandler: streamHandler,
|
||||
CreateResponse: createResponse,
|
||||
})
|
||||
} else {
|
||||
finalResponse = delegateResponse
|
||||
}
|
||||
} else {
|
||||
completionResponse = loopCompletion
|
||||
toolCallResponses = loopTools
|
||||
finalResponse = loopResponse
|
||||
}
|
||||
} else {
|
||||
// No tool calls, sandbox mode, or loop disabled: standard response
|
||||
finalResponse = ast.buildStandardResponse(&NextProcessContext{
|
||||
Context: ctx,
|
||||
CompletionResponse: completionResponse,
|
||||
FullMessages: fullMessages,
|
||||
ToolCallResponses: toolCallResponses,
|
||||
StreamHandler: streamHandler,
|
||||
CreateResponse: createResponse,
|
||||
})
|
||||
}
|
||||
|
||||
// Create completion node to report final output
|
||||
ast.traceAgentCompletion(ctx, createResponse, nextResponse, completionResponse, finalResponse)
|
||||
|
||||
// Only close output and send stream_end if this is the root call (entry point)
|
||||
// Nested calls (from MCP, hooks, etc.) should not close the output or send stream_end
|
||||
// Note: Flush is already handled by the stream handler (handleStreamEnd)
|
||||
if ctx.Stack != nil && ctx.Stack.IsRoot() {
|
||||
// Log closing output for root call
|
||||
if trace, _ := ctx.Trace(); trace != nil {
|
||||
trace.Debug("Agent: Closing output (root call)", map[string]any{
|
||||
"stack_id": ctx.Stack.ID,
|
||||
"depth": ctx.Stack.Depth,
|
||||
"assistant_id": ctx.Stack.AssistantID,
|
||||
})
|
||||
}
|
||||
|
||||
// Send ChunkStreamEnd (agent-level stream completion)
|
||||
ast.sendAgentStreamEnd(ctx, streamHandler, streamStartTime, "completed", nil, completionResponse)
|
||||
|
||||
// Close the output writer to send [DONE] marker
|
||||
if err := ctx.CloseOutput(); err != nil {
|
||||
if trace, _ := ctx.Trace(); trace != nil {
|
||||
trace.Error(i18n.Tr(ast.ID, ctx.Locale, "assistant.agent.stream.close_error"), map[string]any{"error": err.Error()}) // "Failed to close output"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Log skipping close for nested call
|
||||
if trace, _ := ctx.Trace(); trace != nil && ctx.Stack != nil {
|
||||
trace.Debug("Agent: Skipping output close (nested call)", map[string]any{
|
||||
"stack_id": ctx.Stack.ID,
|
||||
"depth": ctx.Stack.Depth,
|
||||
"parent_id": ctx.Stack.ParentID,
|
||||
"assistant_id": ctx.Stack.AssistantID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Return finalResponse which could be:
|
||||
// 1. Result from delegated agent call (already a Response)
|
||||
// 2. Custom data from Next hook (wrapped in standard Response)
|
||||
// 3. Standard response
|
||||
return finalResponse, nil
|
||||
}
|
||||
|
||||
// GetConnector get the connector object, capabilities, and error.
|
||||
// Priority: opts.Connector > ast.Connector (may be "use::<role>") > "default" role > legacy fallback
|
||||
// Note: opts.Connector may be set by Create hook's applyOptionsAdjustments
|
||||
func (ast *Assistant) GetConnector(ctx *context.Context, opts ...*context.Options) (connector.Connector, *goullm.Capabilities, error) {
|
||||
cid := ast.Connector
|
||||
if len(opts) > 0 && opts[0] != nil && opts[0].Connector != "" {
|
||||
cid = opts[0].Connector
|
||||
}
|
||||
|
||||
// Extract identity for role-based resolution
|
||||
var identity llmprovider.Identity
|
||||
if ctx != nil && ctx.Authorized != nil {
|
||||
identity = ctx.Authorized
|
||||
}
|
||||
|
||||
// Unified resolution: explicit connector / use:: prefix / empty → all handled
|
||||
conn, caps, err := llm.ResolveConnector(cid, identity)
|
||||
if err == nil {
|
||||
return conn, caps, nil
|
||||
}
|
||||
// Legacy fallback
|
||||
if defaultConnector != "" {
|
||||
if conn, err := connector.Select(defaultConnector); err == nil {
|
||||
log.Warn("[LLM] Connector %s resolve failed, fallback to %s", cid, defaultConnector)
|
||||
return conn, llm.GetCapabilitiesFromConn(conn), nil
|
||||
}
|
||||
}
|
||||
if fallback := findCapableConnector(); fallback != "" {
|
||||
if conn, err := connector.Select(fallback); err == nil {
|
||||
log.Warn("[LLM] Connector %s resolve failed, fallback to %s (auto-detected)", cid, fallback)
|
||||
return conn, llm.GetCapabilitiesFromConn(conn), nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil, fmt.Errorf("connector not specified")
|
||||
}
|
||||
|
||||
// Info get the assistant information
|
||||
func (ast *Assistant) Info(locale ...string) *message.AssistantInfo {
|
||||
lc := "en"
|
||||
if len(locale) > 0 {
|
||||
lc = locale[0]
|
||||
}
|
||||
return &message.AssistantInfo{
|
||||
ID: ast.ID,
|
||||
Type: ast.Type,
|
||||
Name: i18n.Tr(ast.ID, lc, ast.Name),
|
||||
Avatar: ast.Avatar,
|
||||
Description: i18n.Tr(ast.ID, lc, ast.Description),
|
||||
}
|
||||
}
|
||||
|
||||
// getStreamHandler returns the stream handler from options or a default one
|
||||
func (ast *Assistant) getStreamHandler(ctx *context.Context, opts ...*context.Options) message.StreamFunc {
|
||||
// Check if handler is provided in options
|
||||
if len(opts) > 0 && opts[0] != nil && opts[0].Writer != nil {
|
||||
return handlers.DefaultStreamHandler(ctx)
|
||||
}
|
||||
return handlers.DefaultStreamHandler(ctx)
|
||||
}
|
||||
|
||||
// sendAgentStreamStart sends ChunkStreamStart for root stack only (agent-level stream start)
|
||||
// This ensures only one stream_start per agent execution, even with multiple LLM calls
|
||||
func (ast *Assistant) sendAgentStreamStart(ctx *context.Context, handler message.StreamFunc, startTime time.Time) {
|
||||
if ctx.Stack == nil || !ctx.Stack.IsRoot() || handler == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Build the start data
|
||||
startData := message.EventStreamStartData{
|
||||
ContextID: ctx.ID,
|
||||
ChatID: ctx.ChatID,
|
||||
TraceID: ctx.TraceID(),
|
||||
RequestID: ctx.RequestID(),
|
||||
Timestamp: startTime.UnixMilli(),
|
||||
Assistant: ast.Info(ctx.Locale),
|
||||
Metadata: ctx.Metadata,
|
||||
}
|
||||
|
||||
if startJSON, err := jsoniter.Marshal(startData); err == nil {
|
||||
handler(message.ChunkStreamStart, startJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// sendAgentStreamEnd sends ChunkStreamEnd for root stack only (agent-level stream completion)
|
||||
func (ast *Assistant) sendAgentStreamEnd(ctx *context.Context, handler message.StreamFunc, startTime time.Time, status string, err error, response *context.CompletionResponse) {
|
||||
if ctx.Stack == nil || !ctx.Stack.IsRoot() || handler == nil {
|
||||
return
|
||||
}
|
||||
|
||||
endData := &message.EventStreamEndData{
|
||||
RequestID: ctx.RequestID(),
|
||||
ContextID: ctx.ID,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
DurationMs: time.Since(startTime).Milliseconds(),
|
||||
Status: status,
|
||||
TraceID: ctx.TraceID(),
|
||||
Metadata: ctx.Metadata,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
endData.Error = err.Error()
|
||||
}
|
||||
|
||||
if response != nil && response.Usage != nil {
|
||||
endData.Usage = response.Usage
|
||||
}
|
||||
|
||||
if endJSON, marshalErr := jsoniter.Marshal(endData); marshalErr == nil {
|
||||
handler(message.ChunkStreamEnd, endJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// sendStreamEndOnError sends ChunkStreamEnd with error status for root stack only
|
||||
func (ast *Assistant) sendStreamEndOnError(ctx *context.Context, handler message.StreamFunc, startTime time.Time, err error) {
|
||||
ast.sendAgentStreamEnd(ctx, handler, startTime, "error", err, nil)
|
||||
}
|
||||
|
||||
// handleInterrupt handles the interrupt signal
|
||||
// This is called by the interrupt listener when a signal is received
|
||||
func (ast *Assistant) handleInterrupt(ctx *context.Context, signal *context.InterruptSignal) error {
|
||||
switch signal.Type {
|
||||
case context.InterruptForce:
|
||||
ctx.Logger.Debug("Force interrupt received")
|
||||
if ctx.Buffer != nil {
|
||||
ctx.Buffer.FailCurrentStep(context.ResumeStatusInterrupted,
|
||||
fmt.Errorf("interrupted by user"))
|
||||
}
|
||||
case context.InterruptGraceful:
|
||||
ctx.Logger.Debug("Graceful interrupt received: messages=%d", len(signal.Messages))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// initializeCapabilities gets connector and capabilities, then sets them in context
|
||||
// This should be called early (before sending stream_start) so that output adapters
|
||||
// can use capabilities when converting stream_start event
|
||||
func (ast *Assistant) initializeCapabilities(ctx *context.Context, opts *context.Options) error {
|
||||
if ast.Prompts == nil && ast.MCP == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, capabilities, err := ast.GetConnector(ctx, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set capabilities in context for output adapters to use
|
||||
if capabilities != nil {
|
||||
ctx.Capabilities = capabilities
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildToolRetryMessages builds messages for LLM retry with tool call results
|
||||
// Format follows OpenAI's tool call response pattern:
|
||||
// 1. Assistant message with tool calls
|
||||
// 2. Tool messages with results (one per tool call)
|
||||
// 3. System message explaining the retry
|
||||
func (ast *Assistant) buildToolRetryMessages(
|
||||
previousMessages []context.Message,
|
||||
completionResponse *context.CompletionResponse,
|
||||
toolResults []ToolCallResult,
|
||||
) []context.Message {
|
||||
retryMessages := make([]context.Message, 0, len(previousMessages)+len(toolResults)+2)
|
||||
|
||||
// Add all previous messages
|
||||
retryMessages = append(retryMessages, previousMessages...)
|
||||
|
||||
// Add assistant message with tool calls
|
||||
assistantMsg := context.Message{
|
||||
Role: context.RoleAssistant,
|
||||
Content: completionResponse.Content,
|
||||
ReasoningContent: completionResponse.ReasoningContent,
|
||||
ToolCalls: completionResponse.ToolCalls,
|
||||
}
|
||||
retryMessages = append(retryMessages, assistantMsg)
|
||||
|
||||
// Add tool result messages (one per tool call)
|
||||
for _, result := range toolResults {
|
||||
toolMsg := context.Message{
|
||||
Role: context.RoleTool,
|
||||
Content: result.Content,
|
||||
ToolCallID: &result.ToolCallID,
|
||||
}
|
||||
// Add tool name if available
|
||||
if result.Name != "" {
|
||||
name := result.Name
|
||||
toolMsg.Name = &name
|
||||
}
|
||||
retryMessages = append(retryMessages, toolMsg)
|
||||
}
|
||||
|
||||
// Add system message explaining the retry (optional, helps LLM understand context)
|
||||
systemMsg := context.Message{
|
||||
Role: context.RoleSystem,
|
||||
Content: i18n.Tr(ast.ID, "en", "assistant.agent.tool_retry_prompt"),
|
||||
}
|
||||
retryMessages = append(retryMessages, systemMsg)
|
||||
|
||||
return retryMessages
|
||||
}
|
||||
|
|
@ -1,398 +0,0 @@
|
|||
package assistant_test
|
||||
|
||||
import (
|
||||
stdContext "context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/output/message"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
// newTestContextWithInterrupt creates a Context with interrupt controller for testing
|
||||
// Returns the context and a cancel function that should be called before Release()
|
||||
func newTestContextWithInterrupt(chatID, assistantID string) (*context.Context, stdContext.CancelFunc) {
|
||||
authorized := &types.AuthorizedInfo{
|
||||
Subject: "test-user",
|
||||
ClientID: "test-client-id",
|
||||
UserID: "test-user-123",
|
||||
TeamID: "test-team-456",
|
||||
TenantID: "test-tenant-789",
|
||||
SessionID: "test-session-id",
|
||||
}
|
||||
|
||||
// Use cancellable context to properly stop goroutines on timeout
|
||||
parentCtx, cancel := stdContext.WithCancel(stdContext.Background())
|
||||
|
||||
ctx := context.New(parentCtx, authorized, chatID)
|
||||
ctx.ID = fmt.Sprintf("test_ctx_%d", time.Now().UnixNano())
|
||||
ctx.AssistantID = assistantID
|
||||
ctx.Locale = "en-us"
|
||||
ctx.Theme = "light"
|
||||
ctx.Client = context.Client{
|
||||
Type: "web",
|
||||
UserAgent: "TestAgent/1.0",
|
||||
IP: "127.0.0.1",
|
||||
}
|
||||
ctx.Referer = context.RefererAPI
|
||||
ctx.Accept = context.AcceptWebCUI
|
||||
ctx.Route = "/test/route"
|
||||
ctx.IDGenerator = message.NewIDGenerator() // Initialize context-scoped ID generator
|
||||
ctx.Metadata = map[string]interface{}{
|
||||
"test": "interrupt_test",
|
||||
}
|
||||
|
||||
// Initialize interrupt controller
|
||||
ctx.Interrupt = context.NewInterruptController()
|
||||
|
||||
// Register context globally
|
||||
if err := context.Register(ctx); err != nil {
|
||||
panic(fmt.Sprintf("Failed to register context: %v", err))
|
||||
}
|
||||
|
||||
// Start interrupt listener
|
||||
ctx.Interrupt.Start(ctx.ID)
|
||||
|
||||
return ctx, cancel
|
||||
}
|
||||
|
||||
// TestAgentInterruptGraceful tests graceful interrupt during agent stream
|
||||
func TestAgentInterruptGraceful(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.interrupt")
|
||||
if err != nil {
|
||||
t.Skipf("Skipping test: assistant 'tests.interrupt' not found: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
t.Run("GracefulInterruptDuringStream", func(t *testing.T) {
|
||||
// Create context with interrupt support
|
||||
ctx, cancel := newTestContextWithInterrupt("chat-interrupt-graceful", "tests.interrupt")
|
||||
defer func() {
|
||||
cancel() // Cancel context first to stop goroutines
|
||||
time.Sleep(100 * time.Millisecond) // Wait for goroutines to exit
|
||||
ctx.Release()
|
||||
}()
|
||||
|
||||
// Track handler invocations
|
||||
handlerInvoked := false
|
||||
var receivedSignal *context.InterruptSignal
|
||||
|
||||
// Override the handler to track invocations
|
||||
originalHandler := ctx.Interrupt
|
||||
ctx.Interrupt.SetHandler(func(c *context.Context, signal *context.InterruptSignal) error {
|
||||
handlerInvoked = true
|
||||
receivedSignal = signal
|
||||
t.Logf("✓ Interrupt handler invoked: type=%s, messages=%d", signal.Type, len(signal.Messages))
|
||||
return nil
|
||||
})
|
||||
|
||||
inputMessages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "Tell me a long story about artificial intelligence"},
|
||||
}
|
||||
|
||||
// Start streaming in a goroutine
|
||||
streamDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := agent.Stream(ctx, inputMessages)
|
||||
streamDone <- err
|
||||
}()
|
||||
|
||||
// Wait a bit to ensure stream has started
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
// Send graceful interrupt signal
|
||||
signal := &context.InterruptSignal{
|
||||
Type: context.InterruptGraceful,
|
||||
Messages: []context.Message{
|
||||
{Role: context.RoleUser, Content: "Actually, can you make it shorter?"},
|
||||
},
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
err = context.SendInterrupt(ctx.ID, signal)
|
||||
if err != nil {
|
||||
t.Logf("Warning: Failed to send interrupt (stream may have completed): %v", err)
|
||||
} else {
|
||||
t.Log("✓ Graceful interrupt signal sent")
|
||||
}
|
||||
|
||||
// Wait for stream to complete (with timeout)
|
||||
select {
|
||||
case err := <-streamDone:
|
||||
if err != nil {
|
||||
t.Logf("Stream completed with error: %v", err)
|
||||
} else {
|
||||
t.Log("✓ Stream completed successfully")
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Log("Stream timeout (expected for real LLM calls)")
|
||||
cancel() // Cancel to stop the stream goroutine
|
||||
<-streamDone // Wait for goroutine to exit
|
||||
}
|
||||
|
||||
// Verify handler was invoked if signal was sent
|
||||
if originalHandler != nil {
|
||||
time.Sleep(200 * time.Millisecond) // Wait for async handler
|
||||
if handlerInvoked {
|
||||
t.Log("✓ Interrupt handler was invoked")
|
||||
if receivedSignal != nil && receivedSignal.Type == context.InterruptGraceful {
|
||||
t.Log("✓ Received graceful interrupt signal")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestAgentInterruptForce tests force interrupt during agent stream
|
||||
func TestAgentInterruptForce(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.interrupt")
|
||||
if err != nil {
|
||||
t.Skipf("Skipping test: assistant 'tests.interrupt' not found: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
t.Run("ForceInterruptDuringStream", func(t *testing.T) {
|
||||
// Create context with interrupt support
|
||||
ctx, cancel := newTestContextWithInterrupt("chat-interrupt-force", "tests.interrupt")
|
||||
defer func() {
|
||||
cancel() // Cancel context first to stop goroutines
|
||||
time.Sleep(100 * time.Millisecond) // Wait for goroutines to exit
|
||||
ctx.Release()
|
||||
}()
|
||||
|
||||
// Track handler invocations
|
||||
handlerInvoked := false
|
||||
streamInterrupted := false
|
||||
|
||||
ctx.Interrupt.SetHandler(func(c *context.Context, signal *context.InterruptSignal) error {
|
||||
handlerInvoked = true
|
||||
t.Logf("✓ Interrupt handler invoked: type=%s", signal.Type)
|
||||
return nil
|
||||
})
|
||||
|
||||
inputMessages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "Write a very detailed essay about machine learning"},
|
||||
}
|
||||
|
||||
// Start streaming in a goroutine
|
||||
streamDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := agent.Stream(ctx, inputMessages)
|
||||
streamDone <- err
|
||||
}()
|
||||
|
||||
// Wait a bit to ensure stream has started
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
// Send force interrupt signal
|
||||
signal := &context.InterruptSignal{
|
||||
Type: context.InterruptForce,
|
||||
Messages: []context.Message{
|
||||
{Role: context.RoleUser, Content: "Stop! I need something else now."},
|
||||
},
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
err = context.SendInterrupt(ctx.ID, signal)
|
||||
if err != nil {
|
||||
t.Logf("Warning: Failed to send interrupt: %v", err)
|
||||
} else {
|
||||
t.Log("✓ Force interrupt signal sent")
|
||||
}
|
||||
|
||||
// Wait for stream to complete or be interrupted
|
||||
select {
|
||||
case err := <-streamDone:
|
||||
if err != nil {
|
||||
// Check if error is due to interrupt
|
||||
if err.Error() == "force interrupted by user" ||
|
||||
err.Error() == "interrupted by user" ||
|
||||
err.Error() == "interrupted by user before stream start" {
|
||||
streamInterrupted = true
|
||||
t.Logf("✓ Stream was interrupted: %v", err)
|
||||
} else {
|
||||
t.Logf("Stream completed with error: %v", err)
|
||||
}
|
||||
} else {
|
||||
t.Log("Stream completed without error")
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Log("Stream timeout")
|
||||
cancel() // Cancel to stop the stream goroutine
|
||||
<-streamDone // Wait for goroutine to exit
|
||||
}
|
||||
|
||||
// Verify interrupt behavior
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
if handlerInvoked {
|
||||
t.Log("✓ Force interrupt handler was invoked")
|
||||
}
|
||||
if streamInterrupted {
|
||||
t.Log("✓ Stream was interrupted by force signal")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestAgentMultipleInterrupts tests multiple interrupts during stream
|
||||
func TestAgentMultipleInterrupts(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.interrupt")
|
||||
if err != nil {
|
||||
t.Skipf("Skipping test: assistant 'tests.interrupt' not found: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
t.Run("MultipleGracefulInterrupts", func(t *testing.T) {
|
||||
// Create context with interrupt support
|
||||
ctx, cancel := newTestContextWithInterrupt("chat-interrupt-multiple", "tests.interrupt")
|
||||
defer func() {
|
||||
cancel() // Cancel context first to stop goroutines
|
||||
time.Sleep(100 * time.Millisecond) // Wait for goroutines to exit
|
||||
ctx.Release()
|
||||
}()
|
||||
|
||||
handlerCallCount := 0
|
||||
ctx.Interrupt.SetHandler(func(c *context.Context, signal *context.InterruptSignal) error {
|
||||
handlerCallCount++
|
||||
t.Logf("✓ Interrupt handler invoked (call %d): %d messages", handlerCallCount, len(signal.Messages))
|
||||
return nil
|
||||
})
|
||||
|
||||
inputMessages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "Explain quantum computing in detail"},
|
||||
}
|
||||
|
||||
// Start streaming
|
||||
streamDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := agent.Stream(ctx, inputMessages)
|
||||
streamDone <- err
|
||||
}()
|
||||
|
||||
// Wait for stream to start
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
|
||||
// Send multiple graceful interrupts
|
||||
for i := 1; i <= 3; i++ {
|
||||
signal := &context.InterruptSignal{
|
||||
Type: context.InterruptGraceful,
|
||||
Messages: []context.Message{
|
||||
{Role: context.RoleUser, Content: fmt.Sprintf("Additional question %d", i)},
|
||||
},
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
err = context.SendInterrupt(ctx.ID, signal)
|
||||
if err != nil {
|
||||
t.Logf("Warning: Failed to send interrupt %d: %v", i, err)
|
||||
} else {
|
||||
t.Logf("✓ Sent interrupt %d", i)
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Wait for stream to complete
|
||||
select {
|
||||
case err := <-streamDone:
|
||||
if err != nil {
|
||||
t.Logf("Stream completed with error: %v", err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Log("Stream timeout")
|
||||
cancel() // Cancel to stop the stream goroutine
|
||||
<-streamDone // Wait for goroutine to exit
|
||||
}
|
||||
|
||||
// Check if interrupts were received
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
pendingCount := ctx.Interrupt.GetPendingCount()
|
||||
t.Logf("Handler was called %d times, pending count: %d", handlerCallCount, pendingCount)
|
||||
|
||||
if handlerCallCount > 0 {
|
||||
t.Log("✓ Multiple interrupts were processed")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestAgentInterruptWithoutStream tests interrupt behavior when no stream is active
|
||||
func TestAgentInterruptWithoutStream(t *testing.T) {
|
||||
t.Run("InterruptBeforeStream", func(t *testing.T) {
|
||||
// Create context with interrupt support
|
||||
ctx, cancel := newTestContextWithInterrupt("chat-interrupt-before", "test-assistant")
|
||||
defer func() {
|
||||
cancel()
|
||||
ctx.Release()
|
||||
}()
|
||||
|
||||
// Send interrupt before starting stream
|
||||
signal := &context.InterruptSignal{
|
||||
Type: context.InterruptGraceful,
|
||||
Messages: []context.Message{
|
||||
{Role: context.RoleUser, Content: "Early interrupt"},
|
||||
},
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
err := context.SendInterrupt(ctx.ID, signal)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to send interrupt: %v", err)
|
||||
}
|
||||
|
||||
// Wait for signal to be processed
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Check if signal is in queue
|
||||
receivedSignal := ctx.Interrupt.Peek()
|
||||
if receivedSignal == nil {
|
||||
t.Fatal("Expected interrupt signal to be queued")
|
||||
}
|
||||
|
||||
if receivedSignal.Type != context.InterruptGraceful {
|
||||
t.Errorf("Expected graceful interrupt, got: %s", receivedSignal.Type)
|
||||
}
|
||||
|
||||
t.Log("✓ Interrupt queued before stream starts")
|
||||
})
|
||||
}
|
||||
|
||||
// TestAgentInterruptContextCleanup tests cleanup after interrupt
|
||||
func TestAgentInterruptContextCleanup(t *testing.T) {
|
||||
t.Run("CleanupAfterInterrupt", func(t *testing.T) {
|
||||
ctx, cancel := newTestContextWithInterrupt("chat-interrupt-cleanup", "test-assistant")
|
||||
|
||||
// Send interrupt
|
||||
signal := &context.InterruptSignal{
|
||||
Type: context.InterruptGraceful,
|
||||
Messages: []context.Message{{Role: context.RoleUser, Content: "test"}},
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
context.SendInterrupt(ctx.ID, signal)
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Cancel and release context
|
||||
cancel()
|
||||
ctx.Release()
|
||||
|
||||
// Try to send interrupt to released context
|
||||
err := context.SendInterrupt(ctx.ID, signal)
|
||||
if err == nil {
|
||||
t.Error("Expected error when sending to released context")
|
||||
} else {
|
||||
t.Logf("✓ Correctly rejected interrupt to released context: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1,236 +0,0 @@
|
|||
package assistant_test
|
||||
|
||||
import (
|
||||
stdContext "context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/output/message"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
// newAgentNextTestContext creates a test context
|
||||
func newAgentNextTestContext(chatID, assistantID string) *context.Context {
|
||||
authorized := &types.AuthorizedInfo{
|
||||
Subject: "test-user",
|
||||
UserID: "test-123",
|
||||
TenantID: "test-tenant",
|
||||
}
|
||||
|
||||
ctx := context.New(stdContext.Background(), authorized, chatID)
|
||||
ctx.ID = chatID
|
||||
ctx.AssistantID = assistantID
|
||||
ctx.Locale = "en-us"
|
||||
ctx.Client = context.Client{
|
||||
Type: "web",
|
||||
IP: "127.0.0.1",
|
||||
}
|
||||
ctx.Referer = context.RefererAPI
|
||||
ctx.Accept = context.AcceptWebCUI
|
||||
ctx.IDGenerator = message.NewIDGenerator() // Initialize ID generator
|
||||
ctx.Metadata = make(map[string]interface{})
|
||||
return ctx
|
||||
}
|
||||
|
||||
// TestAgentNextStandard tests agent with Next Hook returning nil (standard response)
|
||||
func TestAgentNextStandard(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.realworld-next")
|
||||
assert.NoError(t, err)
|
||||
|
||||
ctx := newAgentNextTestContext("test-standard", "tests.realworld-next")
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "scenario: standard - Hello"},
|
||||
}
|
||||
|
||||
response, err := agent.Stream(ctx, messages)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, response)
|
||||
|
||||
assert.NotNil(t, response.Completion)
|
||||
assert.Nil(t, response.Next)
|
||||
|
||||
// Verify response structure
|
||||
assert.Equal(t, "tests.realworld-next", response.AssistantID)
|
||||
assert.NotEmpty(t, response.ContextID)
|
||||
assert.NotEmpty(t, response.RequestID)
|
||||
assert.NotEmpty(t, response.TraceID)
|
||||
assert.NotEmpty(t, response.ChatID)
|
||||
|
||||
// Verify completion has content
|
||||
assert.NotNil(t, response.Completion.Content)
|
||||
|
||||
t.Log("✓ Standard response test passed")
|
||||
}
|
||||
|
||||
// TestAgentNextCustomData tests agent with Next Hook returning custom data
|
||||
func TestAgentNextCustomData(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.realworld-next")
|
||||
assert.NoError(t, err)
|
||||
|
||||
ctx := newAgentNextTestContext("test-custom", "tests.realworld-next")
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "scenario: custom_data - Give me info"},
|
||||
}
|
||||
|
||||
response, err := agent.Stream(ctx, messages)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, response)
|
||||
|
||||
assert.NotNil(t, response.Completion)
|
||||
assert.NotNil(t, response.Next)
|
||||
|
||||
// Verify response structure
|
||||
assert.Equal(t, "tests.realworld-next", response.AssistantID)
|
||||
assert.NotEmpty(t, response.ContextID)
|
||||
assert.NotEmpty(t, response.RequestID)
|
||||
assert.NotEmpty(t, response.TraceID)
|
||||
|
||||
// Verify custom data structure (from scenarioCustomData)
|
||||
// response.Next contains the "data" field value from NextHookResponse
|
||||
nextData, ok := response.Next.(map[string]interface{})
|
||||
assert.True(t, ok, "Next should be a map")
|
||||
assert.Equal(t, "custom_response", nextData["type"])
|
||||
assert.Equal(t, "This is a custom response from Next Hook", nextData["message"])
|
||||
assert.NotEmpty(t, nextData["timestamp"])
|
||||
assert.NotNil(t, nextData["message_count"])
|
||||
|
||||
t.Log("✓ Custom data test passed")
|
||||
}
|
||||
|
||||
// TestAgentNextDelegate tests agent with Next Hook delegating to another agent
|
||||
func TestAgentNextDelegate(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.realworld-next")
|
||||
assert.NoError(t, err)
|
||||
|
||||
ctx := newAgentNextTestContext("test-delegate", "tests.realworld-next")
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "scenario: delegate - Forward this"},
|
||||
}
|
||||
|
||||
response, err := agent.Stream(ctx, messages)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, response)
|
||||
|
||||
// Verify response structure
|
||||
assert.NotEmpty(t, response.AssistantID)
|
||||
assert.NotEmpty(t, response.ContextID)
|
||||
assert.NotEmpty(t, response.RequestID)
|
||||
assert.NotEmpty(t, response.TraceID)
|
||||
|
||||
// Verify completion (delegated agent should have returned completion)
|
||||
assert.NotNil(t, response.Completion)
|
||||
assert.NotNil(t, response.Completion.Content)
|
||||
|
||||
// Next should be from the delegated agent
|
||||
// If delegated agent also has Next hook, it will be present
|
||||
t.Logf("✓ Delegation test passed (delegated to: %s)", response.AssistantID)
|
||||
}
|
||||
|
||||
// TestAgentNextConditional tests agent with conditional logic in Next Hook
|
||||
func TestAgentNextConditional(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.realworld-next")
|
||||
assert.NoError(t, err)
|
||||
|
||||
ctx := newAgentNextTestContext("test-conditional", "tests.realworld-next")
|
||||
messages := []context.Message{
|
||||
// Use conditional_success sub-scenario for deterministic behavior
|
||||
// This avoids test flakiness caused by LLM response unpredictability
|
||||
{Role: context.RoleUser, Content: "scenario: conditional_success - Task completed"},
|
||||
}
|
||||
|
||||
response, err := agent.Stream(ctx, messages)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, response)
|
||||
|
||||
assert.NotNil(t, response.Next)
|
||||
|
||||
// Verify response structure
|
||||
assert.Equal(t, "tests.realworld-next", response.AssistantID)
|
||||
assert.NotEmpty(t, response.ContextID)
|
||||
assert.NotEmpty(t, response.RequestID)
|
||||
assert.NotEmpty(t, response.TraceID)
|
||||
|
||||
// Verify conditional response structure (from scenarioConditional)
|
||||
// response.Next contains the "data" field value from NextHookResponse
|
||||
nextData, ok := response.Next.(map[string]interface{})
|
||||
assert.True(t, ok, "Next should be a map")
|
||||
assert.Equal(t, "Conditional analysis complete", nextData["message"])
|
||||
assert.Contains(t, nextData, "action")
|
||||
assert.Contains(t, nextData, "reason")
|
||||
assert.Contains(t, nextData, "conditions")
|
||||
|
||||
// Verify action is one of the expected values
|
||||
action, ok := nextData["action"].(string)
|
||||
assert.True(t, ok)
|
||||
assert.Contains(t, []string{"continue", "flag_for_review", "confirm_success", "summarize", "delegate"}, action)
|
||||
|
||||
t.Log("✓ Conditional logic test passed")
|
||||
}
|
||||
|
||||
// TestAgentWithoutNextHook tests agent without Next Hook
|
||||
func TestAgentWithoutNextHook(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.create")
|
||||
assert.NoError(t, err)
|
||||
|
||||
ctx := newAgentNextTestContext("test-no-next", "tests.create")
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "Hello"},
|
||||
}
|
||||
|
||||
response, err := agent.Stream(ctx, messages)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, response)
|
||||
|
||||
assert.Nil(t, response.Next)
|
||||
|
||||
// Verify response structure
|
||||
assert.Equal(t, "tests.create", response.AssistantID)
|
||||
assert.NotEmpty(t, response.ContextID)
|
||||
assert.NotEmpty(t, response.RequestID)
|
||||
assert.NotEmpty(t, response.TraceID)
|
||||
assert.NotEmpty(t, response.ChatID)
|
||||
|
||||
// Verify completion
|
||||
assert.NotNil(t, response.Completion)
|
||||
assert.NotNil(t, response.Completion.Content)
|
||||
|
||||
t.Log("✓ No Next Hook test passed")
|
||||
}
|
||||
|
|
@ -1,704 +0,0 @@
|
|||
package assistant
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/yaoapp/gou/fs"
|
||||
"github.com/yaoapp/yao/agent/caller"
|
||||
agentContext "github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/i18n"
|
||||
"github.com/yaoapp/yao/agent/llm"
|
||||
"github.com/yaoapp/yao/agent/search"
|
||||
searchTypes "github.com/yaoapp/yao/agent/search/types"
|
||||
store "github.com/yaoapp/yao/agent/store/types"
|
||||
"github.com/yaoapp/yao/openapi/utils"
|
||||
sui "github.com/yaoapp/yao/sui/core"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Initialize AgentGetterFunc to allow content and search packages to call agents
|
||||
caller.AgentGetterFunc = func(agentID string) (caller.AgentCaller, error) {
|
||||
ast, err := Get(agentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Return a wrapper that implements AgentCaller interface
|
||||
return &agentCallerWrapper{ast: ast}, nil
|
||||
}
|
||||
|
||||
// Initialize AssistantReloadFunc for hot-reload after deploy
|
||||
caller.AssistantReloadFunc = func(id string) error {
|
||||
p := "/assistants/" + strings.Replace(id, ".", "/", 1)
|
||||
ast, err := LoadPath(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ast.BuiltIn = true
|
||||
ast.Readonly = true
|
||||
if ast.Tags == nil {
|
||||
ast.Tags = []string{}
|
||||
}
|
||||
if err := ast.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ast.initialize(); err != nil {
|
||||
return err
|
||||
}
|
||||
loaded.Put(ast)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Initialize Agent JSAPI factory for ctx.agent.* methods
|
||||
caller.SetJSAPIFactory()
|
||||
|
||||
// Initialize LLM JSAPI factory for ctx.llm.* methods
|
||||
llm.SetJSAPIFactory()
|
||||
|
||||
// Initialize Search JSAPI factory with config getter
|
||||
search.SetJSAPIFactory(func(assistantID string) (*searchTypes.Config, *search.Uses) {
|
||||
ast, err := Get(assistantID)
|
||||
if err != nil || ast == nil {
|
||||
return nil, nil
|
||||
}
|
||||
// Convert assistant.Uses to search.Uses
|
||||
var uses *search.Uses
|
||||
if ast.Uses != nil {
|
||||
uses = &search.Uses{
|
||||
Search: ast.Uses.Search,
|
||||
Web: ast.Uses.Web,
|
||||
Keyword: ast.Uses.Keyword,
|
||||
QueryDSL: ast.Uses.QueryDSL,
|
||||
Rerank: ast.Uses.Rerank,
|
||||
}
|
||||
}
|
||||
return ast.Search, uses
|
||||
})
|
||||
}
|
||||
|
||||
// agentCallerWrapper wraps Assistant to implement AgentCaller interface
|
||||
type agentCallerWrapper struct {
|
||||
ast *Assistant
|
||||
}
|
||||
|
||||
func (w *agentCallerWrapper) Stream(ctx *agentContext.Context, messages []agentContext.Message, options ...*agentContext.Options) (*agentContext.Response, error) {
|
||||
return w.ast.Stream(ctx, messages, options...)
|
||||
}
|
||||
|
||||
// Get get the assistant by id
|
||||
func Get(id string) (*Assistant, error) {
|
||||
return LoadStore(id)
|
||||
}
|
||||
|
||||
// GetPlaceholder returns the placeholder of the assistant
|
||||
func (ast *Assistant) GetPlaceholder(locale string) *store.Placeholder {
|
||||
|
||||
prompts := []string{}
|
||||
if ast.Placeholder.Prompts != nil {
|
||||
prompts = i18n.Translate(ast.ID, locale, ast.Placeholder.Prompts).([]string)
|
||||
}
|
||||
title := i18n.Translate(ast.ID, locale, ast.Placeholder.Title).(string)
|
||||
description := i18n.Translate(ast.ID, locale, ast.Placeholder.Description).(string)
|
||||
return &store.Placeholder{
|
||||
Title: title,
|
||||
Description: description,
|
||||
Prompts: prompts,
|
||||
}
|
||||
}
|
||||
|
||||
// GetName returns the name of the assistant
|
||||
func (ast *Assistant) GetName(locale string) string {
|
||||
return i18n.Translate(ast.ID, locale, ast.Name).(string)
|
||||
}
|
||||
|
||||
// GetDescription returns the description of the assistant
|
||||
func (ast *Assistant) GetDescription(locale string) string {
|
||||
return i18n.Translate(ast.ID, locale, ast.Description).(string)
|
||||
}
|
||||
|
||||
// Save save the assistant
|
||||
func (ast *Assistant) Save() error {
|
||||
if storage == nil {
|
||||
return fmt.Errorf("storage is not set")
|
||||
}
|
||||
|
||||
_, err := storage.SaveAssistant(&ast.AssistantModel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Map convert the assistant to a map
|
||||
func (ast *Assistant) Map() map[string]interface{} {
|
||||
|
||||
if ast == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"assistant_id": ast.ID,
|
||||
"type": ast.Type,
|
||||
"name": ast.Name,
|
||||
"readonly": ast.Readonly,
|
||||
"public": ast.Public,
|
||||
"share": ast.Share,
|
||||
"avatar": ast.Avatar,
|
||||
"connector": ast.Connector,
|
||||
"connector_options": ast.ConnectorOptions,
|
||||
"path": ast.Path,
|
||||
"built_in": ast.BuiltIn,
|
||||
"sort": ast.Sort,
|
||||
"description": ast.Description,
|
||||
"options": ast.Options,
|
||||
"prompts": ast.Prompts,
|
||||
"prompt_presets": ast.PromptPresets,
|
||||
"disable_global_prompts": ast.DisableGlobalPrompts,
|
||||
"source": ast.Source,
|
||||
"kb": ast.KB,
|
||||
"db": ast.DB,
|
||||
"mcp": ast.MCP,
|
||||
"workflow": ast.Workflow,
|
||||
"tags": ast.Tags,
|
||||
"modes": ast.Modes,
|
||||
"default_mode": ast.DefaultMode,
|
||||
"mentionable": ast.Mentionable,
|
||||
"automated": ast.Automated,
|
||||
"placeholder": ast.Placeholder,
|
||||
"locales": ast.Locales,
|
||||
"uses": ast.Uses,
|
||||
"search": ast.Search,
|
||||
"dependencies": ast.Dependencies,
|
||||
"created_at": utils.NanoToTime(ast.CreatedAt),
|
||||
"updated_at": utils.NanoToTime(ast.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
// Validate validates the assistant configuration
|
||||
func (ast *Assistant) Validate() error {
|
||||
if ast.ID == "" {
|
||||
return fmt.Errorf("assistant_id is required")
|
||||
}
|
||||
if ast.Name == "" {
|
||||
return fmt.Errorf("name is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Assets get the assets content
|
||||
func (ast *Assistant) Assets(name string, data sui.Data) (string, error) {
|
||||
|
||||
app, err := fs.Get("app")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
root := path.Join(ast.Path, "assets", name)
|
||||
raw, err := app.ReadFile(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if data != nil {
|
||||
content, _ := data.Replace(string(raw))
|
||||
return content, nil
|
||||
}
|
||||
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
// Clone creates a deep copy of the assistant
|
||||
func (ast *Assistant) Clone() *Assistant {
|
||||
if ast == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
clone := &Assistant{
|
||||
AssistantModel: store.AssistantModel{
|
||||
ID: ast.ID,
|
||||
Type: ast.Type,
|
||||
Name: ast.Name,
|
||||
Avatar: ast.Avatar,
|
||||
Connector: ast.Connector,
|
||||
Path: ast.Path,
|
||||
BuiltIn: ast.BuiltIn,
|
||||
Sort: ast.Sort,
|
||||
Description: ast.Description,
|
||||
Readonly: ast.Readonly,
|
||||
Public: ast.Public,
|
||||
Share: ast.Share,
|
||||
Mentionable: ast.Mentionable,
|
||||
Automated: ast.Automated,
|
||||
DisableGlobalPrompts: ast.DisableGlobalPrompts,
|
||||
Source: ast.Source,
|
||||
CreatedAt: ast.CreatedAt,
|
||||
UpdatedAt: ast.UpdatedAt,
|
||||
},
|
||||
HookScript: ast.HookScript,
|
||||
}
|
||||
|
||||
// Deep copy tags
|
||||
if ast.Tags != nil {
|
||||
clone.Tags = make([]string, len(ast.Tags))
|
||||
copy(clone.Tags, ast.Tags)
|
||||
}
|
||||
|
||||
// Deep copy modes
|
||||
if ast.Modes != nil {
|
||||
clone.Modes = make([]string, len(ast.Modes))
|
||||
copy(clone.Modes, ast.Modes)
|
||||
}
|
||||
|
||||
// Copy default_mode (simple string)
|
||||
clone.DefaultMode = ast.DefaultMode
|
||||
|
||||
// Deep copy KB
|
||||
if ast.KB != nil {
|
||||
clone.KB = &store.KnowledgeBase{}
|
||||
if ast.KB.Collections != nil {
|
||||
clone.KB.Collections = make([]string, len(ast.KB.Collections))
|
||||
copy(clone.KB.Collections, ast.KB.Collections)
|
||||
}
|
||||
if ast.KB.Options != nil {
|
||||
clone.KB.Options = make(map[string]interface{})
|
||||
for k, v := range ast.KB.Options {
|
||||
clone.KB.Options[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deep copy DB
|
||||
if ast.DB != nil {
|
||||
clone.DB = &store.Database{}
|
||||
if ast.DB.Models != nil {
|
||||
clone.DB.Models = make([]string, len(ast.DB.Models))
|
||||
copy(clone.DB.Models, ast.DB.Models)
|
||||
}
|
||||
if ast.DB.Options != nil {
|
||||
clone.DB.Options = make(map[string]interface{})
|
||||
for k, v := range ast.DB.Options {
|
||||
clone.DB.Options[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deep copy MCP
|
||||
if ast.MCP != nil {
|
||||
clone.MCP = &store.MCPServers{}
|
||||
if ast.MCP.Servers != nil {
|
||||
clone.MCP.Servers = make([]store.MCPServerConfig, len(ast.MCP.Servers))
|
||||
for i, server := range ast.MCP.Servers {
|
||||
clone.MCP.Servers[i] = store.MCPServerConfig{
|
||||
ServerID: server.ServerID,
|
||||
}
|
||||
// Deep copy Resources slice
|
||||
if server.Resources != nil {
|
||||
clone.MCP.Servers[i].Resources = make([]string, len(server.Resources))
|
||||
copy(clone.MCP.Servers[i].Resources, server.Resources)
|
||||
}
|
||||
// Deep copy Tools slice
|
||||
if server.Tools != nil {
|
||||
clone.MCP.Servers[i].Tools = make([]string, len(server.Tools))
|
||||
copy(clone.MCP.Servers[i].Tools, server.Tools)
|
||||
}
|
||||
}
|
||||
}
|
||||
if ast.MCP.Options != nil {
|
||||
clone.MCP.Options = make(map[string]interface{})
|
||||
for k, v := range ast.MCP.Options {
|
||||
clone.MCP.Options[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deep copy options
|
||||
if ast.Options != nil {
|
||||
clone.Options = make(map[string]interface{})
|
||||
for k, v := range ast.Options {
|
||||
clone.Options[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Deep copy prompts
|
||||
if ast.Prompts != nil {
|
||||
clone.Prompts = make([]store.Prompt, len(ast.Prompts))
|
||||
copy(clone.Prompts, ast.Prompts)
|
||||
}
|
||||
|
||||
// Deep copy prompt presets
|
||||
if ast.PromptPresets != nil {
|
||||
clone.PromptPresets = make(map[string][]store.Prompt)
|
||||
for k, v := range ast.PromptPresets {
|
||||
prompts := make([]store.Prompt, len(v))
|
||||
copy(prompts, v)
|
||||
clone.PromptPresets[k] = prompts
|
||||
}
|
||||
}
|
||||
|
||||
// Deep copy connector options
|
||||
if ast.ConnectorOptions != nil {
|
||||
clone.ConnectorOptions = &store.ConnectorOptions{
|
||||
Optional: ast.ConnectorOptions.Optional,
|
||||
}
|
||||
if ast.ConnectorOptions.Connectors != nil {
|
||||
clone.ConnectorOptions.Connectors = make([]string, len(ast.ConnectorOptions.Connectors))
|
||||
copy(clone.ConnectorOptions.Connectors, ast.ConnectorOptions.Connectors)
|
||||
}
|
||||
if ast.ConnectorOptions.Filters != nil {
|
||||
clone.ConnectorOptions.Filters = make([]store.ModelCapability, len(ast.ConnectorOptions.Filters))
|
||||
copy(clone.ConnectorOptions.Filters, ast.ConnectorOptions.Filters)
|
||||
}
|
||||
}
|
||||
|
||||
// Deep copy workflow
|
||||
if ast.Workflow != nil {
|
||||
clone.Workflow = &store.Workflow{}
|
||||
if ast.Workflow.Workflows != nil {
|
||||
clone.Workflow.Workflows = make([]string, len(ast.Workflow.Workflows))
|
||||
copy(clone.Workflow.Workflows, ast.Workflow.Workflows)
|
||||
}
|
||||
if ast.Workflow.Options != nil {
|
||||
clone.Workflow.Options = make(map[string]interface{})
|
||||
for k, v := range ast.Workflow.Options {
|
||||
clone.Workflow.Options[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deep copy placeholder
|
||||
if ast.Placeholder != nil {
|
||||
clone.Placeholder = &store.Placeholder{
|
||||
Title: ast.Placeholder.Title,
|
||||
Description: ast.Placeholder.Description,
|
||||
}
|
||||
if ast.Placeholder.Prompts != nil {
|
||||
clone.Placeholder.Prompts = make([]string, len(ast.Placeholder.Prompts))
|
||||
copy(clone.Placeholder.Prompts, ast.Placeholder.Prompts)
|
||||
}
|
||||
}
|
||||
|
||||
// Deep copy locales
|
||||
if ast.Locales != nil {
|
||||
clone.Locales = make(i18n.Map)
|
||||
for k, v := range ast.Locales {
|
||||
// Deep copy messages
|
||||
messages := make(map[string]any)
|
||||
if v.Messages != nil {
|
||||
for mk, mv := range v.Messages {
|
||||
messages[mk] = mv
|
||||
}
|
||||
}
|
||||
clone.Locales[k] = i18n.I18n{
|
||||
Locale: v.Locale,
|
||||
Messages: messages,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deep copy uses
|
||||
if ast.Uses != nil {
|
||||
clone.Uses = &agentContext.Uses{
|
||||
Vision: ast.Uses.Vision,
|
||||
Audio: ast.Uses.Audio,
|
||||
Search: ast.Uses.Search,
|
||||
Fetch: ast.Uses.Fetch,
|
||||
Web: ast.Uses.Web,
|
||||
Keyword: ast.Uses.Keyword,
|
||||
QueryDSL: ast.Uses.QueryDSL,
|
||||
Rerank: ast.Uses.Rerank,
|
||||
}
|
||||
}
|
||||
|
||||
// Deep copy search config
|
||||
if ast.Search != nil {
|
||||
clone.Search = &searchTypes.Config{}
|
||||
if ast.Search.Web != nil {
|
||||
clone.Search.Web = &searchTypes.WebConfig{
|
||||
Provider: ast.Search.Web.Provider,
|
||||
APIKeyEnv: ast.Search.Web.APIKeyEnv,
|
||||
MaxResults: ast.Search.Web.MaxResults,
|
||||
}
|
||||
}
|
||||
if ast.Search.KB != nil {
|
||||
clone.Search.KB = &searchTypes.KBConfig{
|
||||
Threshold: ast.Search.KB.Threshold,
|
||||
Graph: ast.Search.KB.Graph,
|
||||
}
|
||||
if ast.Search.KB.Collections != nil {
|
||||
clone.Search.KB.Collections = make([]string, len(ast.Search.KB.Collections))
|
||||
copy(clone.Search.KB.Collections, ast.Search.KB.Collections)
|
||||
}
|
||||
}
|
||||
if ast.Search.DB != nil {
|
||||
clone.Search.DB = &searchTypes.DBConfig{
|
||||
MaxResults: ast.Search.DB.MaxResults,
|
||||
}
|
||||
if ast.Search.DB.Models != nil {
|
||||
clone.Search.DB.Models = make([]string, len(ast.Search.DB.Models))
|
||||
copy(clone.Search.DB.Models, ast.Search.DB.Models)
|
||||
}
|
||||
}
|
||||
if ast.Search.Keyword != nil {
|
||||
clone.Search.Keyword = &searchTypes.KeywordConfig{
|
||||
MaxKeywords: ast.Search.Keyword.MaxKeywords,
|
||||
Language: ast.Search.Keyword.Language,
|
||||
}
|
||||
}
|
||||
if ast.Search.QueryDSL != nil {
|
||||
clone.Search.QueryDSL = &searchTypes.QueryDSLConfig{
|
||||
Strict: ast.Search.QueryDSL.Strict,
|
||||
}
|
||||
}
|
||||
if ast.Search.Rerank != nil {
|
||||
clone.Search.Rerank = &searchTypes.RerankConfig{
|
||||
TopN: ast.Search.Rerank.TopN,
|
||||
}
|
||||
}
|
||||
if ast.Search.Citation != nil {
|
||||
clone.Search.Citation = &searchTypes.CitationConfig{
|
||||
Format: ast.Search.Citation.Format,
|
||||
AutoInjectPrompt: ast.Search.Citation.AutoInjectPrompt,
|
||||
CustomPrompt: ast.Search.Citation.CustomPrompt,
|
||||
}
|
||||
}
|
||||
if ast.Search.Weights != nil {
|
||||
clone.Search.Weights = &searchTypes.WeightsConfig{
|
||||
User: ast.Search.Weights.User,
|
||||
Hook: ast.Search.Weights.Hook,
|
||||
Auto: ast.Search.Weights.Auto,
|
||||
}
|
||||
}
|
||||
if ast.Search.Options != nil {
|
||||
clone.Search.Options = &searchTypes.OptionsConfig{
|
||||
SkipThreshold: ast.Search.Options.SkipThreshold,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deep copy dependencies
|
||||
if ast.Dependencies != nil {
|
||||
clone.Dependencies = make(map[string]string, len(ast.Dependencies))
|
||||
for k, v := range ast.Dependencies {
|
||||
clone.Dependencies[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return clone
|
||||
}
|
||||
|
||||
// GetInfo returns the basic info of the assistant with optional locale
|
||||
func (ast *Assistant) GetInfo(locale ...string) *store.AssistantInfo {
|
||||
if ast == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
loc := ""
|
||||
if len(locale) > 0 {
|
||||
loc = locale[0]
|
||||
}
|
||||
|
||||
info := &store.AssistantInfo{
|
||||
AssistantID: ast.ID,
|
||||
Avatar: ast.Avatar,
|
||||
Connector: ast.Connector,
|
||||
ConnectorOptions: ast.ConnectorOptions,
|
||||
Modes: ast.Modes,
|
||||
DefaultMode: ast.DefaultMode,
|
||||
Sandbox: ast.IsSandbox,
|
||||
ComputerFilter: ast.ComputerFilter,
|
||||
}
|
||||
|
||||
if loc != "" {
|
||||
info.Name = ast.GetName(loc)
|
||||
info.Description = ast.GetDescription(loc)
|
||||
} else {
|
||||
info.Name = ast.Name
|
||||
info.Description = ast.Description
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// GetInfoByIDs retrieves basic info for multiple assistants by their IDs
|
||||
// Returns a map of assistant_id -> AssistantInfo
|
||||
func GetInfoByIDs(ids []string, locale ...string) map[string]*store.AssistantInfo {
|
||||
result := make(map[string]*store.AssistantInfo)
|
||||
|
||||
if len(ids) == 0 {
|
||||
return result
|
||||
}
|
||||
|
||||
for _, id := range ids {
|
||||
ast, err := Get(id)
|
||||
if err != nil || ast == nil {
|
||||
continue
|
||||
}
|
||||
result[id] = ast.GetInfo(locale...)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Update updates the assistant properties
|
||||
func (ast *Assistant) Update(data map[string]interface{}) error {
|
||||
if ast == nil {
|
||||
return fmt.Errorf("assistant is nil")
|
||||
}
|
||||
|
||||
if v, ok := data["name"].(string); ok {
|
||||
ast.Name = v
|
||||
}
|
||||
if v, ok := data["avatar"].(string); ok {
|
||||
ast.Avatar = v
|
||||
}
|
||||
if v, ok := data["description"].(string); ok {
|
||||
ast.Description = v
|
||||
}
|
||||
if v, ok := data["connector"].(string); ok {
|
||||
ast.Connector = v
|
||||
}
|
||||
|
||||
// Note: tools field is deprecated, now handled by MCP
|
||||
|
||||
if v, ok := data["type"].(string); ok {
|
||||
ast.Type = v
|
||||
}
|
||||
if v, ok := data["sort"].(int); ok {
|
||||
ast.Sort = v
|
||||
}
|
||||
if v, ok := data["mentionable"].(bool); ok {
|
||||
ast.Mentionable = v
|
||||
}
|
||||
if v, ok := data["automated"].(bool); ok {
|
||||
ast.Automated = v
|
||||
}
|
||||
if v, ok := data["disable_global_prompts"].(bool); ok {
|
||||
ast.DisableGlobalPrompts = v
|
||||
}
|
||||
if v, ok := data["readonly"].(bool); ok {
|
||||
ast.Readonly = v
|
||||
}
|
||||
if v, ok := data["public"].(bool); ok {
|
||||
ast.Public = v
|
||||
}
|
||||
if v, ok := data["share"].(string); ok {
|
||||
ast.Share = v
|
||||
}
|
||||
if v, ok := data["tags"].([]string); ok {
|
||||
ast.Tags = v
|
||||
}
|
||||
if v, ok := data["modes"].([]string); ok {
|
||||
ast.Modes = v
|
||||
}
|
||||
if v, ok := data["default_mode"].(string); ok {
|
||||
ast.DefaultMode = v
|
||||
}
|
||||
if v, ok := data["options"].(map[string]interface{}); ok {
|
||||
ast.Options = v
|
||||
}
|
||||
if v, ok := data["source"].(string); ok {
|
||||
ast.Source = v
|
||||
}
|
||||
|
||||
// ConnectorOptions
|
||||
if v, has := data["connector_options"]; has {
|
||||
connOpts, err := store.ToConnectorOptions(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ast.ConnectorOptions = connOpts
|
||||
}
|
||||
|
||||
// PromptPresets
|
||||
if v, has := data["prompt_presets"]; has {
|
||||
presets, err := store.ToPromptPresets(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ast.PromptPresets = presets
|
||||
}
|
||||
|
||||
// KB
|
||||
if v, has := data["kb"]; has {
|
||||
kb, err := store.ToKnowledgeBase(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ast.KB = kb
|
||||
}
|
||||
|
||||
// DB
|
||||
if v, has := data["db"]; has {
|
||||
db, err := store.ToDatabase(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ast.DB = db
|
||||
}
|
||||
|
||||
// MCP
|
||||
if v, has := data["mcp"]; has {
|
||||
mcp, err := store.ToMCPServers(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ast.MCP = mcp
|
||||
}
|
||||
|
||||
// Workflow
|
||||
if v, has := data["workflow"]; has {
|
||||
workflow, err := store.ToWorkflow(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ast.Workflow = workflow
|
||||
}
|
||||
|
||||
// Uses
|
||||
if v, has := data["uses"]; has {
|
||||
uses, err := store.ToUses(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ast.Uses = uses
|
||||
}
|
||||
|
||||
// Search
|
||||
if v, has := data["search"]; has {
|
||||
search, err := store.ToSearchConfig(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ast.Search = search
|
||||
}
|
||||
|
||||
// Dependencies
|
||||
if v, has := data["dependencies"]; has {
|
||||
if v == nil {
|
||||
ast.Dependencies = nil
|
||||
} else {
|
||||
switch d := v.(type) {
|
||||
case map[string]string:
|
||||
ast.Dependencies = d
|
||||
case map[string]interface{}:
|
||||
deps := make(map[string]string, len(d))
|
||||
for k, val := range d {
|
||||
if s, ok := val.(string); ok {
|
||||
deps[k] = s
|
||||
}
|
||||
}
|
||||
ast.Dependencies = deps
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ast.Validate()
|
||||
}
|
||||
|
||||
// GetMergedSearchConfig returns the search config for this assistant
|
||||
// Note: The config is already merged with global config during loading (loadMap)
|
||||
func (ast *Assistant) GetMergedSearchConfig() *searchTypes.Config {
|
||||
return ast.Search
|
||||
}
|
||||
|
|
@ -1,595 +0,0 @@
|
|||
package assistant
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cast"
|
||||
"github.com/yaoapp/gou/json"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
store "github.com/yaoapp/yao/agent/store/types"
|
||||
)
|
||||
|
||||
// BuildRequest build the LLM request
|
||||
func (ast *Assistant) BuildRequest(ctx *context.Context, messages []context.Message, createResponse *context.HookCreateResponse) ([]context.Message, *context.CompletionOptions, error) {
|
||||
// Build completion options from createResponse and ctx (includes MCP tools)
|
||||
options, mcpSamplesPrompt, err := ast.buildCompletionOptions(ctx, createResponse)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Build final messages with proper priority (includes MCP samples if available)
|
||||
finalMessages, err := ast.buildMessages(ctx, messages, createResponse, mcpSamplesPrompt)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return finalMessages, options, nil
|
||||
}
|
||||
|
||||
// buildMessages builds the final message list with proper priority
|
||||
// Priority: Prompts > MCP Samples > createResponse.Messages > input messages
|
||||
// If createResponse is nil or has no messages, use input messages
|
||||
func (ast *Assistant) buildMessages(ctx *context.Context, messages []context.Message, createResponse *context.HookCreateResponse, mcpSamplesPrompt string) ([]context.Message, error) {
|
||||
var finalMessages []context.Message
|
||||
|
||||
// If createResponse is nil or has no messages, use input messages
|
||||
if createResponse == nil || len(createResponse.Messages) == 0 {
|
||||
finalMessages = messages
|
||||
} else {
|
||||
// createResponse.Messages takes priority over input messages
|
||||
finalMessages = createResponse.Messages
|
||||
}
|
||||
|
||||
// Add MCP samples prompt as a system message (if available)
|
||||
if mcpSamplesPrompt != "" {
|
||||
mcpSamplesMsg := context.Message{
|
||||
Role: context.RoleSystem,
|
||||
Content: mcpSamplesPrompt,
|
||||
}
|
||||
// Prepend MCP samples before other messages
|
||||
finalMessages = append([]context.Message{mcpSamplesMsg}, finalMessages...)
|
||||
}
|
||||
|
||||
// Build and prepend system prompts (global + assistant prompts)
|
||||
promptMessages := ast.buildSystemPrompts(ctx, createResponse)
|
||||
if len(promptMessages) > 0 {
|
||||
finalMessages = append(promptMessages, finalMessages...)
|
||||
}
|
||||
|
||||
return finalMessages, nil
|
||||
}
|
||||
|
||||
// buildSystemPrompts builds system prompt messages from global prompts and assistant prompts
|
||||
// Order: Global prompts (if not disabled) -> Assistant prompts (or preset)
|
||||
// Variables are parsed with context information
|
||||
//
|
||||
// Priority for prompt preset selection:
|
||||
// 1. createResponse.PromptPreset (highest)
|
||||
// 2. ctx.Metadata["__prompt_preset"]
|
||||
// 3. ast.Prompts (default)
|
||||
//
|
||||
// Priority for disable global prompts:
|
||||
// 1. createResponse.DisableGlobalPrompts (highest)
|
||||
// 2. ctx.Metadata["__disable_global_prompts"]
|
||||
// 3. ast.DisableGlobalPrompts (default)
|
||||
func (ast *Assistant) buildSystemPrompts(ctx *context.Context, createResponse *context.HookCreateResponse) []context.Message {
|
||||
// Build context variables from ctx and ast
|
||||
ctxVars := ast.buildContextVariables(ctx)
|
||||
|
||||
// Determine if global prompts should be disabled
|
||||
disableGlobal := ast.shouldDisableGlobalPrompts(ctx, createResponse)
|
||||
|
||||
// Get assistant prompts (default or preset)
|
||||
assistantPrompts := ast.getAssistantPrompts(ctx, createResponse)
|
||||
|
||||
var allPrompts []store.Prompt
|
||||
|
||||
// 1. Add global prompts (if not disabled)
|
||||
if !disableGlobal && len(globalPrompts) > 0 {
|
||||
// Parse global prompts with context variables
|
||||
parsedGlobal := store.Prompts(globalPrompts).Parse(ctxVars)
|
||||
allPrompts = append(allPrompts, parsedGlobal...)
|
||||
}
|
||||
|
||||
// 2. Add assistant prompts (default or preset)
|
||||
if len(assistantPrompts) > 0 {
|
||||
// Parse assistant prompts with context variables
|
||||
parsedAssistant := store.Prompts(assistantPrompts).Parse(ctxVars)
|
||||
allPrompts = append(allPrompts, parsedAssistant...)
|
||||
}
|
||||
|
||||
// Convert to context.Message slice
|
||||
if len(allPrompts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
messages := make([]context.Message, 0, len(allPrompts))
|
||||
for _, prompt := range allPrompts {
|
||||
msg := context.Message{
|
||||
Role: context.MessageRole(prompt.Role),
|
||||
Content: prompt.Content,
|
||||
}
|
||||
if prompt.Name != "" {
|
||||
name := prompt.Name
|
||||
msg.Name = &name
|
||||
}
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
// shouldDisableGlobalPrompts determines if global prompts should be disabled
|
||||
// Priority: createResponse > ctx.Metadata > ast.DisableGlobalPrompts
|
||||
func (ast *Assistant) shouldDisableGlobalPrompts(ctx *context.Context, createResponse *context.HookCreateResponse) bool {
|
||||
// Priority 1: Hook response (highest)
|
||||
if createResponse != nil && createResponse.DisableGlobalPrompts != nil {
|
||||
return *createResponse.DisableGlobalPrompts
|
||||
}
|
||||
|
||||
// Priority 2: ctx.Metadata["__disable_global_prompts"]
|
||||
if ctx != nil && ctx.Metadata != nil {
|
||||
if disable, ok := ctx.Metadata["__disable_global_prompts"].(bool); ok {
|
||||
return disable
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: Assistant configuration (default)
|
||||
return ast.DisableGlobalPrompts
|
||||
}
|
||||
|
||||
// getAssistantPrompts returns the assistant prompts based on preset selection
|
||||
// Priority: createResponse.PromptPreset > ctx.Metadata["__prompt_preset"] > ast.Prompts
|
||||
func (ast *Assistant) getAssistantPrompts(ctx *context.Context, createResponse *context.HookCreateResponse) []store.Prompt {
|
||||
// Get preset key
|
||||
presetKey := ast.getPromptPresetKey(ctx, createResponse)
|
||||
|
||||
// If preset key is specified and exists, use it
|
||||
if presetKey != "" && ast.PromptPresets != nil {
|
||||
if presets, ok := ast.PromptPresets[presetKey]; ok && len(presets) > 0 {
|
||||
return presets
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to default prompts
|
||||
return ast.Prompts
|
||||
}
|
||||
|
||||
// getPromptPresetKey returns the prompt preset key
|
||||
// Priority: createResponse.PromptPreset > ctx.Metadata["__prompt_preset"]
|
||||
func (ast *Assistant) getPromptPresetKey(ctx *context.Context, createResponse *context.HookCreateResponse) string {
|
||||
// Priority 1: Hook response (highest)
|
||||
if createResponse != nil && createResponse.PromptPreset != "" {
|
||||
return createResponse.PromptPreset
|
||||
}
|
||||
|
||||
// Priority 2: ctx.Metadata["__prompt_preset"]
|
||||
if ctx != nil && ctx.Metadata != nil {
|
||||
if preset, ok := ctx.Metadata["__prompt_preset"].(string); ok && preset != "" {
|
||||
return preset
|
||||
}
|
||||
}
|
||||
|
||||
// No preset specified
|
||||
return ""
|
||||
}
|
||||
|
||||
// buildContextVariables extracts context variables from Context and Assistant for prompt parsing
|
||||
func (ast *Assistant) buildContextVariables(ctx *context.Context) map[string]string {
|
||||
vars := make(map[string]string)
|
||||
|
||||
// Get locale from ctx (default to empty)
|
||||
locale := ""
|
||||
if ctx != nil && ctx.Locale != "" {
|
||||
locale = ctx.Locale
|
||||
}
|
||||
|
||||
// Assistant info (with locale support)
|
||||
if ast != nil {
|
||||
if ast.ID != "" {
|
||||
vars["ASSISTANT_ID"] = ast.ID
|
||||
}
|
||||
// Use localized name and description
|
||||
name := ast.GetName(locale)
|
||||
if name != "" {
|
||||
vars["ASSISTANT_NAME"] = name
|
||||
}
|
||||
description := ast.GetDescription(locale)
|
||||
if description != "" {
|
||||
vars["ASSISTANT_DESCRIPTION"] = description
|
||||
}
|
||||
if ast.Type != "" {
|
||||
vars["ASSISTANT_TYPE"] = ast.Type
|
||||
}
|
||||
}
|
||||
|
||||
// Workspace info
|
||||
if workspaceID, err := ctx.GetWorkspaceID(); err == nil {
|
||||
vars["WORKSPACE_ID"] = workspaceID
|
||||
}
|
||||
|
||||
if ctx == nil {
|
||||
return vars
|
||||
}
|
||||
|
||||
// Basic context info
|
||||
if ctx.ChatID != "" {
|
||||
vars["CHAT_ID"] = ctx.ChatID
|
||||
}
|
||||
if ctx.Locale != "" {
|
||||
vars["LOCALE"] = ctx.Locale
|
||||
}
|
||||
if ctx.Theme != "" {
|
||||
vars["THEME"] = ctx.Theme
|
||||
}
|
||||
if ctx.Route != "" {
|
||||
vars["ROUTE"] = ctx.Route
|
||||
}
|
||||
if ctx.Referer != "" {
|
||||
vars["REFERER"] = ctx.Referer
|
||||
}
|
||||
|
||||
// Client info (only non-sensitive fields)
|
||||
if ctx.Client.Type != "" {
|
||||
vars["CLIENT_TYPE"] = ctx.Client.Type
|
||||
}
|
||||
|
||||
// Authorized info (only internal IDs, no PII)
|
||||
// Note: USER_SUBJECT and CLIENT_IP are excluded for privacy/GDPR compliance
|
||||
if ctx.Authorized != nil {
|
||||
if ctx.Authorized.UserID != "" {
|
||||
vars["USER_ID"] = ctx.Authorized.UserID
|
||||
}
|
||||
if ctx.Authorized.TeamID != "" {
|
||||
vars["TEAM_ID"] = ctx.Authorized.TeamID
|
||||
}
|
||||
if ctx.Authorized.TenantID != "" {
|
||||
vars["TENANT_ID"] = ctx.Authorized.TenantID
|
||||
}
|
||||
}
|
||||
|
||||
// Metadata - custom variables from ctx.Metadata
|
||||
// All metadata keys are exposed as $CTX.{KEY}
|
||||
// Supports string, int, uint, float, bool types
|
||||
if ctx.Metadata != nil {
|
||||
for key, value := range ctx.Metadata {
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
strVal := cast.ToString(value)
|
||||
if strVal != "" {
|
||||
vars[key] = strVal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return vars
|
||||
}
|
||||
|
||||
// buildCompletionOptions builds completion options from multiple sources
|
||||
// Priority (lowest to highest, later overrides earlier): ast > ctx > createResponse
|
||||
// The priority means: if createResponse has a value, use it; else use ctx; else use ast
|
||||
// Returns (options, mcpSamplesPrompt, error)
|
||||
func (ast *Assistant) buildCompletionOptions(ctx *context.Context, createResponse *context.HookCreateResponse) (*context.CompletionOptions, string, error) {
|
||||
options := &context.CompletionOptions{}
|
||||
|
||||
// Layer 1 (base): Apply ast - Assistant configuration
|
||||
if err := ast.applyAssistantOptions(options); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// Layer 2 (middle): Apply ctx - Context configuration (overrides ast)
|
||||
ast.applyContextOptions(options, ctx)
|
||||
|
||||
// Layer 3 (highest): Apply createResponse - Hook configuration (overrides all)
|
||||
if createResponse != nil {
|
||||
ast.applyCreateResponseOptions(options, createResponse)
|
||||
}
|
||||
|
||||
// Add MCP tools if configured and get samples prompt
|
||||
mcpSamplesPrompt, err := ast.applyMCPTools(ctx, options, createResponse)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to apply MCP tools: %w", err)
|
||||
}
|
||||
|
||||
return options, mcpSamplesPrompt, nil
|
||||
}
|
||||
|
||||
// applyAssistantOptions applies options from ast.Options to CompletionOptions
|
||||
// ast.Options can contain any OpenAI API parameters (temperature, top_p, stop, etc.)
|
||||
// Returns error if any option validation fails (e.g., invalid JSON Schema)
|
||||
func (ast *Assistant) applyAssistantOptions(options *context.CompletionOptions) error {
|
||||
if ast.Options == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Temperature
|
||||
if v, ok := ast.Options["temperature"].(float64); ok {
|
||||
options.Temperature = &v
|
||||
}
|
||||
|
||||
// MaxTokens
|
||||
if v, ok := ast.Options["max_tokens"].(float64); ok {
|
||||
intVal := int(v)
|
||||
options.MaxTokens = &intVal
|
||||
} else if v, ok := ast.Options["max_tokens"].(int); ok {
|
||||
options.MaxTokens = &v
|
||||
}
|
||||
|
||||
// MaxCompletionTokens
|
||||
if v, ok := ast.Options["max_completion_tokens"].(float64); ok {
|
||||
intVal := int(v)
|
||||
options.MaxCompletionTokens = &intVal
|
||||
} else if v, ok := ast.Options["max_completion_tokens"].(int); ok {
|
||||
options.MaxCompletionTokens = &v
|
||||
}
|
||||
|
||||
// TopP
|
||||
if v, ok := ast.Options["top_p"].(float64); ok {
|
||||
options.TopP = &v
|
||||
}
|
||||
|
||||
// N (number of choices)
|
||||
if v, ok := ast.Options["n"].(float64); ok {
|
||||
intVal := int(v)
|
||||
options.N = &intVal
|
||||
} else if v, ok := ast.Options["n"].(int); ok {
|
||||
options.N = &v
|
||||
}
|
||||
|
||||
// Stop sequences (can be string or []string)
|
||||
if v, ok := ast.Options["stop"]; ok {
|
||||
options.Stop = v
|
||||
}
|
||||
|
||||
// PresencePenalty
|
||||
if v, ok := ast.Options["presence_penalty"].(float64); ok {
|
||||
options.PresencePenalty = &v
|
||||
}
|
||||
|
||||
// FrequencyPenalty
|
||||
if v, ok := ast.Options["frequency_penalty"].(float64); ok {
|
||||
options.FrequencyPenalty = &v
|
||||
}
|
||||
|
||||
// LogitBias
|
||||
if v, ok := ast.Options["logit_bias"].(map[string]interface{}); ok {
|
||||
logitBias := make(map[string]float64)
|
||||
for key, val := range v {
|
||||
if fval, ok := val.(float64); ok {
|
||||
logitBias[key] = fval
|
||||
}
|
||||
}
|
||||
if len(logitBias) > 0 {
|
||||
options.LogitBias = logitBias
|
||||
}
|
||||
}
|
||||
|
||||
// User
|
||||
if v, ok := ast.Options["user"].(string); ok {
|
||||
options.User = v
|
||||
}
|
||||
|
||||
// ResponseFormat
|
||||
// @todo: Assistant should have a default response format
|
||||
if v, ok := ast.Options["response_format"]; ok {
|
||||
// Try to convert to *context.ResponseFormat
|
||||
if rf, ok := v.(*context.ResponseFormat); ok {
|
||||
// Validate JSONSchema if present - reject if invalid
|
||||
if rf.JSONSchema != nil && rf.JSONSchema.Schema != nil {
|
||||
if err := json.ValidateSchema(rf.JSONSchema.Schema); err != nil {
|
||||
return fmt.Errorf("invalid JSON Schema in response_format: %w", err)
|
||||
}
|
||||
}
|
||||
options.ResponseFormat = rf
|
||||
} else if rfMap, ok := v.(map[string]interface{}); ok {
|
||||
// Handle legacy map[string]interface{} format
|
||||
// Try to parse into ResponseFormat struct
|
||||
rf := &context.ResponseFormat{}
|
||||
|
||||
// Parse type
|
||||
if typeStr, ok := rfMap["type"].(string); ok {
|
||||
rf.Type = context.ResponseFormatType(typeStr)
|
||||
}
|
||||
|
||||
// Parse json_schema if present
|
||||
if jsonSchemaMap, ok := rfMap["json_schema"].(map[string]interface{}); ok {
|
||||
jsonSchema := &context.JSONSchema{}
|
||||
|
||||
if name, ok := jsonSchemaMap["name"].(string); ok {
|
||||
jsonSchema.Name = name
|
||||
}
|
||||
if desc, ok := jsonSchemaMap["description"].(string); ok {
|
||||
jsonSchema.Description = desc
|
||||
}
|
||||
if schema, ok := jsonSchemaMap["schema"]; ok {
|
||||
// Validate schema format - reject if invalid
|
||||
if err := json.ValidateSchema(schema); err != nil {
|
||||
return fmt.Errorf("invalid JSON Schema in response_format: %w", err)
|
||||
}
|
||||
jsonSchema.Schema = schema
|
||||
}
|
||||
if strict, ok := jsonSchemaMap["strict"].(bool); ok {
|
||||
jsonSchema.Strict = &strict
|
||||
}
|
||||
|
||||
rf.JSONSchema = jsonSchema
|
||||
}
|
||||
|
||||
options.ResponseFormat = rf
|
||||
}
|
||||
}
|
||||
|
||||
// Seed
|
||||
if v, ok := ast.Options["seed"].(float64); ok {
|
||||
intVal := int(v)
|
||||
options.Seed = &intVal
|
||||
} else if v, ok := ast.Options["seed"].(int); ok {
|
||||
options.Seed = &v
|
||||
}
|
||||
|
||||
// Tools
|
||||
if v, ok := ast.Options["tools"].([]interface{}); ok {
|
||||
tools := make([]map[string]interface{}, 0, len(v))
|
||||
for _, tool := range v {
|
||||
if toolMap, ok := tool.(map[string]interface{}); ok {
|
||||
tools = append(tools, toolMap)
|
||||
}
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
options.Tools = tools
|
||||
}
|
||||
}
|
||||
|
||||
// ToolChoice
|
||||
if v, ok := ast.Options["tool_choice"]; ok {
|
||||
options.ToolChoice = v
|
||||
}
|
||||
|
||||
// Stream
|
||||
if v, ok := ast.Options["stream"].(bool); ok {
|
||||
options.Stream = &v
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyContextOptions applies options from ctx to CompletionOptions
|
||||
// ctx provides Route and Metadata for CUI context
|
||||
func (ast *Assistant) applyContextOptions(options *context.CompletionOptions, ctx *context.Context) {
|
||||
// Set Route and Metadata from ctx
|
||||
options.Route = ctx.Route
|
||||
options.Metadata = ctx.Metadata
|
||||
|
||||
// Set Uses configurations (assistant.Uses has priority over global settings)
|
||||
// These can be overridden by createResponse
|
||||
options.Uses = ast.getUses()
|
||||
}
|
||||
|
||||
// applyCreateResponseOptions applies options from createResponse to CompletionOptions
|
||||
// createResponse takes highest priority and overrides any previous settings
|
||||
func (ast *Assistant) applyCreateResponseOptions(options *context.CompletionOptions, createResponse *context.HookCreateResponse) {
|
||||
// Audio configuration
|
||||
if createResponse.Audio != nil {
|
||||
options.Audio = createResponse.Audio
|
||||
}
|
||||
|
||||
// Temperature
|
||||
if createResponse.Temperature != nil {
|
||||
options.Temperature = createResponse.Temperature
|
||||
}
|
||||
|
||||
// MaxTokens
|
||||
if createResponse.MaxTokens != nil {
|
||||
options.MaxTokens = createResponse.MaxTokens
|
||||
}
|
||||
|
||||
// MaxCompletionTokens
|
||||
if createResponse.MaxCompletionTokens != nil {
|
||||
options.MaxCompletionTokens = createResponse.MaxCompletionTokens
|
||||
}
|
||||
|
||||
// Route
|
||||
if createResponse.Route != "" {
|
||||
options.Route = createResponse.Route
|
||||
}
|
||||
|
||||
// Metadata (merge with existing)
|
||||
if createResponse.Metadata != nil {
|
||||
if options.Metadata == nil {
|
||||
options.Metadata = createResponse.Metadata
|
||||
} else {
|
||||
// Merge: createResponse.Metadata overrides existing
|
||||
for key, value := range createResponse.Metadata {
|
||||
options.Metadata[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Uses configuration (merge with existing)
|
||||
// createResponse.Uses has highest priority and overrides existing Uses
|
||||
if createResponse.Uses != nil {
|
||||
if options.Uses == nil {
|
||||
options.Uses = createResponse.Uses
|
||||
} else {
|
||||
// Merge: createResponse.Uses overrides existing (only non-empty fields)
|
||||
if createResponse.Uses.Vision != "" {
|
||||
options.Uses.Vision = createResponse.Uses.Vision
|
||||
}
|
||||
if createResponse.Uses.Audio != "" {
|
||||
options.Uses.Audio = createResponse.Uses.Audio
|
||||
}
|
||||
if createResponse.Uses.Search != "" {
|
||||
options.Uses.Search = createResponse.Uses.Search
|
||||
}
|
||||
if createResponse.Uses.Fetch != "" {
|
||||
options.Uses.Fetch = createResponse.Uses.Fetch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ForceUses configuration
|
||||
// If hook specifies ForceUses, it takes priority
|
||||
if createResponse.ForceUses != nil {
|
||||
options.ForceUses = *createResponse.ForceUses
|
||||
}
|
||||
}
|
||||
|
||||
// getUses get the Uses configuration with priority: assistant.Uses > global settings
|
||||
// Note: createResponse.Uses (applied in applyCreateResponseOptions) has even higher priority
|
||||
// getUses returns the Uses config for this assistant
|
||||
// Note: The config is already merged with global config during loading (loadMap)
|
||||
func (ast *Assistant) getUses() *context.Uses {
|
||||
return ast.Uses
|
||||
}
|
||||
|
||||
// applyMCPTools adds MCP tools to completion options and returns samples prompt
|
||||
// Returns (samplesPrompt, error)
|
||||
func (ast *Assistant) applyMCPTools(ctx *context.Context, options *context.CompletionOptions, createResponse *context.HookCreateResponse) (string, error) {
|
||||
|
||||
// Priority 1: Check if hook provides MCP servers
|
||||
if createResponse != nil && len(createResponse.MCPServers) > 0 {
|
||||
return ast.buildAndApplyMCPTools(ctx, options, createResponse)
|
||||
}
|
||||
|
||||
// Priority 2: Check if assistant has MCP config
|
||||
if ast.MCP != nil && len(ast.MCP.Servers) > 0 {
|
||||
return ast.buildAndApplyMCPTools(ctx, options, nil)
|
||||
}
|
||||
|
||||
// No MCP config
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// buildAndApplyMCPTools builds MCP tools and applies them to options
|
||||
func (ast *Assistant) buildAndApplyMCPTools(ctx *context.Context, options *context.CompletionOptions, createResponse *context.HookCreateResponse) (string, error) {
|
||||
// Build MCP tools and get samples prompt
|
||||
mcpTools, samplesPrompt, err := ast.buildMCPTools(ctx, createResponse)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to build MCP tools: %w", err)
|
||||
}
|
||||
|
||||
// Convert mcpTools to map format for CompletionOptions.Tools
|
||||
if len(mcpTools) > 0 {
|
||||
toolMaps := make([]map[string]interface{}, len(mcpTools))
|
||||
for i, tool := range mcpTools {
|
||||
toolMaps[i] = map[string]interface{}{
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": tool.Name,
|
||||
"description": tool.Description,
|
||||
"parameters": tool.Parameters,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Add MCP tools to existing tools (append to preserve existing tools)
|
||||
if options.Tools == nil {
|
||||
options.Tools = toolMaps
|
||||
} else {
|
||||
options.Tools = append(options.Tools, toolMaps...)
|
||||
}
|
||||
}
|
||||
|
||||
return samplesPrompt, nil
|
||||
}
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
package assistant
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/yaoapp/yao/agent/content"
|
||||
"github.com/yaoapp/yao/agent/content/text"
|
||||
contentTypes "github.com/yaoapp/yao/agent/content/types"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
)
|
||||
|
||||
// BuildContent processes messages through Vision function to convert extended content types
|
||||
// (file, data) to standard LLM-compatible types (text, image_url, input_audio)
|
||||
//
|
||||
// This should be called after BuildRequest and before executing LLM call
|
||||
func (ast *Assistant) BuildContent(ctx *context.Context, messages []context.Message, options *context.CompletionOptions, opts *context.Options) ([]context.Message, error) {
|
||||
// Skip complex content parsing if requested (for internal calls like needsearch)
|
||||
// Still convert file attachments to raw text
|
||||
if opts != nil && opts.Skip != nil && opts.Skip.ContentParsing {
|
||||
return convertFilesToText(ctx, messages), nil
|
||||
}
|
||||
|
||||
// Set AssistantID in context for file info tracking in Space
|
||||
// This ensures hooks can access file information using the correct namespace
|
||||
if ctx.AssistantID == "" {
|
||||
ctx.AssistantID = ast.ID
|
||||
}
|
||||
|
||||
// Get connector and capabilities
|
||||
connector, capabilities, err := ast.GetConnector(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get connector: %w", err)
|
||||
}
|
||||
|
||||
// Build parse options
|
||||
parseOptions := &contentTypes.Options{
|
||||
Capabilities: capabilities,
|
||||
CompletionOptions: options,
|
||||
Connector: connector,
|
||||
StreamOptions: options.StreamOptions,
|
||||
}
|
||||
|
||||
contentMessages, referenceContext, err := content.ParseUserInput(ctx, messages, parseOptions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse content: %w", err)
|
||||
}
|
||||
|
||||
// Inject reference context into messages
|
||||
if referenceContext != nil {
|
||||
contentMessages = ast.injectSearchContext(contentMessages, referenceContext)
|
||||
}
|
||||
|
||||
return contentMessages, nil
|
||||
}
|
||||
|
||||
// convertFilesToText converts file attachments in messages to raw text
|
||||
// Used when SkipContentParsing is enabled - simple text extraction without vision/PDF processing
|
||||
func convertFilesToText(ctx *context.Context, messages []context.Message) []context.Message {
|
||||
result := make([]context.Message, 0, len(messages))
|
||||
textHandler := text.New(nil)
|
||||
|
||||
for _, msg := range messages {
|
||||
// Only process user messages
|
||||
if msg.Role != context.RoleUser {
|
||||
result = append(result, msg)
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle content parts
|
||||
parts, ok := msg.Content.([]context.ContentPart)
|
||||
if !ok {
|
||||
// Try []interface{} (from history/JSON)
|
||||
if iparts, ok := msg.Content.([]interface{}); ok {
|
||||
parts = convertInterfaceToParts(iparts)
|
||||
}
|
||||
}
|
||||
|
||||
if len(parts) == 0 {
|
||||
result = append(result, msg)
|
||||
continue
|
||||
}
|
||||
|
||||
// Convert file parts to text
|
||||
newParts := make([]context.ContentPart, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
switch part.Type {
|
||||
case context.ContentFile:
|
||||
// Convert file to raw text
|
||||
if part.File != nil && part.File.URL != "" {
|
||||
textPart, _, err := textHandler.ParseRaw(ctx, part)
|
||||
if err == nil {
|
||||
newParts = append(newParts, textPart)
|
||||
continue
|
||||
}
|
||||
}
|
||||
newParts = append(newParts, part)
|
||||
|
||||
case context.ContentImageURL:
|
||||
// Skip images - cannot convert to text without vision
|
||||
continue
|
||||
|
||||
default:
|
||||
newParts = append(newParts, part)
|
||||
}
|
||||
}
|
||||
|
||||
newMsg := msg
|
||||
newMsg.Content = newParts
|
||||
result = append(result, newMsg)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// convertInterfaceToParts converts []interface{} to []ContentPart for file extraction
|
||||
func convertInterfaceToParts(items []interface{}) []context.ContentPart {
|
||||
parts := make([]context.ContentPart, 0, len(items))
|
||||
for _, item := range items {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
typeStr, _ := m["type"].(string)
|
||||
part := context.ContentPart{
|
||||
Type: context.ContentPartType(typeStr),
|
||||
}
|
||||
|
||||
switch typeStr {
|
||||
case "text":
|
||||
if t, ok := m["text"].(string); ok {
|
||||
part.Text = t
|
||||
}
|
||||
case "file":
|
||||
if fileData, ok := m["file"].(map[string]interface{}); ok {
|
||||
part.File = &context.FileAttachment{}
|
||||
if url, ok := fileData["url"].(string); ok {
|
||||
part.File.URL = url
|
||||
}
|
||||
if filename, ok := fileData["filename"].(string); ok {
|
||||
part.File.Filename = filename
|
||||
}
|
||||
}
|
||||
case "image_url":
|
||||
part.Type = context.ContentImageURL
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
parts = append(parts, part)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
|
@ -1,304 +0,0 @@
|
|||
package assistant_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
)
|
||||
|
||||
// TestBuildRequest_MCP tests MCP tool integration in BuildRequest
|
||||
func TestBuildRequest_MCP(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.mcptest")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get tests.mcptest assistant: %s", err.Error())
|
||||
}
|
||||
|
||||
ctx := newTestContext("chat-test-mcp", "tests.mcptest")
|
||||
|
||||
t.Run("MCPToolsLoaded", func(t *testing.T) {
|
||||
inputMessages := []context.Message{{Role: context.RoleUser, Content: "test mcp tools"}}
|
||||
|
||||
// Build LLM request
|
||||
_, options, err := agent.BuildRequest(ctx, inputMessages, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to build LLM request: %s", err.Error())
|
||||
}
|
||||
|
||||
// Verify that tools are loaded
|
||||
if options.Tools == nil {
|
||||
t.Fatal("Expected tools to be loaded, got nil")
|
||||
}
|
||||
|
||||
if len(options.Tools) == 0 {
|
||||
t.Fatal("Expected at least some MCP tools, got empty list")
|
||||
}
|
||||
|
||||
// Count MCP tools (should be filtered to only ping and echo)
|
||||
mcpToolCount := 0
|
||||
var toolNames []string
|
||||
for _, toolMap := range options.Tools {
|
||||
fn, ok := toolMap["function"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name, ok := fn["name"].(string)
|
||||
if ok {
|
||||
toolNames = append(toolNames, name)
|
||||
mcpToolCount++
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Found %d MCP tools: %v", mcpToolCount, toolNames)
|
||||
|
||||
// Verify tool count (should be exactly 2: ping and echo)
|
||||
if mcpToolCount != 2 {
|
||||
t.Errorf("Expected 2 MCP tools (ping, echo), got %d: %v", mcpToolCount, toolNames)
|
||||
}
|
||||
|
||||
// Verify specific tools exist
|
||||
hasEchoPing := false
|
||||
hasEchoEcho := false
|
||||
for _, name := range toolNames {
|
||||
if name == "echo__ping" {
|
||||
hasEchoPing = true
|
||||
}
|
||||
if name == "echo__echo" {
|
||||
hasEchoEcho = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasEchoPing {
|
||||
t.Error("Expected 'echo__ping' tool to be present")
|
||||
}
|
||||
if !hasEchoEcho {
|
||||
t.Error("Expected 'echo__echo' tool to be present")
|
||||
}
|
||||
|
||||
// Verify that 'status' tool is NOT included (filtered out)
|
||||
for _, name := range toolNames {
|
||||
if name == "echo__status" {
|
||||
t.Error("Tool 'echo__status' should be filtered out but was found")
|
||||
}
|
||||
}
|
||||
|
||||
t.Log("✓ MCP tools loaded and filtered correctly")
|
||||
})
|
||||
|
||||
t.Run("MCPSamplesPrompt", func(t *testing.T) {
|
||||
inputMessages := []context.Message{{Role: context.RoleUser, Content: "test mcp samples"}}
|
||||
|
||||
// Build LLM request
|
||||
finalMessages, _, err := agent.BuildRequest(ctx, inputMessages, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to build LLM request: %s", err.Error())
|
||||
}
|
||||
|
||||
// Check if messages contain MCP samples prompt
|
||||
// The samples prompt should be added as a system message
|
||||
hasMCPSamples := false
|
||||
for _, msg := range finalMessages {
|
||||
if msg.Role == context.RoleSystem {
|
||||
if content, ok := msg.Content.(string); ok {
|
||||
if len(content) > 50 &&
|
||||
(contains(content, "MCP Tool Usage Examples") ||
|
||||
contains(content, "echo.ping") ||
|
||||
contains(content, "echo.echo")) {
|
||||
hasMCPSamples = true
|
||||
t.Logf("Found MCP samples prompt (length: %d chars)", len(content))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Note: samples may not exist for echo tools, so this is informational
|
||||
if hasMCPSamples {
|
||||
t.Log("✓ MCP samples prompt included in messages")
|
||||
} else {
|
||||
t.Log("ℹ No MCP samples prompt found (may not have sample files)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MCPToolNameFormat", func(t *testing.T) {
|
||||
inputMessages := []context.Message{{Role: context.RoleUser, Content: "test tool format"}}
|
||||
|
||||
// Build LLM request
|
||||
_, options, err := agent.BuildRequest(ctx, inputMessages, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to build LLM request: %s", err.Error())
|
||||
}
|
||||
|
||||
// Verify tool name format: server_id.tool_name
|
||||
for _, toolMap := range options.Tools {
|
||||
fn, ok := toolMap["function"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name, ok := fn["name"].(string)
|
||||
if ok {
|
||||
// Parse tool name
|
||||
serverID, toolName, ok := assistant.ParseMCPToolName(name)
|
||||
if !ok {
|
||||
t.Errorf("Tool name '%s' is not in correct format (server_id.tool_name)", name)
|
||||
continue
|
||||
}
|
||||
|
||||
// Verify server ID
|
||||
if serverID != "echo" {
|
||||
t.Errorf("Expected server_id 'echo', got '%s' for tool '%s'", serverID, name)
|
||||
}
|
||||
|
||||
// Verify tool name is either ping or echo
|
||||
if toolName != "ping" && toolName != "echo" {
|
||||
t.Errorf("Expected tool name 'ping' or 'echo', got '%s'", toolName)
|
||||
}
|
||||
|
||||
t.Logf("✓ Tool name format correct: %s → (%s, %s)", name, serverID, toolName)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MCPToolSchema", func(t *testing.T) {
|
||||
inputMessages := []context.Message{{Role: context.RoleUser, Content: "test tool schema"}}
|
||||
|
||||
// Build LLM request
|
||||
_, options, err := agent.BuildRequest(ctx, inputMessages, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to build LLM request: %s", err.Error())
|
||||
}
|
||||
|
||||
// Verify tool schema structure
|
||||
for _, toolMap := range options.Tools {
|
||||
// Verify type field
|
||||
if toolType, ok := toolMap["type"].(string); !ok || toolType != "function" {
|
||||
t.Errorf("Expected tool type 'function', got: %v", toolMap["type"])
|
||||
}
|
||||
|
||||
// Verify function field exists
|
||||
fn, ok := toolMap["function"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Error("Tool missing 'function' field or wrong type")
|
||||
continue
|
||||
}
|
||||
|
||||
// Verify required fields
|
||||
if _, hasName := fn["name"]; !hasName {
|
||||
t.Error("Tool function missing 'name' field")
|
||||
}
|
||||
if _, hasDesc := fn["description"]; !hasDesc {
|
||||
t.Error("Tool function missing 'description' field")
|
||||
}
|
||||
if _, hasParams := fn["parameters"]; !hasParams {
|
||||
t.Error("Tool function missing 'parameters' field")
|
||||
}
|
||||
|
||||
t.Logf("✓ Tool schema valid: %v", fn["name"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MCPHookOverride", func(t *testing.T) {
|
||||
// Test that hook can override MCP servers
|
||||
// Use tests.mcptest-hook which has a create hook that returns only ["ping"]
|
||||
hookAgent, err := assistant.Get("tests.mcptest-hook")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get tests.mcptest-hook assistant: %s", err.Error())
|
||||
}
|
||||
|
||||
hookCtx := newTestContext("chat-test-mcp-hook", "tests.mcptest-hook")
|
||||
inputMessages := []context.Message{{Role: context.RoleUser, Content: "test hook override"}}
|
||||
|
||||
// Call create hook to get createResponse
|
||||
var createResponse *context.HookCreateResponse
|
||||
if hookAgent.HookScript != nil {
|
||||
createResponse, _, err = hookAgent.HookScript.Create(hookCtx, inputMessages, &context.Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to call create hook: %s", err.Error())
|
||||
}
|
||||
|
||||
t.Logf("Create hook response: %+v", createResponse)
|
||||
if createResponse != nil && len(createResponse.MCPServers) > 0 {
|
||||
t.Logf("Hook MCP servers: %+v", createResponse.MCPServers)
|
||||
}
|
||||
} else {
|
||||
t.Fatal("Expected hookAgent to have Script/hook configured")
|
||||
}
|
||||
|
||||
// Build LLM request with create hook response
|
||||
_, options, err := hookAgent.BuildRequest(hookCtx, inputMessages, createResponse)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to build LLM request: %s", err.Error())
|
||||
}
|
||||
|
||||
// Verify that tools are loaded
|
||||
if options.Tools == nil {
|
||||
t.Fatal("Expected tools to be loaded, got nil")
|
||||
}
|
||||
|
||||
// Count MCP tools
|
||||
mcpToolCount := 0
|
||||
var toolNames []string
|
||||
for _, toolMap := range options.Tools {
|
||||
fn, ok := toolMap["function"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
name, ok := fn["name"].(string)
|
||||
if ok {
|
||||
toolNames = append(toolNames, name)
|
||||
mcpToolCount++
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Found %d MCP tools after hook override: %v", mcpToolCount, toolNames)
|
||||
|
||||
// Verify tool count (hook should override to only 1: ping)
|
||||
if mcpToolCount != 1 {
|
||||
t.Errorf("Expected 1 MCP tool (ping only), got %d: %v", mcpToolCount, toolNames)
|
||||
}
|
||||
|
||||
// Verify only ping tool exists
|
||||
hasEchoPing := false
|
||||
hasEchoEcho := false
|
||||
for _, name := range toolNames {
|
||||
if name == "echo__ping" {
|
||||
hasEchoPing = true
|
||||
}
|
||||
if name == "echo__echo" {
|
||||
hasEchoEcho = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasEchoPing {
|
||||
t.Error("Expected 'echo__ping' tool to be present")
|
||||
}
|
||||
if hasEchoEcho {
|
||||
t.Error("Tool 'echo__echo' should be filtered out by hook override but was found")
|
||||
}
|
||||
|
||||
t.Log("✓ Hook successfully overrode MCP servers configuration")
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to check if string contains substring
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) &&
|
||||
(s == substr ||
|
||||
len(s) > len(substr) &&
|
||||
(s[:len(substr)] == substr ||
|
||||
s[len(s)-len(substr):] == substr ||
|
||||
findSubstring(s, substr)))
|
||||
}
|
||||
|
||||
func findSubstring(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -1,835 +0,0 @@
|
|||
package assistant_test
|
||||
|
||||
import (
|
||||
stdContext "context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
store "github.com/yaoapp/yao/agent/store/types"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
// containsString is a helper to check if a content (string or interface{}) contains a substring
|
||||
func containsString(content interface{}, substr string) bool {
|
||||
switch v := content.(type) {
|
||||
case string:
|
||||
return strings.Contains(v, substr)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// newPromptTestContext creates a context suitable for prompt testing with Create Hook
|
||||
func newPromptTestContext(chatID, assistantID string) *context.Context {
|
||||
authorized := &types.AuthorizedInfo{
|
||||
Subject: "test-user",
|
||||
ClientID: "test-client-id",
|
||||
UserID: "test-user-123",
|
||||
TeamID: "test-team-456",
|
||||
TenantID: "test-tenant-789",
|
||||
SessionID: "test-session-id",
|
||||
}
|
||||
|
||||
ctx := context.New(stdContext.Background(), authorized, chatID)
|
||||
ctx.AssistantID = assistantID
|
||||
ctx.Locale = "en-us"
|
||||
ctx.Theme = "light"
|
||||
ctx.Client = context.Client{
|
||||
Type: "web",
|
||||
UserAgent: "TestAgent/1.0",
|
||||
IP: "127.0.0.1",
|
||||
}
|
||||
ctx.Referer = context.RefererAPI
|
||||
ctx.Accept = context.AcceptWebCUI
|
||||
ctx.Metadata = make(map[string]interface{})
|
||||
return ctx
|
||||
}
|
||||
|
||||
// newMinimalTestContext creates a minimal context for testing
|
||||
// Use this when you only need specific fields set
|
||||
func newMinimalTestContext() *context.Context {
|
||||
return context.New(stdContext.Background(), nil, "test-chat")
|
||||
}
|
||||
|
||||
func TestBuildSystemPromptsIntegration(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
t.Run("AssistantWithLocale", func(t *testing.T) {
|
||||
// Load an assistant with locales
|
||||
ast, err := assistant.Get("tests.fullfields")
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := newMinimalTestContext()
|
||||
ctx.Locale = "zh-cn"
|
||||
ctx.Authorized = &types.AuthorizedInfo{
|
||||
UserID: "test-user-123",
|
||||
TeamID: "test-team-456",
|
||||
}
|
||||
ctx.Metadata = map[string]interface{}{
|
||||
"CUSTOM_VAR": "custom-value",
|
||||
"INT_VAR": 42,
|
||||
"BOOL_VAR": true,
|
||||
}
|
||||
|
||||
// Build request to test the full flow
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "Hello"},
|
||||
}
|
||||
|
||||
finalMessages, options, err := ast.BuildRequest(ctx, messages, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, options)
|
||||
|
||||
// Should have system prompts prepended
|
||||
assert.Greater(t, len(finalMessages), 1)
|
||||
|
||||
// First messages should be system prompts
|
||||
hasSystemPrompt := false
|
||||
for _, msg := range finalMessages {
|
||||
if msg.Role == context.RoleSystem {
|
||||
hasSystemPrompt = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, hasSystemPrompt, "Should have system prompts")
|
||||
})
|
||||
|
||||
t.Run("DisableGlobalPrompts", func(t *testing.T) {
|
||||
// Load fullfields assistant which has disable_global_prompts: true
|
||||
ast, err := assistant.Get("tests.fullfields")
|
||||
require.NoError(t, err)
|
||||
require.True(t, ast.DisableGlobalPrompts)
|
||||
|
||||
ctx := newMinimalTestContext()
|
||||
ctx.Locale = "en-us"
|
||||
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "Hello"},
|
||||
}
|
||||
|
||||
finalMessages, _, err := ast.BuildRequest(ctx, messages, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should still have assistant prompts
|
||||
hasSystemPrompt := false
|
||||
for _, msg := range finalMessages {
|
||||
if msg.Role == context.RoleSystem {
|
||||
hasSystemPrompt = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, hasSystemPrompt, "Should have assistant prompts even with global disabled")
|
||||
})
|
||||
|
||||
t.Run("MetadataTypeConversion", func(t *testing.T) {
|
||||
ast, err := assistant.Get("yaobots")
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := newMinimalTestContext()
|
||||
ctx.Metadata = map[string]interface{}{
|
||||
"STRING_VAL": "hello",
|
||||
"INT_VAL": 123,
|
||||
"INT64_VAL": int64(456),
|
||||
"FLOAT_VAL": 3.14,
|
||||
"BOOL_TRUE": true,
|
||||
"BOOL_FALSE": false,
|
||||
"UINT_VAL": uint(789),
|
||||
"NIL_VAL": nil,
|
||||
"EMPTY_VAL": "",
|
||||
"ZERO_INT": 0,
|
||||
"ZERO_FLOAT": 0.0,
|
||||
}
|
||||
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "Test metadata"},
|
||||
}
|
||||
|
||||
// This should not panic
|
||||
_, _, err = ast.BuildRequest(ctx, messages, nil)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("AuthorizedInfoPrivacy", func(t *testing.T) {
|
||||
ast, err := assistant.Get("yaobots")
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := newMinimalTestContext()
|
||||
ctx.Authorized = &types.AuthorizedInfo{
|
||||
UserID: "user-123",
|
||||
Subject: "user@example.com", // PII - should not be exposed
|
||||
TeamID: "team-456",
|
||||
TenantID: "tenant-789",
|
||||
}
|
||||
ctx.Client = context.Client{
|
||||
Type: "web",
|
||||
IP: "192.168.1.1", // Should not be exposed
|
||||
}
|
||||
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "Test privacy"},
|
||||
}
|
||||
|
||||
finalMessages, _, err := ast.BuildRequest(ctx, messages, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check that sensitive info is not in any system prompts
|
||||
for _, msg := range finalMessages {
|
||||
if msg.Role == context.RoleSystem {
|
||||
assert.NotContains(t, msg.Content, "user@example.com", "Subject should not be in prompts")
|
||||
assert.NotContains(t, msg.Content, "192.168.1.1", "IP should not be in prompts")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ContextVariablesInPrompts", func(t *testing.T) {
|
||||
// Set up global prompts with variables
|
||||
assistant.SetGlobalPrompts([]store.Prompt{
|
||||
{Role: "system", Content: "User ID: $CTX.USER_ID, Team: $CTX.TEAM_ID, Custom: $CTX.MY_VAR"},
|
||||
})
|
||||
defer assistant.SetGlobalPrompts(nil)
|
||||
|
||||
ast, err := assistant.Get("yaobots")
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := newMinimalTestContext()
|
||||
ctx.Authorized = &types.AuthorizedInfo{
|
||||
UserID: "user-abc",
|
||||
TeamID: "team-xyz",
|
||||
}
|
||||
ctx.Metadata = map[string]interface{}{
|
||||
"MY_VAR": "my-value",
|
||||
}
|
||||
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "Test variables"},
|
||||
}
|
||||
|
||||
finalMessages, _, err := ast.BuildRequest(ctx, messages, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Find the global prompt and verify variables are replaced
|
||||
found := false
|
||||
for _, msg := range finalMessages {
|
||||
if msg.Role == context.RoleSystem && !found {
|
||||
if assert.Contains(t, msg.Content, "User ID: user-abc") {
|
||||
found = true
|
||||
assert.Contains(t, msg.Content, "Team: team-xyz")
|
||||
assert.Contains(t, msg.Content, "Custom: my-value")
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Should find global prompt with replaced variables")
|
||||
})
|
||||
|
||||
t.Run("SystemVariablesReplacement", func(t *testing.T) {
|
||||
// Set up global prompts with $SYS.* variables
|
||||
assistant.SetGlobalPrompts([]store.Prompt{
|
||||
{Role: "system", Content: "Time: $SYS.TIME, Date: $SYS.DATE, Datetime: $SYS.DATETIME, Weekday: $SYS.WEEKDAY"},
|
||||
})
|
||||
defer assistant.SetGlobalPrompts(nil)
|
||||
|
||||
ast, err := assistant.Get("yaobots")
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := newMinimalTestContext()
|
||||
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "Test system variables"},
|
||||
}
|
||||
|
||||
finalMessages, _, err := ast.BuildRequest(ctx, messages, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Find the global prompt and verify $SYS.* variables are replaced
|
||||
found := false
|
||||
for _, msg := range finalMessages {
|
||||
if msg.Role == context.RoleSystem {
|
||||
// Should NOT contain $SYS. prefix (variables should be replaced)
|
||||
if !assert.NotContains(t, msg.Content, "$SYS.TIME") {
|
||||
continue
|
||||
}
|
||||
if !assert.NotContains(t, msg.Content, "$SYS.DATE") {
|
||||
continue
|
||||
}
|
||||
if !assert.NotContains(t, msg.Content, "$SYS.DATETIME") {
|
||||
continue
|
||||
}
|
||||
if !assert.NotContains(t, msg.Content, "$SYS.WEEKDAY") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Should contain "Time:", "Date:", etc. with actual values
|
||||
assert.Contains(t, msg.Content, "Time:")
|
||||
assert.Contains(t, msg.Content, "Date:")
|
||||
assert.Contains(t, msg.Content, "Datetime:")
|
||||
assert.Contains(t, msg.Content, "Weekday:")
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Should find global prompt with replaced $SYS.* variables")
|
||||
})
|
||||
|
||||
t.Run("EnvVariablesReplacement", func(t *testing.T) {
|
||||
// Set test environment variable
|
||||
t.Setenv("TEST_PROMPT_VAR", "env-test-value")
|
||||
|
||||
// Set up global prompts with $ENV.* variables
|
||||
assistant.SetGlobalPrompts([]store.Prompt{
|
||||
{Role: "system", Content: "Env Value: $ENV.TEST_PROMPT_VAR, Not Exist: $ENV.NOT_EXIST_VAR_XYZ"},
|
||||
})
|
||||
defer assistant.SetGlobalPrompts(nil)
|
||||
|
||||
ast, err := assistant.Get("yaobots")
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := newMinimalTestContext()
|
||||
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "Test env variables"},
|
||||
}
|
||||
|
||||
finalMessages, _, err := ast.BuildRequest(ctx, messages, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Find the global prompt and verify $ENV.* variables are replaced
|
||||
found := false
|
||||
for _, msg := range finalMessages {
|
||||
if msg.Role == context.RoleSystem {
|
||||
// Should NOT contain $ENV. prefix for existing vars
|
||||
if !assert.NotContains(t, msg.Content, "$ENV.TEST_PROMPT_VAR") {
|
||||
continue
|
||||
}
|
||||
// Should contain the actual env value
|
||||
assert.Contains(t, msg.Content, "Env Value: env-test-value")
|
||||
// Non-existent env var should be replaced with empty string
|
||||
assert.Contains(t, msg.Content, "Not Exist: ")
|
||||
assert.NotContains(t, msg.Content, "$ENV.NOT_EXIST_VAR_XYZ")
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Should find global prompt with replaced $ENV.* variables")
|
||||
})
|
||||
|
||||
t.Run("AllVariableTypesReplacement", func(t *testing.T) {
|
||||
// Set test environment variable
|
||||
t.Setenv("TEST_APP_NAME", "MyTestApp")
|
||||
|
||||
// Set up global prompts with all variable types
|
||||
assistant.SetGlobalPrompts([]store.Prompt{
|
||||
{Role: "system", Content: `System Info:
|
||||
- Time: $SYS.TIME
|
||||
- Date: $SYS.DATE
|
||||
- App: $ENV.TEST_APP_NAME
|
||||
- User: $CTX.USER_ID
|
||||
- Custom: $CTX.CUSTOM_KEY
|
||||
- Assistant: $CTX.ASSISTANT_NAME`},
|
||||
})
|
||||
defer assistant.SetGlobalPrompts(nil)
|
||||
|
||||
ast, err := assistant.Get("yaobots")
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := newMinimalTestContext()
|
||||
ctx.Authorized = &types.AuthorizedInfo{
|
||||
UserID: "all-vars-user",
|
||||
}
|
||||
ctx.Metadata = map[string]interface{}{
|
||||
"CUSTOM_KEY": "custom-value-123",
|
||||
}
|
||||
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "Test all variables"},
|
||||
}
|
||||
|
||||
finalMessages, _, err := ast.BuildRequest(ctx, messages, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Find the global prompt and verify ALL variable types are replaced
|
||||
found := false
|
||||
for _, msg := range finalMessages {
|
||||
if msg.Role == context.RoleSystem && !found {
|
||||
content := msg.Content
|
||||
|
||||
// Check $SYS.* replaced
|
||||
if assert.NotContains(t, content, "$SYS.TIME") &&
|
||||
assert.NotContains(t, content, "$SYS.DATE") {
|
||||
|
||||
// Check $ENV.* replaced
|
||||
assert.NotContains(t, content, "$ENV.TEST_APP_NAME")
|
||||
assert.Contains(t, content, "App: MyTestApp")
|
||||
|
||||
// Check $CTX.* replaced
|
||||
assert.NotContains(t, content, "$CTX.USER_ID")
|
||||
assert.Contains(t, content, "User: all-vars-user")
|
||||
|
||||
assert.NotContains(t, content, "$CTX.CUSTOM_KEY")
|
||||
assert.Contains(t, content, "Custom: custom-value-123")
|
||||
|
||||
// Check assistant name from $CTX.ASSISTANT_NAME
|
||||
assert.NotContains(t, content, "$CTX.ASSISTANT_NAME")
|
||||
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Should find global prompt with all variable types replaced")
|
||||
})
|
||||
|
||||
t.Run("PromptPresetFromHook", func(t *testing.T) {
|
||||
// Load fullfields assistant which has prompt_presets
|
||||
ast, err := assistant.Get("tests.fullfields")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast.PromptPresets)
|
||||
require.Contains(t, ast.PromptPresets, "chat.friendly")
|
||||
|
||||
ctx := newMinimalTestContext()
|
||||
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "Test preset from hook"},
|
||||
}
|
||||
|
||||
// Hook returns prompt_preset
|
||||
createResponse := &context.HookCreateResponse{
|
||||
PromptPreset: "chat.friendly",
|
||||
}
|
||||
|
||||
finalMessages, _, err := ast.BuildRequest(ctx, messages, createResponse)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should have system prompts from the preset
|
||||
hasSystemPrompt := false
|
||||
for _, msg := range finalMessages {
|
||||
if msg.Role == context.RoleSystem {
|
||||
hasSystemPrompt = true
|
||||
// Verify it's from the friendly preset (check content)
|
||||
assert.Contains(t, msg.Content, "friendly", "Should use friendly preset prompts")
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, hasSystemPrompt, "Should have system prompts from preset")
|
||||
})
|
||||
|
||||
t.Run("PromptPresetFromMetadata", func(t *testing.T) {
|
||||
// Load fullfields assistant which has prompt_presets
|
||||
ast, err := assistant.Get("tests.fullfields")
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := newMinimalTestContext()
|
||||
ctx.Metadata = map[string]interface{}{
|
||||
"__prompt_preset": "chat.professional",
|
||||
}
|
||||
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "Test preset from metadata"},
|
||||
}
|
||||
|
||||
finalMessages, _, err := ast.BuildRequest(ctx, messages, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should have system prompts from the preset
|
||||
hasSystemPrompt := false
|
||||
for _, msg := range finalMessages {
|
||||
if msg.Role == context.RoleSystem {
|
||||
hasSystemPrompt = true
|
||||
// Verify it's from the professional preset
|
||||
assert.Contains(t, msg.Content, "professional", "Should use professional preset prompts")
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, hasSystemPrompt, "Should have system prompts from preset")
|
||||
})
|
||||
|
||||
t.Run("PromptPresetHookOverridesMetadata", func(t *testing.T) {
|
||||
// Load fullfields assistant
|
||||
ast, err := assistant.Get("tests.fullfields")
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := newMinimalTestContext()
|
||||
ctx.Metadata = map[string]interface{}{
|
||||
"__prompt_preset": "chat.professional", // Lower priority
|
||||
}
|
||||
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "Test hook overrides metadata"},
|
||||
}
|
||||
|
||||
// Hook returns different preset (higher priority)
|
||||
createResponse := &context.HookCreateResponse{
|
||||
PromptPreset: "chat.friendly",
|
||||
}
|
||||
|
||||
finalMessages, _, err := ast.BuildRequest(ctx, messages, createResponse)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should use hook's preset, not metadata's
|
||||
for _, msg := range finalMessages {
|
||||
if msg.Role == context.RoleSystem {
|
||||
assert.Contains(t, msg.Content, "friendly", "Hook preset should override metadata preset")
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PromptPresetNotFound", func(t *testing.T) {
|
||||
// Load fullfields assistant
|
||||
ast, err := assistant.Get("tests.fullfields")
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := newMinimalTestContext()
|
||||
ctx.Metadata = map[string]interface{}{
|
||||
"__prompt_preset": "non.existent.preset",
|
||||
}
|
||||
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "Test non-existent preset"},
|
||||
}
|
||||
|
||||
finalMessages, _, err := ast.BuildRequest(ctx, messages, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should fallback to default prompts (not crash)
|
||||
hasSystemPrompt := false
|
||||
for _, msg := range finalMessages {
|
||||
if msg.Role == context.RoleSystem {
|
||||
hasSystemPrompt = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, hasSystemPrompt, "Should fallback to default prompts when preset not found")
|
||||
})
|
||||
|
||||
t.Run("DisableGlobalPromptsFromHook", func(t *testing.T) {
|
||||
// Set global prompts
|
||||
assistant.SetGlobalPrompts([]store.Prompt{
|
||||
{Role: "system", Content: "GLOBAL_PROMPT_MARKER"},
|
||||
})
|
||||
defer assistant.SetGlobalPrompts(nil)
|
||||
|
||||
// Load an assistant that does NOT disable global prompts
|
||||
ast, err := assistant.Get("yaobots")
|
||||
require.NoError(t, err)
|
||||
require.False(t, ast.DisableGlobalPrompts)
|
||||
|
||||
ctx := newMinimalTestContext()
|
||||
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "Test disable from hook"},
|
||||
}
|
||||
|
||||
// Hook disables global prompts
|
||||
disableTrue := true
|
||||
createResponse := &context.HookCreateResponse{
|
||||
DisableGlobalPrompts: &disableTrue,
|
||||
}
|
||||
|
||||
finalMessages, _, err := ast.BuildRequest(ctx, messages, createResponse)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should NOT have global prompt
|
||||
for _, msg := range finalMessages {
|
||||
if msg.Role == context.RoleSystem {
|
||||
assert.NotContains(t, msg.Content, "GLOBAL_PROMPT_MARKER", "Global prompts should be disabled by hook")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DisableGlobalPromptsFromMetadata", func(t *testing.T) {
|
||||
// Set global prompts
|
||||
assistant.SetGlobalPrompts([]store.Prompt{
|
||||
{Role: "system", Content: "GLOBAL_PROMPT_MARKER_2"},
|
||||
})
|
||||
defer assistant.SetGlobalPrompts(nil)
|
||||
|
||||
// Load an assistant that does NOT disable global prompts
|
||||
ast, err := assistant.Get("yaobots")
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := newMinimalTestContext()
|
||||
ctx.Metadata = map[string]interface{}{
|
||||
"__disable_global_prompts": true,
|
||||
}
|
||||
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "Test disable from metadata"},
|
||||
}
|
||||
|
||||
finalMessages, _, err := ast.BuildRequest(ctx, messages, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should NOT have global prompt
|
||||
for _, msg := range finalMessages {
|
||||
if msg.Role == context.RoleSystem {
|
||||
assert.NotContains(t, msg.Content, "GLOBAL_PROMPT_MARKER_2", "Global prompts should be disabled by metadata")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("EnableGlobalPromptsOverrideAssistant", func(t *testing.T) {
|
||||
// Set global prompts
|
||||
assistant.SetGlobalPrompts([]store.Prompt{
|
||||
{Role: "system", Content: "GLOBAL_ENABLED_MARKER"},
|
||||
})
|
||||
defer assistant.SetGlobalPrompts(nil)
|
||||
|
||||
// Load fullfields assistant which has disable_global_prompts: true
|
||||
ast, err := assistant.Get("tests.fullfields")
|
||||
require.NoError(t, err)
|
||||
require.True(t, ast.DisableGlobalPrompts)
|
||||
|
||||
ctx := newMinimalTestContext()
|
||||
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "Test enable override"},
|
||||
}
|
||||
|
||||
// Hook enables global prompts (overrides assistant's disable)
|
||||
disableFalse := false
|
||||
createResponse := &context.HookCreateResponse{
|
||||
DisableGlobalPrompts: &disableFalse,
|
||||
}
|
||||
|
||||
finalMessages, _, err := ast.BuildRequest(ctx, messages, createResponse)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should have global prompt (hook enabled it)
|
||||
found := false
|
||||
for _, msg := range finalMessages {
|
||||
if msg.Role == context.RoleSystem && msg.Content == "GLOBAL_ENABLED_MARKER" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Global prompts should be enabled by hook override")
|
||||
})
|
||||
}
|
||||
|
||||
// TestPromptPresetAssistant tests the tests.promptpreset assistant with Create Hook
|
||||
func TestPromptPresetAssistant(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
t.Run("LoadPromptPresetAssistant", func(t *testing.T) {
|
||||
ast, err := assistant.Get("tests.promptpreset")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
assert.Equal(t, "tests.promptpreset", ast.ID)
|
||||
assert.Equal(t, "Prompt Preset Test", ast.Name)
|
||||
assert.False(t, ast.DisableGlobalPrompts)
|
||||
|
||||
// Should have prompt presets loaded
|
||||
require.NotNil(t, ast.PromptPresets)
|
||||
assert.Contains(t, ast.PromptPresets, "mode.friendly")
|
||||
assert.Contains(t, ast.PromptPresets, "mode.professional")
|
||||
|
||||
// Should have script
|
||||
assert.NotNil(t, ast.HookScript)
|
||||
})
|
||||
|
||||
t.Run("CreateHookSelectsFriendlyPreset", func(t *testing.T) {
|
||||
ast, err := assistant.Get("tests.promptpreset")
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := newPromptTestContext("chat-friendly-test", "tests.promptpreset")
|
||||
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "use friendly mode please"},
|
||||
}
|
||||
|
||||
// Call Create hook
|
||||
createResponse, _, err := ast.HookScript.Create(ctx, messages, &context.Options{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, createResponse)
|
||||
assert.Equal(t, "mode.friendly", createResponse.PromptPreset)
|
||||
|
||||
// Build request
|
||||
finalMessages, _, err := ast.BuildRequest(ctx, messages, createResponse)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should have friendly preset marker in one of the system messages
|
||||
found := false
|
||||
for _, msg := range finalMessages {
|
||||
if msg.Role == context.RoleSystem && containsString(msg.Content, "FRIENDLY_PRESET_MARKER") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Should use friendly preset from Create Hook")
|
||||
})
|
||||
|
||||
t.Run("CreateHookSelectsProfessionalPreset", func(t *testing.T) {
|
||||
ast, err := assistant.Get("tests.promptpreset")
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := newPromptTestContext("chat-professional-test", "tests.promptpreset")
|
||||
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "use professional tone"},
|
||||
}
|
||||
|
||||
// Call Create hook
|
||||
createResponse, _, err := ast.HookScript.Create(ctx, messages, &context.Options{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, createResponse)
|
||||
assert.Equal(t, "mode.professional", createResponse.PromptPreset)
|
||||
|
||||
// Build request
|
||||
finalMessages, _, err := ast.BuildRequest(ctx, messages, createResponse)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should have professional preset marker in one of the system messages
|
||||
found := false
|
||||
for _, msg := range finalMessages {
|
||||
if msg.Role == context.RoleSystem && containsString(msg.Content, "PROFESSIONAL_PRESET_MARKER") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Should use professional preset from Create Hook")
|
||||
})
|
||||
|
||||
t.Run("CreateHookDisablesGlobalPrompts", func(t *testing.T) {
|
||||
// Set global prompts
|
||||
assistant.SetGlobalPrompts([]store.Prompt{
|
||||
{Role: "system", Content: "GLOBAL_MARKER_FOR_DISABLE_TEST"},
|
||||
})
|
||||
defer assistant.SetGlobalPrompts(nil)
|
||||
|
||||
ast, err := assistant.Get("tests.promptpreset")
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := newPromptTestContext("chat-disable-global-test", "tests.promptpreset")
|
||||
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "disable global prompts"},
|
||||
}
|
||||
|
||||
// Call Create hook
|
||||
createResponse, _, err := ast.HookScript.Create(ctx, messages, &context.Options{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, createResponse)
|
||||
require.NotNil(t, createResponse.DisableGlobalPrompts)
|
||||
assert.True(t, *createResponse.DisableGlobalPrompts)
|
||||
|
||||
// Build request
|
||||
finalMessages, _, err := ast.BuildRequest(ctx, messages, createResponse)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should NOT have global prompt
|
||||
for _, msg := range finalMessages {
|
||||
if msg.Role == context.RoleSystem {
|
||||
assert.NotContains(t, msg.Content, "GLOBAL_MARKER_FOR_DISABLE_TEST")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CreateHookPresetAndDisableGlobal", func(t *testing.T) {
|
||||
// Set global prompts
|
||||
assistant.SetGlobalPrompts([]store.Prompt{
|
||||
{Role: "system", Content: "GLOBAL_MARKER_COMBINED_TEST"},
|
||||
})
|
||||
defer assistant.SetGlobalPrompts(nil)
|
||||
|
||||
ast, err := assistant.Get("tests.promptpreset")
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := newPromptTestContext("chat-combined-test", "tests.promptpreset")
|
||||
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "friendly no global"},
|
||||
}
|
||||
|
||||
// Call Create hook
|
||||
createResponse, _, err := ast.HookScript.Create(ctx, messages, &context.Options{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, createResponse)
|
||||
assert.Equal(t, "mode.friendly", createResponse.PromptPreset)
|
||||
require.NotNil(t, createResponse.DisableGlobalPrompts)
|
||||
assert.True(t, *createResponse.DisableGlobalPrompts)
|
||||
|
||||
// Build request
|
||||
finalMessages, _, err := ast.BuildRequest(ctx, messages, createResponse)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should have friendly preset but NOT global
|
||||
hasFriendly := false
|
||||
for _, msg := range finalMessages {
|
||||
if msg.Role == context.RoleSystem {
|
||||
assert.NotContains(t, msg.Content, "GLOBAL_MARKER_COMBINED_TEST")
|
||||
if containsString(msg.Content, "FRIENDLY_PRESET_MARKER") {
|
||||
hasFriendly = true
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.True(t, hasFriendly, "Should have friendly preset")
|
||||
})
|
||||
|
||||
t.Run("CreateHookUnknownPresetFallback", func(t *testing.T) {
|
||||
ast, err := assistant.Get("tests.promptpreset")
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := newPromptTestContext("chat-unknown-preset-test", "tests.promptpreset")
|
||||
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "unknown preset test"},
|
||||
}
|
||||
|
||||
// Call Create hook
|
||||
createResponse, _, err := ast.HookScript.Create(ctx, messages, &context.Options{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, createResponse)
|
||||
assert.Equal(t, "non.existent.preset", createResponse.PromptPreset)
|
||||
|
||||
// Build request - should not error, fallback to default
|
||||
finalMessages, _, err := ast.BuildRequest(ctx, messages, createResponse)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should fallback to default prompts
|
||||
found := false
|
||||
for _, msg := range finalMessages {
|
||||
if msg.Role == context.RoleSystem && containsString(msg.Content, "DEFAULT_PROMPT_MARKER") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Should fallback to default prompts when preset not found")
|
||||
})
|
||||
|
||||
t.Run("CreateHookReturnsNull", func(t *testing.T) {
|
||||
ast, err := assistant.Get("tests.promptpreset")
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := newPromptTestContext("chat-null-test", "tests.promptpreset")
|
||||
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "just a normal message"},
|
||||
}
|
||||
|
||||
// Call Create hook - should return nil
|
||||
createResponse, _, err := ast.HookScript.Create(ctx, messages, &context.Options{})
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, createResponse)
|
||||
|
||||
// Build request with nil createResponse
|
||||
finalMessages, _, err := ast.BuildRequest(ctx, messages, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should use default prompts
|
||||
found := false
|
||||
for _, msg := range finalMessages {
|
||||
if msg.Role == context.RoleSystem && containsString(msg.Content, "DEFAULT_PROMPT_MARKER") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Should use default prompts when hook returns null")
|
||||
})
|
||||
}
|
||||
|
|
@ -1,410 +0,0 @@
|
|||
package assistant_test
|
||||
|
||||
import (
|
||||
stdContext "context"
|
||||
"testing"
|
||||
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
// newTestContext creates a Context for testing with commonly used fields pre-populated
|
||||
func newTestContext(chatID, assistantID string) *context.Context {
|
||||
authorized := &types.AuthorizedInfo{
|
||||
Subject: "test-user",
|
||||
ClientID: "test-client-id",
|
||||
UserID: "test-user-123",
|
||||
TeamID: "test-team-456",
|
||||
TenantID: "test-tenant-789",
|
||||
SessionID: "test-session-id",
|
||||
}
|
||||
|
||||
ctx := context.New(stdContext.Background(), authorized, chatID)
|
||||
ctx.AssistantID = assistantID
|
||||
ctx.Locale = "en-us"
|
||||
ctx.Theme = "light"
|
||||
ctx.Client = context.Client{
|
||||
Type: "web",
|
||||
UserAgent: "TestAgent/1.0",
|
||||
IP: "127.0.0.1",
|
||||
}
|
||||
ctx.Referer = context.RefererAPI
|
||||
ctx.Accept = context.AcceptWebCUI
|
||||
ctx.Route = "/test/route"
|
||||
ctx.Metadata = map[string]interface{}{
|
||||
"test": "context_metadata",
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
// TestBuildRequest tests the BuildRequest function
|
||||
func TestBuildRequest(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.buildrequest")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get tests.buildrequest assistant: %s", err.Error())
|
||||
}
|
||||
|
||||
if agent.HookScript == nil {
|
||||
t.Fatalf("The tests.buildrequest assistant has no script")
|
||||
}
|
||||
|
||||
ctx := newTestContext("chat-test-buildrequest", "tests.buildrequest")
|
||||
|
||||
// Test 1: No override from hook - should use ast.Options and ctx values
|
||||
t.Run("NoOverride", func(t *testing.T) {
|
||||
inputMessages := []context.Message{{Role: "user", Content: "no_override"}}
|
||||
|
||||
// Call Create hook
|
||||
createResponse, _, err := agent.HookScript.Create(ctx, inputMessages, &context.Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to call Create hook: %s", err.Error())
|
||||
}
|
||||
|
||||
// Build LLM request
|
||||
_, options, err := agent.BuildRequest(ctx, inputMessages, createResponse)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to build LLM request: %s", err.Error())
|
||||
}
|
||||
|
||||
// Verify options - should use ast.Options values
|
||||
if options.Temperature == nil {
|
||||
t.Error("Expected temperature from ast.Options, got nil")
|
||||
} else if *options.Temperature != 0.5 {
|
||||
t.Errorf("Expected temperature 0.5 from ast.Options, got: %f", *options.Temperature)
|
||||
}
|
||||
|
||||
if options.MaxTokens == nil {
|
||||
t.Error("Expected max_tokens from ast.Options, got nil")
|
||||
} else if *options.MaxTokens != 1000 {
|
||||
t.Errorf("Expected max_tokens 1000 from ast.Options, got: %d", *options.MaxTokens)
|
||||
}
|
||||
|
||||
if options.TopP == nil {
|
||||
t.Error("Expected top_p from ast.Options, got nil")
|
||||
} else if *options.TopP != 0.9 {
|
||||
t.Errorf("Expected top_p 0.9 from ast.Options, got: %f", *options.TopP)
|
||||
}
|
||||
|
||||
// Verify ctx values
|
||||
if options.Route != "/test/route" {
|
||||
t.Errorf("Expected route '/test/route' from ctx, got: %s", options.Route)
|
||||
}
|
||||
|
||||
if options.Metadata == nil {
|
||||
t.Error("Expected metadata from ctx, got nil")
|
||||
} else if options.Metadata["test"] != "context_metadata" {
|
||||
t.Errorf("Expected metadata from ctx, got: %v", options.Metadata)
|
||||
}
|
||||
|
||||
t.Log("✓ No override: ast.Options and ctx values used correctly")
|
||||
})
|
||||
|
||||
// Test 2: Override temperature - hook value should take priority
|
||||
t.Run("OverrideTemperature", func(t *testing.T) {
|
||||
inputMessages := []context.Message{{Role: "user", Content: "override_temperature"}}
|
||||
|
||||
createResponse, _, err := agent.HookScript.Create(ctx, inputMessages, &context.Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to call Create hook: %s", err.Error())
|
||||
}
|
||||
|
||||
_, options, err := agent.BuildRequest(ctx, inputMessages, createResponse)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to build LLM request: %s", err.Error())
|
||||
}
|
||||
|
||||
// Verify temperature override
|
||||
if options.Temperature == nil {
|
||||
t.Error("Expected temperature, got nil")
|
||||
} else if *options.Temperature != 0.9 {
|
||||
t.Errorf("Expected temperature 0.9 from hook, got: %f", *options.Temperature)
|
||||
}
|
||||
|
||||
// Other values should still come from ast.Options
|
||||
if options.MaxTokens == nil {
|
||||
t.Error("Expected max_tokens from ast.Options, got nil")
|
||||
} else if *options.MaxTokens != 1000 {
|
||||
t.Errorf("Expected max_tokens 1000 from ast.Options, got: %d", *options.MaxTokens)
|
||||
}
|
||||
|
||||
t.Log("✓ Temperature override: hook value takes priority over ast.Options")
|
||||
})
|
||||
|
||||
// Test 3: Override all - all hook values should take priority
|
||||
t.Run("OverrideAll", func(t *testing.T) {
|
||||
inputMessages := []context.Message{{Role: "user", Content: "override_all"}}
|
||||
|
||||
createResponse, _, err := agent.HookScript.Create(ctx, inputMessages, &context.Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to call Create hook: %s", err.Error())
|
||||
}
|
||||
|
||||
_, options, err := agent.BuildRequest(ctx, inputMessages, createResponse)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to build LLM request: %s", err.Error())
|
||||
}
|
||||
|
||||
// Verify all overrides
|
||||
if options.Temperature == nil || *options.Temperature != 0.8 {
|
||||
t.Errorf("Expected temperature 0.8 from hook, got: %v", options.Temperature)
|
||||
}
|
||||
|
||||
if options.MaxTokens == nil || *options.MaxTokens != 2000 {
|
||||
t.Errorf("Expected max_tokens 2000 from hook, got: %v", options.MaxTokens)
|
||||
}
|
||||
|
||||
if options.MaxCompletionTokens == nil || *options.MaxCompletionTokens != 1800 {
|
||||
t.Errorf("Expected max_completion_tokens 1800 from hook, got: %v", options.MaxCompletionTokens)
|
||||
}
|
||||
|
||||
if options.Audio == nil {
|
||||
t.Error("Expected audio from hook, got nil")
|
||||
} else {
|
||||
if options.Audio.Voice != "alloy" {
|
||||
t.Errorf("Expected voice 'alloy', got: %s", options.Audio.Voice)
|
||||
}
|
||||
if options.Audio.Format != "mp3" {
|
||||
t.Errorf("Expected format 'mp3', got: %s", options.Audio.Format)
|
||||
}
|
||||
}
|
||||
|
||||
if options.Route != "/hook/route" {
|
||||
t.Errorf("Expected route '/hook/route' from hook, got: %s", options.Route)
|
||||
}
|
||||
|
||||
if options.Metadata == nil {
|
||||
t.Error("Expected metadata from hook, got nil")
|
||||
} else {
|
||||
if options.Metadata["source"] != "hook" {
|
||||
t.Errorf("Expected metadata['source'] = 'hook', got: %v", options.Metadata["source"])
|
||||
}
|
||||
}
|
||||
|
||||
t.Log("✓ Override all: all hook values take priority")
|
||||
})
|
||||
|
||||
// Test 4: Override route and metadata - tests CUI context priority
|
||||
t.Run("OverrideRouteMetadata", func(t *testing.T) {
|
||||
inputMessages := []context.Message{{Role: "user", Content: "override_route_metadata"}}
|
||||
|
||||
createResponse, _, err := agent.HookScript.Create(ctx, inputMessages, &context.Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to call Create hook: %s", err.Error())
|
||||
}
|
||||
|
||||
_, options, err := agent.BuildRequest(ctx, inputMessages, createResponse)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to build LLM request: %s", err.Error())
|
||||
}
|
||||
|
||||
// Verify route override
|
||||
if options.Route != "/custom/route" {
|
||||
t.Errorf("Expected route '/custom/route' from hook, got: %s", options.Route)
|
||||
}
|
||||
|
||||
// Verify metadata merge (ctx metadata should be merged with hook metadata)
|
||||
if options.Metadata == nil {
|
||||
t.Error("Expected metadata, got nil")
|
||||
} else {
|
||||
// Hook metadata should be present
|
||||
if options.Metadata["custom"] != true {
|
||||
t.Errorf("Expected metadata['custom'] = true from hook, got: %v", options.Metadata["custom"])
|
||||
}
|
||||
if options.Metadata["hook_data"] != "test" {
|
||||
t.Errorf("Expected metadata['hook_data'] = 'test' from hook, got: %v", options.Metadata["hook_data"])
|
||||
}
|
||||
// Original ctx metadata should still be there (merged)
|
||||
if options.Metadata["test"] != "context_metadata" {
|
||||
t.Errorf("Expected original ctx metadata to be preserved, got: %v", options.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
// Other values should still come from ast.Options
|
||||
if options.Temperature == nil || *options.Temperature != 0.5 {
|
||||
t.Errorf("Expected temperature 0.5 from ast.Options, got: %v", options.Temperature)
|
||||
}
|
||||
|
||||
t.Log("✓ Route and metadata override: hook values take priority, metadata merged")
|
||||
})
|
||||
|
||||
// Test 5: Nil createResponse - should use ast.Options and ctx values
|
||||
t.Run("NilCreateResponse", func(t *testing.T) {
|
||||
// Create a fresh context for this test
|
||||
freshCtx := newTestContext("chat-test-nil", "tests.buildrequest")
|
||||
inputMessages := []context.Message{{Role: "user", Content: "test message"}}
|
||||
|
||||
_, options, err := agent.BuildRequest(freshCtx, inputMessages, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to build LLM request: %s", err.Error())
|
||||
}
|
||||
|
||||
// Should use ast.Options values
|
||||
if options.Temperature == nil || *options.Temperature != 0.5 {
|
||||
t.Errorf("Expected temperature 0.5 from ast.Options, got: %v", options.Temperature)
|
||||
}
|
||||
|
||||
// Should use ctx values
|
||||
if options.Route != "/test/route" {
|
||||
t.Errorf("Expected route '/test/route' from ctx, got: %s", options.Route)
|
||||
}
|
||||
|
||||
t.Log("✓ Nil createResponse: ast.Options and ctx values used")
|
||||
})
|
||||
|
||||
// Test 6: ResponseFormat with *context.ResponseFormat
|
||||
t.Run("ResponseFormatStruct", func(t *testing.T) {
|
||||
freshCtx := newTestContext("chat-test-response-format", "tests.buildrequest")
|
||||
inputMessages := []context.Message{{Role: "user", Content: "test message"}}
|
||||
|
||||
// Create a test agent with response_format in Options
|
||||
testAgent := *agent
|
||||
strict := true
|
||||
testAgent.Options = map[string]interface{}{
|
||||
"temperature": 0.7,
|
||||
"response_format": &context.ResponseFormat{
|
||||
Type: context.ResponseFormatJSONSchema,
|
||||
JSONSchema: &context.JSONSchema{
|
||||
Name: "test_schema",
|
||||
Description: "Test schema description",
|
||||
Schema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"name": map[string]interface{}{
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
Strict: &strict,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, options, err := testAgent.BuildRequest(freshCtx, inputMessages, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to build LLM request: %s", err.Error())
|
||||
}
|
||||
|
||||
// Verify ResponseFormat
|
||||
if options.ResponseFormat == nil {
|
||||
t.Fatal("Expected ResponseFormat, got nil")
|
||||
}
|
||||
|
||||
if options.ResponseFormat.Type != context.ResponseFormatJSONSchema {
|
||||
t.Errorf("Expected type 'json_schema', got: %s", options.ResponseFormat.Type)
|
||||
}
|
||||
|
||||
if options.ResponseFormat.JSONSchema == nil {
|
||||
t.Fatal("Expected JSONSchema, got nil")
|
||||
}
|
||||
|
||||
if options.ResponseFormat.JSONSchema.Name != "test_schema" {
|
||||
t.Errorf("Expected schema name 'test_schema', got: %s", options.ResponseFormat.JSONSchema.Name)
|
||||
}
|
||||
|
||||
if options.ResponseFormat.JSONSchema.Description != "Test schema description" {
|
||||
t.Errorf("Expected schema description 'Test schema description', got: %s", options.ResponseFormat.JSONSchema.Description)
|
||||
}
|
||||
|
||||
if options.ResponseFormat.JSONSchema.Strict == nil || *options.ResponseFormat.JSONSchema.Strict != true {
|
||||
t.Errorf("Expected strict = true, got: %v", options.ResponseFormat.JSONSchema.Strict)
|
||||
}
|
||||
|
||||
t.Log("✓ ResponseFormat with *context.ResponseFormat struct works correctly")
|
||||
})
|
||||
|
||||
// Test 7: ResponseFormat with legacy map[string]interface{}
|
||||
t.Run("ResponseFormatLegacyMap", func(t *testing.T) {
|
||||
freshCtx := newTestContext("chat-test-response-format-map", "tests.buildrequest")
|
||||
inputMessages := []context.Message{{Role: "user", Content: "test message"}}
|
||||
|
||||
// Create a test agent with legacy map format
|
||||
testAgent := *agent
|
||||
testAgent.Options = map[string]interface{}{
|
||||
"temperature": 0.7,
|
||||
"response_format": map[string]interface{}{
|
||||
"type": "json_schema",
|
||||
"json_schema": map[string]interface{}{
|
||||
"name": "legacy_schema",
|
||||
"description": "Legacy schema format",
|
||||
"schema": map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"email": map[string]interface{}{
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
"strict": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, options, err := testAgent.BuildRequest(freshCtx, inputMessages, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to build LLM request: %s", err.Error())
|
||||
}
|
||||
|
||||
// Verify ResponseFormat was converted from map
|
||||
if options.ResponseFormat == nil {
|
||||
t.Fatal("Expected ResponseFormat, got nil")
|
||||
}
|
||||
|
||||
if options.ResponseFormat.Type != context.ResponseFormatJSONSchema {
|
||||
t.Errorf("Expected type 'json_schema', got: %s", options.ResponseFormat.Type)
|
||||
}
|
||||
|
||||
if options.ResponseFormat.JSONSchema == nil {
|
||||
t.Fatal("Expected JSONSchema, got nil")
|
||||
}
|
||||
|
||||
if options.ResponseFormat.JSONSchema.Name != "legacy_schema" {
|
||||
t.Errorf("Expected schema name 'legacy_schema', got: %s", options.ResponseFormat.JSONSchema.Name)
|
||||
}
|
||||
|
||||
if options.ResponseFormat.JSONSchema.Description != "Legacy schema format" {
|
||||
t.Errorf("Expected schema description 'Legacy schema format', got: %s", options.ResponseFormat.JSONSchema.Description)
|
||||
}
|
||||
|
||||
t.Log("✓ ResponseFormat with legacy map[string]interface{} format works correctly")
|
||||
})
|
||||
|
||||
// Test 8: ResponseFormat with simple type (text or json_object)
|
||||
t.Run("ResponseFormatSimpleType", func(t *testing.T) {
|
||||
freshCtx := newTestContext("chat-test-response-format-simple", "tests.buildrequest")
|
||||
inputMessages := []context.Message{{Role: "user", Content: "test message"}}
|
||||
|
||||
// Create a test agent with simple response_format
|
||||
testAgent := *agent
|
||||
testAgent.Options = map[string]interface{}{
|
||||
"temperature": 0.7,
|
||||
"response_format": map[string]interface{}{
|
||||
"type": "json_object",
|
||||
},
|
||||
}
|
||||
|
||||
_, options, err := testAgent.BuildRequest(freshCtx, inputMessages, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to build LLM request: %s", err.Error())
|
||||
}
|
||||
|
||||
// Verify ResponseFormat
|
||||
if options.ResponseFormat == nil {
|
||||
t.Fatal("Expected ResponseFormat, got nil")
|
||||
}
|
||||
|
||||
if options.ResponseFormat.Type != context.ResponseFormatJSON {
|
||||
t.Errorf("Expected type 'json_object', got: %s", options.ResponseFormat.Type)
|
||||
}
|
||||
|
||||
if options.ResponseFormat.JSONSchema != nil {
|
||||
t.Errorf("Expected JSONSchema to be nil for simple type, got: %v", options.ResponseFormat.JSONSchema)
|
||||
}
|
||||
|
||||
t.Log("✓ ResponseFormat with simple type (json_object) works correctly")
|
||||
})
|
||||
}
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
package assistant
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Cache represents a thread-safe LRU cache for Assistant objects
|
||||
type Cache struct {
|
||||
capacity int
|
||||
mu sync.RWMutex
|
||||
list *list.List
|
||||
items map[string]*list.Element
|
||||
}
|
||||
|
||||
// cacheItem represents an item in the cache
|
||||
type cacheItem struct {
|
||||
key string
|
||||
value *Assistant
|
||||
}
|
||||
|
||||
// NewCache creates a new LRU cache with the given capacity
|
||||
func NewCache(capacity int) *Cache {
|
||||
return &Cache{
|
||||
capacity: capacity,
|
||||
list: list.New(),
|
||||
items: make(map[string]*list.Element),
|
||||
}
|
||||
}
|
||||
|
||||
// Get retrieves an Assistant from the cache by its ID
|
||||
func (c *Cache) Get(id string) (*Assistant, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if element, exists := c.items[id]; exists {
|
||||
c.list.MoveToFront(element)
|
||||
return element.Value.(*cacheItem).value, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Put adds or updates an Assistant in the cache
|
||||
func (c *Cache) Put(assistant *Assistant) {
|
||||
if assistant == nil || assistant.ID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// If item exists, update it and move to front
|
||||
if element, exists := c.items[assistant.ID]; exists {
|
||||
c.list.MoveToFront(element)
|
||||
element.Value.(*cacheItem).value = assistant
|
||||
return
|
||||
}
|
||||
|
||||
// If cache is at capacity, remove oldest item before adding new one
|
||||
if c.list.Len() >= c.capacity {
|
||||
c.removeOldest()
|
||||
}
|
||||
|
||||
// Add new item
|
||||
element := c.list.PushFront(&cacheItem{
|
||||
key: assistant.ID,
|
||||
value: assistant,
|
||||
})
|
||||
c.items[assistant.ID] = element
|
||||
}
|
||||
|
||||
// Remove removes an Assistant from the cache
|
||||
func (c *Cache) Remove(id string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if element, exists := c.items[id]; exists {
|
||||
item := element.Value.(*cacheItem)
|
||||
|
||||
// Unregister scripts before removing from cache
|
||||
if item.value != nil && len(item.value.Scripts) > 0 {
|
||||
item.value.UnregisterScripts()
|
||||
}
|
||||
|
||||
c.list.Remove(element)
|
||||
delete(c.items, id)
|
||||
}
|
||||
}
|
||||
|
||||
// Len returns the current number of items in the cache
|
||||
func (c *Cache) Len() int {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return c.list.Len()
|
||||
}
|
||||
|
||||
// All returns all assistants in the cache
|
||||
func (c *Cache) All() []*Assistant {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
assistants := make([]*Assistant, 0, c.list.Len())
|
||||
for element := c.list.Front(); element != nil; element = element.Next() {
|
||||
item := element.Value.(*cacheItem)
|
||||
assistants = append(assistants, item.value)
|
||||
}
|
||||
return assistants
|
||||
}
|
||||
|
||||
// Clear removes all items from the cache
|
||||
func (c *Cache) Clear() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// Unregister all scripts before clearing cache
|
||||
for element := c.list.Front(); element != nil; element = element.Next() {
|
||||
item := element.Value.(*cacheItem)
|
||||
if item.value != nil && len(item.value.Scripts) > 0 {
|
||||
item.value.UnregisterScripts()
|
||||
}
|
||||
}
|
||||
|
||||
c.list.Init()
|
||||
c.items = make(map[string]*list.Element)
|
||||
}
|
||||
|
||||
// ClearExcept removes items from the cache except those matching the keep function
|
||||
// keep function returns true for items that should be preserved
|
||||
func (c *Cache) ClearExcept(keep func(id string) bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// Collect items to remove
|
||||
var toRemove []*list.Element
|
||||
for element := c.list.Front(); element != nil; element = element.Next() {
|
||||
item := element.Value.(*cacheItem)
|
||||
if !keep(item.key) {
|
||||
toRemove = append(toRemove, element)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove collected items
|
||||
for _, element := range toRemove {
|
||||
item := element.Value.(*cacheItem)
|
||||
|
||||
// Unregister scripts before removing
|
||||
if item.value != nil && len(item.value.Scripts) > 0 {
|
||||
item.value.UnregisterScripts()
|
||||
}
|
||||
|
||||
c.list.Remove(element)
|
||||
delete(c.items, item.key)
|
||||
}
|
||||
}
|
||||
|
||||
// removeOldest removes the least recently used item from the cache
|
||||
func (c *Cache) removeOldest() {
|
||||
if element := c.list.Back(); element != nil {
|
||||
item := element.Value.(*cacheItem)
|
||||
|
||||
// Unregister scripts before removing from cache
|
||||
if item.value != nil && len(item.value.Scripts) > 0 {
|
||||
item.value.UnregisterScripts()
|
||||
}
|
||||
|
||||
c.list.Remove(element)
|
||||
delete(c.items, item.key)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,253 +0,0 @@
|
|||
package assistant_test
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/gou/process"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
)
|
||||
|
||||
func TestCacheBasic(t *testing.T) {
|
||||
cache := assistant.NewCache(2)
|
||||
|
||||
// Test empty cache
|
||||
assert.Equal(t, 0, cache.Len(), "Expected empty cache")
|
||||
|
||||
// Create test assistants
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ast1, err := assistant.Get("tests.mcpload")
|
||||
assert.NoError(t, err)
|
||||
|
||||
ast2, err := assistant.Get("tests.create")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test adding items
|
||||
cache.Put(ast1)
|
||||
cache.Put(ast2)
|
||||
|
||||
assert.Equal(t, 2, cache.Len(), "Expected cache length 2")
|
||||
|
||||
// Test getting items
|
||||
cached1, exists := cache.Get("tests.mcpload")
|
||||
assert.True(t, exists, "Should find tests.mcpload")
|
||||
assert.Equal(t, "tests.mcpload", cached1.ID)
|
||||
|
||||
cached2, exists := cache.Get("tests.create")
|
||||
assert.True(t, exists, "Should find tests.create")
|
||||
assert.Equal(t, "tests.create", cached2.ID)
|
||||
}
|
||||
|
||||
func TestCacheLRU(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
cache := assistant.NewCache(2)
|
||||
|
||||
ast1, _ := assistant.Get("tests.mcpload")
|
||||
ast2, _ := assistant.Get("tests.create")
|
||||
ast3, _ := assistant.Get("tests.next")
|
||||
|
||||
// Add first two items
|
||||
cache.Put(ast1)
|
||||
cache.Put(ast2)
|
||||
|
||||
// Access ast1 to make it most recently used
|
||||
cache.Get("tests.mcpload")
|
||||
|
||||
// Add third item, should evict ast2
|
||||
cache.Put(ast3)
|
||||
|
||||
// Check ast2 was evicted
|
||||
_, exists := cache.Get("tests.create")
|
||||
assert.False(t, exists, "tests.create should have been evicted")
|
||||
|
||||
// Check ast1 and ast3 are still present
|
||||
_, exists = cache.Get("tests.mcpload")
|
||||
assert.True(t, exists, "tests.mcpload should still be in cache")
|
||||
|
||||
_, exists = cache.Get("tests.next")
|
||||
assert.True(t, exists, "tests.next should be in cache")
|
||||
}
|
||||
|
||||
func TestCacheRemove(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
cache := assistant.NewCache(2)
|
||||
|
||||
ast1, _ := assistant.Get("tests.mcpload")
|
||||
cache.Put(ast1)
|
||||
|
||||
// Verify scripts are registered
|
||||
_, exists := process.Handlers["agents.tests.mcpload.tools"]
|
||||
assert.True(t, exists, "Handler should be registered before removal")
|
||||
|
||||
// Test remove existing item
|
||||
cache.Remove("tests.mcpload")
|
||||
assert.Equal(t, 0, cache.Len(), "Cache should be empty after removing item")
|
||||
|
||||
// Verify scripts are unregistered
|
||||
_, exists = process.Handlers["agents.tests.mcpload.tools"]
|
||||
assert.False(t, exists, "Handler should be unregistered after removal")
|
||||
|
||||
// Test remove non-existing item (should not panic)
|
||||
cache.Remove("nonexistent")
|
||||
assert.Equal(t, 0, cache.Len(), "Cache length should not change")
|
||||
}
|
||||
|
||||
func TestCacheClear(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
cache := assistant.NewCache(3)
|
||||
|
||||
ast1, _ := assistant.Get("tests.mcpload")
|
||||
ast2, _ := assistant.Get("tests.create")
|
||||
ast3, _ := assistant.Get("tests.next")
|
||||
|
||||
cache.Put(ast1)
|
||||
cache.Put(ast2)
|
||||
cache.Put(ast3)
|
||||
|
||||
assert.Equal(t, 3, cache.Len(), "Cache should have 3 items")
|
||||
|
||||
// Verify scripts are registered
|
||||
_, exists := process.Handlers["agents.tests.mcpload.tools"]
|
||||
assert.True(t, exists, "Handler should be registered before clear")
|
||||
|
||||
// Clear cache
|
||||
cache.Clear()
|
||||
assert.Equal(t, 0, cache.Len(), "Cache should be empty after clear")
|
||||
|
||||
// Verify all scripts are unregistered
|
||||
_, exists = process.Handlers["agents.tests.mcpload.tools"]
|
||||
assert.False(t, exists, "Handler should be unregistered after clear")
|
||||
}
|
||||
|
||||
func TestCacheLRUEviction(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
cache := assistant.NewCache(2)
|
||||
|
||||
ast1, _ := assistant.Get("tests.mcpload")
|
||||
ast2, _ := assistant.Get("tests.create")
|
||||
ast3, _ := assistant.Get("tests.next")
|
||||
|
||||
cache.Put(ast1)
|
||||
cache.Put(ast2)
|
||||
|
||||
// Verify both are registered
|
||||
_, exists1 := process.Handlers["agents.tests.mcpload.tools"]
|
||||
assert.True(t, exists1, "Handler 1 should be registered")
|
||||
|
||||
// Add third item to trigger LRU eviction of oldest (ast1)
|
||||
cache.Put(ast3)
|
||||
|
||||
// Verify ast1's handler was unregistered due to eviction
|
||||
_, exists := process.Handlers["agents.tests.mcpload.tools"]
|
||||
assert.False(t, exists, "Handler should be unregistered after LRU eviction")
|
||||
|
||||
// Verify ast2 and ast3 are still in cache
|
||||
_, exists = cache.Get("tests.create")
|
||||
assert.True(t, exists, "tests.create should still be in cache")
|
||||
|
||||
_, exists = cache.Get("tests.next")
|
||||
assert.True(t, exists, "tests.next should be in cache")
|
||||
}
|
||||
|
||||
func TestCacheConcurrent(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
cache := assistant.NewCache(10)
|
||||
var wg sync.WaitGroup
|
||||
workers := 5
|
||||
iterations := 20
|
||||
|
||||
// Load some assistants for concurrent testing
|
||||
assistants := []string{
|
||||
"tests.mcpload",
|
||||
"tests.create",
|
||||
"tests.next",
|
||||
}
|
||||
|
||||
// Concurrent writes
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func(workerID int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
astID := assistants[j%len(assistants)]
|
||||
ast, _ := assistant.Get(astID)
|
||||
if ast != nil {
|
||||
cache.Put(ast)
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Concurrent reads
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func(workerID int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
astID := assistants[j%len(assistants)]
|
||||
cache.Get(astID)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Verify cache is in valid state
|
||||
assert.True(t, cache.Len() >= 0, "Cache should have valid length")
|
||||
assert.True(t, cache.Len() <= 10, "Cache should not exceed capacity")
|
||||
}
|
||||
|
||||
func TestCacheNilInput(t *testing.T) {
|
||||
cache := assistant.NewCache(2)
|
||||
|
||||
// Test putting nil assistant
|
||||
cache.Put(nil)
|
||||
assert.Equal(t, 0, cache.Len(), "Cache should not store nil assistant")
|
||||
|
||||
// Test putting assistant with empty ID
|
||||
emptyAST := &assistant.Assistant{}
|
||||
cache.Put(emptyAST)
|
||||
assert.Equal(t, 0, cache.Len(), "Cache should not store assistant with empty ID")
|
||||
}
|
||||
|
||||
func TestCacheAll(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
cache := assistant.NewCache(5)
|
||||
|
||||
ast1, _ := assistant.Get("tests.mcpload")
|
||||
ast2, _ := assistant.Get("tests.create")
|
||||
ast3, _ := assistant.Get("tests.next")
|
||||
|
||||
cache.Put(ast1)
|
||||
cache.Put(ast2)
|
||||
cache.Put(ast3)
|
||||
|
||||
all := cache.All()
|
||||
assert.Equal(t, 3, len(all), "All() should return 3 assistants")
|
||||
|
||||
// Verify all expected assistants are present
|
||||
ids := make(map[string]bool)
|
||||
for _, ast := range all {
|
||||
ids[ast.ID] = true
|
||||
}
|
||||
|
||||
assert.True(t, ids["tests.mcpload"], "Should contain tests.mcpload")
|
||||
assert.True(t, ids["tests.create"], "Should contain tests.create")
|
||||
assert.True(t, ids["tests.next"], "Should contain tests.next")
|
||||
}
|
||||
|
|
@ -1,400 +0,0 @@
|
|||
package assistant
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
agentcontext "github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/i18n"
|
||||
storetypes "github.com/yaoapp/yao/agent/store/types"
|
||||
)
|
||||
|
||||
// InitializeConversation prepares conversation context (synchronous)
|
||||
// KB collection is now initialized when user logs in (see openapi/user/login.go)
|
||||
func (ast *Assistant) InitializeConversation(ctx *agentcontext.Context, options ...*agentcontext.Options) error {
|
||||
// Reserved for future conversation initialization logic
|
||||
return nil
|
||||
}
|
||||
|
||||
// InitializeConversationAsync prepares conversation context asynchronously
|
||||
func (ast *Assistant) InitializeConversationAsync(ctx *agentcontext.Context, options ...*agentcontext.Options) {
|
||||
go ast.InitializeConversation(ctx, options...)
|
||||
}
|
||||
|
||||
// GetChatKBID returns the KB collection ID for a chat session
|
||||
// Same team + user always returns the same ID (deterministic)
|
||||
// Format: chat_{team}_{user} or chat_user_{user} if no team
|
||||
func GetChatKBID(teamID, userID string) string {
|
||||
// Sanitize IDs: replace invalid chars with underscores
|
||||
cleanTeamID := sanitizeCollectionID(teamID)
|
||||
cleanUserID := sanitizeCollectionID(userID)
|
||||
|
||||
if cleanTeamID != "" {
|
||||
return fmt.Sprintf("chat_%s_%s", cleanTeamID, cleanUserID)
|
||||
}
|
||||
return fmt.Sprintf("chat_user_%s", cleanUserID)
|
||||
}
|
||||
|
||||
// sanitizeCollectionID replaces invalid characters with underscores
|
||||
// Collection IDs only allow: a-z, A-Z, 0-9, and underscore
|
||||
func sanitizeCollectionID(id string) string {
|
||||
if id == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Replace any character that is not alphanumeric or underscore with underscore
|
||||
result := make([]byte, len(id))
|
||||
for i := 0; i < len(id); i++ {
|
||||
c := id[i]
|
||||
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' {
|
||||
result[i] = c
|
||||
} else {
|
||||
result[i] = '_'
|
||||
}
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
|
||||
// mergeChatMetadata merges default metadata with chat context information
|
||||
func mergeChatMetadata(defaultMetadata map[string]interface{}, ctx *agentcontext.Context) map[string]interface{} {
|
||||
metadata := make(map[string]interface{})
|
||||
|
||||
// Copy default metadata
|
||||
for k, v := range defaultMetadata {
|
||||
metadata[k] = v
|
||||
}
|
||||
|
||||
// Add chat-specific metadata (only for internal tracking, not displayed)
|
||||
metadata["chat_id"] = ctx.ChatID
|
||||
metadata["team_id"] = ctx.Authorized.TeamID
|
||||
metadata["user_id"] = ctx.Authorized.UserID
|
||||
|
||||
// Get locale from context, default to zh-CN if not set
|
||||
locale := ctx.Locale
|
||||
if locale == "" {
|
||||
locale = "zh-CN"
|
||||
}
|
||||
locale = strings.ToLower(locale)
|
||||
|
||||
// Use i18n for name and description (fixed, not showing user/team IDs)
|
||||
if _, exists := metadata["name"]; !exists {
|
||||
metadata["name"] = i18n.T(locale, "kb.chat.name")
|
||||
}
|
||||
if _, exists := metadata["description"]; !exists {
|
||||
metadata["description"] = i18n.T(locale, "kb.chat.description")
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Chat Buffer Integration
|
||||
// =============================================================================
|
||||
|
||||
// InitBuffer initializes the chat buffer for the context
|
||||
// Should be called at the start of Stream() for root stack only
|
||||
func (ast *Assistant) InitBuffer(ctx *agentcontext.Context) {
|
||||
// Only initialize for root stack
|
||||
if ctx.Stack == nil || !ctx.Stack.IsRoot() {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if buffer already exists
|
||||
if ctx.Buffer != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if History is disabled in options
|
||||
if ctx.Stack.Options != nil && ctx.Stack.Options.Skip != nil && ctx.Stack.Options.Skip.History {
|
||||
ctx.Logger.Debug("Buffer skipped: Skip.History is true")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate request ID if not set
|
||||
requestID := ctx.RequestID()
|
||||
if requestID == "" {
|
||||
requestID = uuid.New().String()
|
||||
}
|
||||
|
||||
// Get connector and mode from options
|
||||
connector := ""
|
||||
mode := ""
|
||||
if ctx.Stack.Options != nil {
|
||||
connector = ctx.Stack.Options.Connector
|
||||
mode = ctx.Stack.Options.Mode
|
||||
}
|
||||
|
||||
ctx.Buffer = agentcontext.NewChatBuffer(ctx.ChatID, requestID, ast.ID, connector, mode)
|
||||
ctx.Logger.Debug("Buffer initialized: chatID=%s, requestID=%s, assistantID=%s", ctx.ChatID, requestID, ast.ID)
|
||||
}
|
||||
|
||||
// BufferUserInput adds user input messages to the buffer
|
||||
// Should be called after InitBuffer
|
||||
func (ast *Assistant) BufferUserInput(ctx *agentcontext.Context, inputMessages []agentcontext.Message) {
|
||||
if ctx.Buffer == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Only root stack should buffer user input
|
||||
// Delegated agents share the same buffer but should not duplicate user input
|
||||
if ctx.Stack != nil && !ctx.Stack.IsRoot() {
|
||||
return
|
||||
}
|
||||
|
||||
// Convert input messages to buffer format
|
||||
for _, msg := range inputMessages {
|
||||
// Extract content from message
|
||||
var content interface{}
|
||||
var name string
|
||||
|
||||
content = msg.Content
|
||||
if msg.Name != nil {
|
||||
name = *msg.Name
|
||||
}
|
||||
|
||||
ctx.Buffer.AddUserInput(content, name)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateSpaceSnapshot updates the context memory snapshot in the buffer
|
||||
// Only captures Context-level memory (request-scoped temporary data) for recovery
|
||||
func (ast *Assistant) UpdateSpaceSnapshot(ctx *agentcontext.Context) {
|
||||
if ctx.Buffer == nil || ctx.Memory == nil || ctx.Memory.Context == nil {
|
||||
return
|
||||
}
|
||||
|
||||
snapshot := ctx.Memory.Context.Snapshot()
|
||||
ctx.Buffer.SetSpaceSnapshot(snapshot)
|
||||
}
|
||||
|
||||
// BeginStep starts tracking an execution step
|
||||
// Returns the step for further updates
|
||||
func (ast *Assistant) BeginStep(ctx *agentcontext.Context, stepType string, input map[string]interface{}) *agentcontext.BufferedStep {
|
||||
if ctx.Buffer == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update space snapshot before beginning step
|
||||
ast.UpdateSpaceSnapshot(ctx)
|
||||
|
||||
return ctx.Buffer.BeginStep(stepType, input, ctx.Stack)
|
||||
}
|
||||
|
||||
// CompleteStep marks the current step as completed
|
||||
func (ast *Assistant) CompleteStep(ctx *agentcontext.Context, output map[string]interface{}) {
|
||||
if ctx.Buffer == nil {
|
||||
return
|
||||
}
|
||||
ctx.Buffer.CompleteStep(output)
|
||||
}
|
||||
|
||||
// FlushBuffer saves all buffered data to the database
|
||||
// Should be called in defer block at the end of Stream()
|
||||
func (ast *Assistant) FlushBuffer(ctx *agentcontext.Context, finalStatus string, err error) {
|
||||
if ctx.Buffer == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Only flush for root stack
|
||||
if ctx.Stack == nil || !ctx.Stack.IsRoot() {
|
||||
return
|
||||
}
|
||||
|
||||
// Get chat store
|
||||
chatStore := GetChatStore()
|
||||
if chatStore == nil {
|
||||
ctx.Logger.Error("Chat store not available, cannot flush buffer")
|
||||
return
|
||||
}
|
||||
|
||||
// Mark current step as failed/interrupted if needed
|
||||
if finalStatus != agentcontext.StepStatusCompleted && err != nil {
|
||||
ctx.Buffer.FailCurrentStep(finalStatus, err)
|
||||
}
|
||||
|
||||
// 1. Save all messages (user input + assistant responses)
|
||||
messages := ast.convertBufferedMessages(ctx.Buffer.GetMessages())
|
||||
if len(messages) > 0 {
|
||||
if saveErr := chatStore.SaveMessages(ctx.ChatID, messages); saveErr != nil {
|
||||
ctx.Logger.Error("Failed to save messages: %v", saveErr)
|
||||
} else {
|
||||
ctx.Logger.Debug("Saved %d messages for chat=%s", len(messages), ctx.ChatID)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Update chat last_message_at, last_connector, and last_mode
|
||||
if len(messages) > 0 {
|
||||
now := time.Now()
|
||||
updates := map[string]interface{}{
|
||||
"last_message_at": now,
|
||||
}
|
||||
// Also update last_connector if available
|
||||
if connector := ctx.Buffer.Connector(); connector != "" {
|
||||
updates["last_connector"] = connector
|
||||
}
|
||||
// Also update last_mode if available
|
||||
if mode := ctx.Buffer.Mode(); mode != "" {
|
||||
updates["last_mode"] = mode
|
||||
}
|
||||
if updateErr := chatStore.UpdateChat(ctx.ChatID, updates); updateErr != nil {
|
||||
ctx.Logger.Debug("Failed to update chat: %v", updateErr)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Only save resume steps on error/interrupt (not on success)
|
||||
if finalStatus != agentcontext.StepStatusCompleted {
|
||||
steps := ast.convertBufferedSteps(ctx.Buffer.GetStepsForResume(finalStatus))
|
||||
if len(steps) > 0 {
|
||||
if saveErr := chatStore.SaveResume(steps); saveErr != nil {
|
||||
ctx.Logger.Error("Failed to save resume steps: %v", saveErr)
|
||||
} else {
|
||||
ctx.Logger.Debug("Saved %d resume steps for chat=%s (status=%s)", len(steps), ctx.ChatID, finalStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Close SafeWriter to flush remaining writes (root stack only)
|
||||
// This ensures all pending SSE messages are sent before the response completes
|
||||
ctx.CloseSafeWriter()
|
||||
}
|
||||
|
||||
// convertBufferedMessages converts BufferedMessage slice to store Message slice
|
||||
func (ast *Assistant) convertBufferedMessages(buffered []*agentcontext.BufferedMessage) []*storetypes.Message {
|
||||
if len(buffered) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
messages := make([]*storetypes.Message, len(buffered))
|
||||
for i, msg := range buffered {
|
||||
messages[i] = &storetypes.Message{
|
||||
MessageID: msg.MessageID,
|
||||
ChatID: msg.ChatID,
|
||||
RequestID: msg.RequestID,
|
||||
Role: msg.Role,
|
||||
Type: msg.Type,
|
||||
Props: msg.Props,
|
||||
BlockID: msg.BlockID,
|
||||
ThreadID: msg.ThreadID,
|
||||
AssistantID: msg.AssistantID,
|
||||
Connector: msg.Connector,
|
||||
Mode: msg.Mode,
|
||||
Sequence: msg.Sequence,
|
||||
Metadata: msg.Metadata,
|
||||
CreatedAt: msg.CreatedAt,
|
||||
UpdatedAt: msg.CreatedAt,
|
||||
}
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
// convertBufferedSteps converts BufferedStep slice to store Resume slice
|
||||
func (ast *Assistant) convertBufferedSteps(buffered []*agentcontext.BufferedStep) []*storetypes.Resume {
|
||||
if len(buffered) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
steps := make([]*storetypes.Resume, len(buffered))
|
||||
for i, step := range buffered {
|
||||
steps[i] = &storetypes.Resume{
|
||||
ResumeID: step.ResumeID,
|
||||
ChatID: step.ChatID,
|
||||
RequestID: step.RequestID,
|
||||
AssistantID: step.AssistantID,
|
||||
StackID: step.StackID,
|
||||
StackParentID: step.StackParentID,
|
||||
StackDepth: step.StackDepth,
|
||||
Type: step.Type,
|
||||
Status: step.Status,
|
||||
Input: step.Input,
|
||||
Output: step.Output,
|
||||
SpaceSnapshot: step.SpaceSnapshot,
|
||||
Error: step.Error,
|
||||
Sequence: step.Sequence,
|
||||
Metadata: step.Metadata,
|
||||
CreatedAt: step.CreatedAt,
|
||||
UpdatedAt: step.CreatedAt,
|
||||
}
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
// EnsureChat ensures a chat session exists, creates if not
|
||||
func (ast *Assistant) EnsureChat(ctx *agentcontext.Context) error {
|
||||
if ctx.ChatID == "" {
|
||||
return nil // No chat ID, skip
|
||||
}
|
||||
|
||||
// Skip if history is disabled
|
||||
if ctx.Stack != nil && ctx.Stack.Options != nil && ctx.Stack.Options.Skip != nil && ctx.Stack.Options.Skip.History {
|
||||
return nil // Skip.History is true, don't create chat session
|
||||
}
|
||||
|
||||
chatStore := GetChatStore()
|
||||
if chatStore == nil {
|
||||
return nil // No store, skip
|
||||
}
|
||||
|
||||
// Check if chat exists
|
||||
_, err := chatStore.GetChat(ctx.ChatID)
|
||||
if err == nil {
|
||||
return nil // Chat exists
|
||||
}
|
||||
|
||||
// Create new chat with permission fields
|
||||
chat := &storetypes.Chat{
|
||||
ChatID: ctx.ChatID,
|
||||
AssistantID: ast.ID,
|
||||
Status: "active",
|
||||
Share: "private",
|
||||
Sort: 0,
|
||||
Metadata: ctx.Metadata,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Set last_connector from options (user selected connector)
|
||||
if ctx.Stack != nil && ctx.Stack.Options != nil && ctx.Stack.Options.Connector != "" {
|
||||
chat.LastConnector = ctx.Stack.Options.Connector
|
||||
}
|
||||
|
||||
// Set permission fields from authorized info
|
||||
if ctx.Authorized != nil {
|
||||
chat.CreatedBy = ctx.Authorized.UserID
|
||||
chat.UpdatedBy = ctx.Authorized.UserID
|
||||
chat.TeamID = ctx.Authorized.TeamID
|
||||
chat.TenantID = ctx.Authorized.TenantID
|
||||
}
|
||||
|
||||
return chatStore.CreateChat(chat)
|
||||
}
|
||||
|
||||
// GetChatStore returns the chat store instance
|
||||
// Returns nil if storage is not configured
|
||||
func GetChatStore() storetypes.ChatStore {
|
||||
if storage == nil {
|
||||
return nil
|
||||
}
|
||||
return storage
|
||||
}
|
||||
|
||||
// GetStore returns the full store instance (implements both ChatStore and AssistantStore)
|
||||
// Returns nil if storage is not configured
|
||||
func GetStore() storetypes.Store {
|
||||
if storage == nil {
|
||||
return nil
|
||||
}
|
||||
return storage
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Deprecated methods (kept for compatibility)
|
||||
// =============================================================================
|
||||
|
||||
func (ast *Assistant) saveChat(ctx *agentcontext.Context, input []agentcontext.Message, opts *agentcontext.Options) error {
|
||||
_ = ctx
|
||||
_ = input
|
||||
_ = opts
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,995 +0,0 @@
|
|||
package assistant_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
agentcontext "github.com/yaoapp/yao/agent/context"
|
||||
storetypes "github.com/yaoapp/yao/agent/store/types"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
func TestGetChatKBID(t *testing.T) {
|
||||
t.Skip("KB/DB search temporarily disabled")
|
||||
t.Run("WithTeamAndUser", func(t *testing.T) {
|
||||
teamID := "5659-5504-2879"
|
||||
userID := "4287-9400-2030-0504"
|
||||
|
||||
collectionID := assistant.GetChatKBID(teamID, userID)
|
||||
|
||||
// Should sanitize dashes to underscores
|
||||
expected := "chat_5659_5504_2879_4287_9400_2030_0504"
|
||||
assert.Equal(t, expected, collectionID)
|
||||
t.Logf("✓ Collection ID with team: %s", collectionID)
|
||||
})
|
||||
|
||||
t.Run("WithoutTeam", func(t *testing.T) {
|
||||
teamID := ""
|
||||
userID := "4287-9400-2030-0504"
|
||||
|
||||
collectionID := assistant.GetChatKBID(teamID, userID)
|
||||
|
||||
// Should use chat_user_ prefix
|
||||
expected := "chat_user_4287_9400_2030_0504"
|
||||
assert.Equal(t, expected, collectionID)
|
||||
t.Logf("✓ Collection ID without team: %s", collectionID)
|
||||
})
|
||||
|
||||
t.Run("Idempotent", func(t *testing.T) {
|
||||
teamID := "test-team-123"
|
||||
userID := "test-user-456"
|
||||
|
||||
id1 := assistant.GetChatKBID(teamID, userID)
|
||||
id2 := assistant.GetChatKBID(teamID, userID)
|
||||
id3 := assistant.GetChatKBID(teamID, userID)
|
||||
|
||||
// Same input should always produce same output
|
||||
assert.Equal(t, id1, id2)
|
||||
assert.Equal(t, id2, id3)
|
||||
t.Logf("✓ Idempotent: %s", id1)
|
||||
})
|
||||
|
||||
t.Run("SanitizeSpecialChars", func(t *testing.T) {
|
||||
teamID := "team-with-dashes@123"
|
||||
userID := "user.with.dots!"
|
||||
|
||||
collectionID := assistant.GetChatKBID(teamID, userID)
|
||||
|
||||
// Should only contain alphanumeric and underscores
|
||||
assert.Regexp(t, "^[a-zA-Z0-9_]+$", collectionID)
|
||||
t.Logf("✓ Sanitized ID: %s", collectionID)
|
||||
})
|
||||
|
||||
t.Run("EmptyUserID", func(t *testing.T) {
|
||||
teamID := "test-team"
|
||||
userID := ""
|
||||
|
||||
collectionID := assistant.GetChatKBID(teamID, userID)
|
||||
|
||||
// Should handle empty user ID gracefully
|
||||
expected := "chat_test_team_"
|
||||
assert.Equal(t, expected, collectionID)
|
||||
t.Logf("✓ Empty user ID handled: %s", collectionID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPrepareKBCollection(t *testing.T) {
|
||||
t.Skip("KB/DB search temporarily disabled")
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
// Get assistant
|
||||
ast, err := assistant.Get("mohe")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
// Note: KB collection is now created during user login (see openapi/user/login.go)
|
||||
// These tests verify that InitializeConversation handles various scenarios gracefully
|
||||
|
||||
t.Run("InitializeWithAuthorizedInfo", func(t *testing.T) {
|
||||
// Use unique IDs based on timestamp to avoid conflicts
|
||||
timestamp := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
teamID := fmt.Sprintf("test_team_%s", timestamp)
|
||||
userID := fmt.Sprintf("test_user_%s", timestamp)
|
||||
|
||||
ctx := agentcontext.New(context.Background(), &oauthtypes.AuthorizedInfo{
|
||||
TeamID: teamID,
|
||||
UserID: userID,
|
||||
}, "test_chat_prepare_001")
|
||||
|
||||
opts := &agentcontext.Options{}
|
||||
|
||||
// InitializeConversation should succeed (KB collection created at login time)
|
||||
err := ast.InitializeConversation(ctx, opts)
|
||||
assert.NoError(t, err)
|
||||
t.Logf("✓ InitializeConversation completed successfully")
|
||||
})
|
||||
|
||||
t.Run("IdempotentInitialization", func(t *testing.T) {
|
||||
// Use unique IDs based on timestamp to avoid conflicts
|
||||
timestamp := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
teamID := fmt.Sprintf("idem_team_%s", timestamp)
|
||||
userID := fmt.Sprintf("idem_user_%s", timestamp)
|
||||
|
||||
ctx := agentcontext.New(context.Background(), &oauthtypes.AuthorizedInfo{
|
||||
TeamID: teamID,
|
||||
UserID: userID,
|
||||
}, "test_chat_idempotent")
|
||||
|
||||
opts := &agentcontext.Options{}
|
||||
|
||||
// Multiple calls should all succeed
|
||||
err1 := ast.InitializeConversation(ctx, opts)
|
||||
assert.NoError(t, err1)
|
||||
|
||||
err2 := ast.InitializeConversation(ctx, opts)
|
||||
assert.NoError(t, err2)
|
||||
|
||||
err3 := ast.InitializeConversation(ctx, opts)
|
||||
assert.NoError(t, err3)
|
||||
|
||||
t.Logf("✓ Idempotent initialization works correctly")
|
||||
})
|
||||
|
||||
t.Run("HandleMissingAuthorizedInfo", func(t *testing.T) {
|
||||
ctx := agentcontext.New(context.Background(), nil, "test_chat_no_auth") // Missing authorized info
|
||||
|
||||
opts := &agentcontext.Options{}
|
||||
|
||||
// Should not error, just return nil
|
||||
err := ast.InitializeConversation(ctx, opts)
|
||||
assert.NoError(t, err)
|
||||
t.Logf("✓ Correctly handled missing authorized info")
|
||||
})
|
||||
|
||||
t.Run("ConcurrentInitialization", func(t *testing.T) {
|
||||
// Use unique IDs based on timestamp to avoid conflicts
|
||||
timestamp := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
teamID := fmt.Sprintf("concurrent_team_%s", timestamp)
|
||||
userID := fmt.Sprintf("concurrent_user_%s", timestamp)
|
||||
|
||||
ctx := agentcontext.New(context.Background(), &oauthtypes.AuthorizedInfo{
|
||||
TeamID: teamID,
|
||||
UserID: userID,
|
||||
}, "test_chat_concurrent")
|
||||
|
||||
opts := &agentcontext.Options{}
|
||||
|
||||
// Launch 5 concurrent calls
|
||||
var wg sync.WaitGroup
|
||||
errors := make([]error, 5)
|
||||
for i := 0; i < 5; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
errors[idx] = ast.InitializeConversation(ctx, opts)
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all goroutines to complete
|
||||
wg.Wait()
|
||||
|
||||
// All calls should succeed
|
||||
for i, err := range errors {
|
||||
assert.NoError(t, err, "Goroutine %d should not error", i)
|
||||
}
|
||||
|
||||
t.Logf("✓ Concurrent initialization handled correctly")
|
||||
})
|
||||
}
|
||||
|
||||
func TestInitializeConversation(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ast, err := assistant.Get("mohe")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
t.Run("FullInitialization", func(t *testing.T) {
|
||||
// Use unique IDs based on timestamp to avoid conflicts
|
||||
timestamp := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
teamID := fmt.Sprintf("init_team_%s", timestamp)
|
||||
userID := fmt.Sprintf("init_user_%s", timestamp)
|
||||
|
||||
ctx := agentcontext.New(context.Background(), &oauthtypes.AuthorizedInfo{
|
||||
TeamID: teamID,
|
||||
UserID: userID,
|
||||
}, "test_init_chat_001")
|
||||
|
||||
opts := &agentcontext.Options{}
|
||||
|
||||
// Should initialize conversation without error
|
||||
// Note: KB collection is now created during user login, not here
|
||||
err := ast.InitializeConversation(ctx, opts)
|
||||
assert.NoError(t, err)
|
||||
t.Logf("✓ Conversation initialized successfully (KB collection created at login time)")
|
||||
})
|
||||
|
||||
t.Run("SkipHistoryFlag", func(t *testing.T) {
|
||||
ctx := agentcontext.New(context.Background(), &oauthtypes.AuthorizedInfo{
|
||||
TeamID: "skip_team",
|
||||
UserID: "skip_user",
|
||||
}, "test_skip_history")
|
||||
|
||||
opts := &agentcontext.Options{
|
||||
Skip: &agentcontext.Skip{
|
||||
History: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Should skip initialization when history flag is set
|
||||
err := ast.InitializeConversation(ctx, opts)
|
||||
assert.NoError(t, err)
|
||||
t.Logf("✓ Correctly skipped with history flag")
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Buffer Integration Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestBufferInitialization(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ast, err := assistant.Get("mohe")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
t.Run("InitBufferForRootStack", func(t *testing.T) {
|
||||
ctx := agentcontext.New(context.Background(), nil, "test_chat_buffer_001")
|
||||
|
||||
// Enter stack to simulate root stack
|
||||
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
|
||||
defer done()
|
||||
|
||||
// Initialize buffer
|
||||
ast.InitBuffer(ctx)
|
||||
|
||||
// Verify buffer was created
|
||||
assert.NotNil(t, ctx.Buffer, "Buffer should be initialized for root stack")
|
||||
assert.Equal(t, "test_chat_buffer_001", ctx.Buffer.ChatID())
|
||||
assert.Equal(t, ast.ID, ctx.Buffer.AssistantID())
|
||||
t.Logf("✓ Buffer initialized: chatID=%s, assistantID=%s", ctx.Buffer.ChatID(), ctx.Buffer.AssistantID())
|
||||
})
|
||||
|
||||
t.Run("SkipBufferForNestedStack", func(t *testing.T) {
|
||||
ctx := agentcontext.New(context.Background(), nil, "test_chat_buffer_nested")
|
||||
|
||||
// Enter root stack
|
||||
_, _, doneRoot := agentcontext.EnterStack(ctx, "root_assistant", nil)
|
||||
defer doneRoot()
|
||||
|
||||
// Enter nested stack
|
||||
_, _, doneNested := agentcontext.EnterStack(ctx, "nested_assistant", nil)
|
||||
defer doneNested()
|
||||
|
||||
// Try to initialize buffer (should be skipped for nested stack)
|
||||
ast.InitBuffer(ctx)
|
||||
|
||||
// Buffer should be nil because we're not at root
|
||||
assert.Nil(t, ctx.Buffer, "Buffer should not be initialized for nested stack")
|
||||
t.Logf("✓ Buffer correctly skipped for nested stack")
|
||||
})
|
||||
|
||||
t.Run("IdempotentBufferInit", func(t *testing.T) {
|
||||
ctx := agentcontext.New(context.Background(), nil, "test_chat_buffer_idem")
|
||||
|
||||
// Enter stack
|
||||
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
|
||||
defer done()
|
||||
|
||||
// Initialize buffer twice
|
||||
ast.InitBuffer(ctx)
|
||||
firstBuffer := ctx.Buffer
|
||||
|
||||
ast.InitBuffer(ctx)
|
||||
secondBuffer := ctx.Buffer
|
||||
|
||||
// Should be the same buffer instance
|
||||
assert.Same(t, firstBuffer, secondBuffer, "Buffer should be idempotent")
|
||||
t.Logf("✓ Buffer initialization is idempotent")
|
||||
})
|
||||
}
|
||||
|
||||
func TestBufferUserInput(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ast, err := assistant.Get("mohe")
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("BufferSimpleTextInput", func(t *testing.T) {
|
||||
ctx := agentcontext.New(context.Background(), nil, "test_chat_input_001")
|
||||
|
||||
// Enter stack and init buffer
|
||||
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
|
||||
defer done()
|
||||
ast.InitBuffer(ctx)
|
||||
|
||||
// Create input messages
|
||||
inputMessages := []agentcontext.Message{
|
||||
{
|
||||
Role: agentcontext.RoleUser,
|
||||
Content: "Hello, how are you?",
|
||||
},
|
||||
}
|
||||
|
||||
// Buffer user input
|
||||
ast.BufferUserInput(ctx, inputMessages)
|
||||
|
||||
// Verify buffer contains the message
|
||||
messages := ctx.Buffer.GetMessages()
|
||||
assert.Len(t, messages, 1, "Should have 1 buffered message")
|
||||
assert.Equal(t, "user", messages[0].Role)
|
||||
assert.Equal(t, "user_input", messages[0].Type)
|
||||
assert.Equal(t, "Hello, how are you?", messages[0].Props["content"])
|
||||
t.Logf("✓ User input buffered: %v", messages[0].Props)
|
||||
})
|
||||
|
||||
t.Run("BufferMultipleMessages", func(t *testing.T) {
|
||||
ctx := agentcontext.New(context.Background(), nil, "test_chat_input_multi")
|
||||
|
||||
// Enter stack and init buffer
|
||||
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
|
||||
defer done()
|
||||
ast.InitBuffer(ctx)
|
||||
|
||||
// Create multiple input messages
|
||||
inputMessages := []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: "First message"},
|
||||
{Role: agentcontext.RoleUser, Content: "Second message"},
|
||||
}
|
||||
|
||||
// Buffer user input
|
||||
ast.BufferUserInput(ctx, inputMessages)
|
||||
|
||||
// Verify buffer contains all messages
|
||||
messages := ctx.Buffer.GetMessages()
|
||||
assert.Len(t, messages, 2, "Should have 2 buffered messages")
|
||||
assert.Equal(t, 1, messages[0].Sequence)
|
||||
assert.Equal(t, 2, messages[1].Sequence)
|
||||
t.Logf("✓ Multiple messages buffered with correct sequence")
|
||||
})
|
||||
|
||||
t.Run("BufferWithNilBuffer", func(t *testing.T) {
|
||||
ctx := agentcontext.New(context.Background(), nil, "test_chat_input_nil")
|
||||
|
||||
// Don't initialize buffer
|
||||
inputMessages := []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: "Test"},
|
||||
}
|
||||
|
||||
// Should not panic
|
||||
ast.BufferUserInput(ctx, inputMessages)
|
||||
t.Logf("✓ BufferUserInput handles nil buffer gracefully")
|
||||
})
|
||||
}
|
||||
|
||||
func TestBufferStepTracking(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ast, err := assistant.Get("mohe")
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("BeginAndCompleteStep", func(t *testing.T) {
|
||||
ctx := agentcontext.New(context.Background(), nil, "test_chat_step_001")
|
||||
|
||||
// Enter stack and init buffer
|
||||
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
|
||||
defer done()
|
||||
ast.InitBuffer(ctx)
|
||||
|
||||
// Set some context memory data
|
||||
if ctx.Memory != nil && ctx.Memory.Context != nil {
|
||||
ctx.Memory.Context.Set("test_key", "test_value", 0)
|
||||
}
|
||||
|
||||
// Begin a step
|
||||
step := ast.BeginStep(ctx, agentcontext.StepTypeLLM, map[string]interface{}{
|
||||
"messages": []string{"Hello"},
|
||||
})
|
||||
|
||||
assert.NotNil(t, step, "Step should be created")
|
||||
assert.Equal(t, agentcontext.StepTypeLLM, step.Type)
|
||||
assert.Equal(t, agentcontext.StepStatusRunning, step.Status)
|
||||
assert.NotEmpty(t, step.StackID)
|
||||
|
||||
// Complete the step
|
||||
ast.CompleteStep(ctx, map[string]interface{}{
|
||||
"content": "Response",
|
||||
})
|
||||
|
||||
// Verify step is completed
|
||||
steps := ctx.Buffer.GetAllSteps()
|
||||
assert.Len(t, steps, 1)
|
||||
assert.Equal(t, agentcontext.StepStatusCompleted, steps[0].Status)
|
||||
assert.Equal(t, "Response", steps[0].Output["content"])
|
||||
t.Logf("✓ Step tracking works correctly")
|
||||
})
|
||||
|
||||
t.Run("ContextMemorySnapshotCapture", func(t *testing.T) {
|
||||
ctx := agentcontext.New(context.Background(), nil, "test_chat_memory_001")
|
||||
|
||||
// Enter stack and init buffer
|
||||
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
|
||||
defer done()
|
||||
ast.InitBuffer(ctx)
|
||||
|
||||
// Set context memory data before step
|
||||
require.NotNil(t, ctx.Memory)
|
||||
require.NotNil(t, ctx.Memory.Context)
|
||||
ctx.Memory.Context.Set("key1", "value1", 0)
|
||||
ctx.Memory.Context.Set("key2", 123, 0)
|
||||
|
||||
// Begin step (should capture context memory snapshot)
|
||||
ast.BeginStep(ctx, agentcontext.StepTypeHookCreate, nil)
|
||||
|
||||
// Verify context memory snapshot was captured
|
||||
steps := ctx.Buffer.GetAllSteps()
|
||||
require.Len(t, steps, 1)
|
||||
assert.NotNil(t, steps[0].SpaceSnapshot)
|
||||
assert.Equal(t, "value1", steps[0].SpaceSnapshot["key1"])
|
||||
assert.Equal(t, 123, steps[0].SpaceSnapshot["key2"])
|
||||
t.Logf("✓ Context memory snapshot captured: %v", steps[0].SpaceSnapshot)
|
||||
})
|
||||
|
||||
t.Run("MultipleSteps", func(t *testing.T) {
|
||||
ctx := agentcontext.New(context.Background(), nil, "test_chat_multi_step")
|
||||
|
||||
// Enter stack and init buffer
|
||||
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
|
||||
defer done()
|
||||
ast.InitBuffer(ctx)
|
||||
|
||||
// Step 1: hook_create
|
||||
ast.BeginStep(ctx, agentcontext.StepTypeHookCreate, map[string]interface{}{"phase": "create"})
|
||||
ast.CompleteStep(ctx, map[string]interface{}{"result": "created"})
|
||||
|
||||
// Step 2: llm
|
||||
ast.BeginStep(ctx, agentcontext.StepTypeLLM, map[string]interface{}{"phase": "llm"})
|
||||
ast.CompleteStep(ctx, map[string]interface{}{"result": "completed"})
|
||||
|
||||
// Step 3: hook_next
|
||||
ast.BeginStep(ctx, agentcontext.StepTypeHookNext, map[string]interface{}{"phase": "next"})
|
||||
ast.CompleteStep(ctx, map[string]interface{}{"result": "done"})
|
||||
|
||||
// Verify all steps
|
||||
steps := ctx.Buffer.GetAllSteps()
|
||||
assert.Len(t, steps, 3)
|
||||
assert.Equal(t, agentcontext.StepTypeHookCreate, steps[0].Type)
|
||||
assert.Equal(t, agentcontext.StepTypeLLM, steps[1].Type)
|
||||
assert.Equal(t, agentcontext.StepTypeHookNext, steps[2].Type)
|
||||
t.Logf("✓ Multiple steps tracked correctly")
|
||||
})
|
||||
}
|
||||
|
||||
func TestFlushBuffer(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ast, err := assistant.Get("mohe")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Skip if chat store not available
|
||||
chatStore := assistant.GetChatStore()
|
||||
if chatStore == nil {
|
||||
t.Skip("Chat store not configured, skipping flush tests")
|
||||
}
|
||||
|
||||
t.Run("FlushOnSuccess", func(t *testing.T) {
|
||||
chatID := fmt.Sprintf("test_flush_success_%s", uuid.New().String()[:8])
|
||||
ctx := agentcontext.New(context.Background(), nil, chatID)
|
||||
|
||||
// Enter stack and init buffer
|
||||
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
|
||||
defer done()
|
||||
ast.InitBuffer(ctx)
|
||||
|
||||
// Ensure chat exists
|
||||
err := chatStore.CreateChat(&storetypes.Chat{
|
||||
ChatID: chatID,
|
||||
AssistantID: ast.ID,
|
||||
Status: "active",
|
||||
Share: "private",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add some messages to buffer
|
||||
require.NotNil(t, ctx.Buffer, "Buffer should be initialized")
|
||||
ctx.Buffer.AddUserInput("Test question", "")
|
||||
ctx.Buffer.AddAssistantMessage("M1", "text", map[string]interface{}{"content": "Test answer"}, "", "", ast.ID, nil)
|
||||
|
||||
// Add a step
|
||||
ast.BeginStep(ctx, agentcontext.StepTypeLLM, nil)
|
||||
ast.CompleteStep(ctx, nil)
|
||||
|
||||
// Flush buffer (success case)
|
||||
ast.FlushBuffer(ctx, agentcontext.StepStatusCompleted, nil)
|
||||
|
||||
// Verify messages were saved
|
||||
messages, err := chatStore.GetMessages(chatID, storetypes.MessageFilter{})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, messages, 2, "Should have 2 messages saved")
|
||||
|
||||
// Verify no resume records (success case)
|
||||
resumes, err := chatStore.GetResume(chatID)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, resumes, 0, "Should have no resume records on success")
|
||||
|
||||
// Cleanup
|
||||
chatStore.DeleteChat(chatID)
|
||||
t.Logf("✓ Buffer flushed on success: %d messages saved, no resume records", len(messages))
|
||||
})
|
||||
|
||||
t.Run("FlushOnFailure", func(t *testing.T) {
|
||||
chatID := fmt.Sprintf("test_flush_fail_%s", uuid.New().String()[:8])
|
||||
ctx := agentcontext.New(context.Background(), nil, chatID)
|
||||
|
||||
// Enter stack and init buffer
|
||||
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
|
||||
defer done()
|
||||
ast.InitBuffer(ctx)
|
||||
|
||||
// Ensure chat exists
|
||||
err := chatStore.CreateChat(&storetypes.Chat{
|
||||
ChatID: chatID,
|
||||
AssistantID: ast.ID,
|
||||
Status: "active",
|
||||
Share: "private",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add messages
|
||||
ctx.Buffer.AddUserInput("Test question", "")
|
||||
|
||||
// Add a step that will "fail"
|
||||
ast.BeginStep(ctx, agentcontext.StepTypeLLM, map[string]interface{}{"test": "data"})
|
||||
// Don't complete - simulate failure
|
||||
|
||||
// Flush buffer (failure case)
|
||||
testErr := fmt.Errorf("simulated error")
|
||||
ast.FlushBuffer(ctx, agentcontext.ResumeStatusFailed, testErr)
|
||||
|
||||
// Verify messages were saved
|
||||
messages, err := chatStore.GetMessages(chatID, storetypes.MessageFilter{})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, messages, 1, "Should have 1 message saved")
|
||||
|
||||
// Verify resume records were saved
|
||||
resumes, err := chatStore.GetResume(chatID)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, resumes, 1, "Should have 1 resume record on failure")
|
||||
assert.Equal(t, agentcontext.ResumeStatusFailed, resumes[0].Status)
|
||||
|
||||
// Cleanup
|
||||
chatStore.DeleteResume(chatID)
|
||||
chatStore.DeleteChat(chatID)
|
||||
t.Logf("✓ Buffer flushed on failure: messages and resume records saved")
|
||||
})
|
||||
|
||||
t.Run("FlushOnInterrupt", func(t *testing.T) {
|
||||
chatID := fmt.Sprintf("test_flush_interrupt_%s", uuid.New().String()[:8])
|
||||
ctx := agentcontext.New(context.Background(), nil, chatID)
|
||||
|
||||
// Enter stack and init buffer
|
||||
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
|
||||
defer done()
|
||||
ast.InitBuffer(ctx)
|
||||
|
||||
// Ensure chat exists
|
||||
err := chatStore.CreateChat(&storetypes.Chat{
|
||||
ChatID: chatID,
|
||||
AssistantID: ast.ID,
|
||||
Status: "active",
|
||||
Share: "private",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add messages and steps
|
||||
ctx.Buffer.AddUserInput("Test question", "")
|
||||
ast.BeginStep(ctx, agentcontext.StepTypeLLM, nil)
|
||||
|
||||
// Flush buffer (interrupt case)
|
||||
ast.FlushBuffer(ctx, agentcontext.ResumeStatusInterrupted, nil)
|
||||
|
||||
// Verify resume records were saved with interrupted status
|
||||
resumes, err := chatStore.GetResume(chatID)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, resumes, 1, "Should have 1 resume record on interrupt")
|
||||
assert.Equal(t, agentcontext.ResumeStatusInterrupted, resumes[0].Status)
|
||||
|
||||
// Cleanup
|
||||
chatStore.DeleteResume(chatID)
|
||||
chatStore.DeleteChat(chatID)
|
||||
t.Logf("✓ Buffer flushed on interrupt: resume records saved with interrupted status")
|
||||
})
|
||||
|
||||
t.Run("FlushWithModeAndConnector", func(t *testing.T) {
|
||||
chatID := fmt.Sprintf("test_flush_mode_%s", uuid.New().String()[:8])
|
||||
ctx := agentcontext.New(context.Background(), nil, chatID)
|
||||
|
||||
// Enter stack with connector and mode options
|
||||
opts := &agentcontext.Options{
|
||||
Connector: "deepseek.v3",
|
||||
Mode: "task",
|
||||
}
|
||||
_, _, done := agentcontext.EnterStack(ctx, ast.ID, opts)
|
||||
defer done()
|
||||
ast.InitBuffer(ctx)
|
||||
|
||||
// Verify buffer has correct connector and mode
|
||||
require.NotNil(t, ctx.Buffer, "Buffer should be initialized")
|
||||
assert.Equal(t, "deepseek.v3", ctx.Buffer.Connector(), "Buffer should have connector set")
|
||||
assert.Equal(t, "task", ctx.Buffer.Mode(), "Buffer should have mode set")
|
||||
|
||||
// Ensure chat exists
|
||||
err := chatStore.CreateChat(&storetypes.Chat{
|
||||
ChatID: chatID,
|
||||
AssistantID: ast.ID,
|
||||
Status: "active",
|
||||
Share: "private",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add some messages to buffer
|
||||
ctx.Buffer.AddUserInput("Test question for mode", "")
|
||||
ctx.Buffer.AddAssistantMessage("M1", "text", map[string]interface{}{"content": "Test answer with mode"}, "", "", ast.ID, nil)
|
||||
|
||||
// Flush buffer
|
||||
ast.FlushBuffer(ctx, agentcontext.StepStatusCompleted, nil)
|
||||
|
||||
// Verify messages were saved with connector and mode
|
||||
messages, err := chatStore.GetMessages(chatID, storetypes.MessageFilter{})
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, messages, 2, "Should have 2 messages saved")
|
||||
|
||||
// Assistant message should have connector and mode
|
||||
var assistantMsg *storetypes.Message
|
||||
for _, msg := range messages {
|
||||
if msg.Role == "assistant" {
|
||||
assistantMsg = msg
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NotNil(t, assistantMsg, "Should find assistant message")
|
||||
assert.Equal(t, "deepseek.v3", assistantMsg.Connector, "Message should have connector")
|
||||
assert.Equal(t, "task", assistantMsg.Mode, "Message should have mode")
|
||||
|
||||
// Verify chat was updated with last_connector and last_mode
|
||||
chat, err := chatStore.GetChat(chatID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "deepseek.v3", chat.LastConnector, "Chat should have last_connector updated")
|
||||
assert.Equal(t, "task", chat.LastMode, "Chat should have last_mode updated")
|
||||
|
||||
// Cleanup
|
||||
chatStore.DeleteChat(chatID)
|
||||
t.Logf("✓ Buffer flushed with mode and connector: connector=%s, mode=%s", chat.LastConnector, chat.LastMode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEnsureChat(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ast, err := assistant.Get("mohe")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Skip if chat store not available
|
||||
chatStore := assistant.GetChatStore()
|
||||
if chatStore == nil {
|
||||
t.Skip("Chat store not configured, skipping EnsureChat tests")
|
||||
}
|
||||
|
||||
t.Run("CreateNewChat", func(t *testing.T) {
|
||||
chatID := fmt.Sprintf("test_ensure_new_%s", uuid.New().String()[:8])
|
||||
ctx := agentcontext.New(context.Background(), nil, chatID)
|
||||
|
||||
// Ensure chat creates it
|
||||
err := ast.EnsureChat(ctx)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify chat was created
|
||||
chat, err := chatStore.GetChat(chatID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, chat)
|
||||
assert.Equal(t, chatID, chat.ChatID)
|
||||
assert.Equal(t, ast.ID, chat.AssistantID)
|
||||
assert.Equal(t, "active", chat.Status)
|
||||
|
||||
// Cleanup
|
||||
chatStore.DeleteChat(chatID)
|
||||
t.Logf("✓ New chat created: %s", chatID)
|
||||
})
|
||||
|
||||
t.Run("SkipExistingChat", func(t *testing.T) {
|
||||
chatID := fmt.Sprintf("test_ensure_exist_%s", uuid.New().String()[:8])
|
||||
ctx := agentcontext.New(context.Background(), nil, chatID)
|
||||
|
||||
// Create chat first
|
||||
err := chatStore.CreateChat(&storetypes.Chat{
|
||||
ChatID: chatID,
|
||||
AssistantID: ast.ID,
|
||||
Title: "Existing Chat",
|
||||
Status: "active",
|
||||
Share: "private",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// EnsureChat should not error
|
||||
err = ast.EnsureChat(ctx)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify chat still has original title
|
||||
chat, err := chatStore.GetChat(chatID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Existing Chat", chat.Title)
|
||||
|
||||
// Cleanup
|
||||
chatStore.DeleteChat(chatID)
|
||||
t.Logf("✓ Existing chat preserved")
|
||||
})
|
||||
|
||||
t.Run("SkipEmptyChatID", func(t *testing.T) {
|
||||
ctx := agentcontext.New(context.Background(), nil, "")
|
||||
|
||||
// Should not error with empty chat ID
|
||||
err := ast.EnsureChat(ctx)
|
||||
assert.NoError(t, err)
|
||||
t.Logf("✓ Empty chat ID handled gracefully")
|
||||
})
|
||||
|
||||
t.Run("CreateChatWithPermissions", func(t *testing.T) {
|
||||
chatID := fmt.Sprintf("test_ensure_perm_%s", uuid.New().String()[:8])
|
||||
|
||||
// Create context with authorized info
|
||||
ctx := agentcontext.New(context.Background(), &oauthtypes.AuthorizedInfo{
|
||||
UserID: "test_user_001",
|
||||
TeamID: "test_team_001",
|
||||
TenantID: "test_tenant_001",
|
||||
}, chatID)
|
||||
|
||||
// EnsureChat should create with permission fields
|
||||
err := ast.EnsureChat(ctx)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify permission fields were saved
|
||||
chat, err := chatStore.GetChat(chatID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, chat)
|
||||
assert.Equal(t, "test_user_001", chat.CreatedBy, "CreatedBy should be set")
|
||||
assert.Equal(t, "test_user_001", chat.UpdatedBy, "UpdatedBy should be set")
|
||||
assert.Equal(t, "test_team_001", chat.TeamID, "TeamID should be set")
|
||||
assert.Equal(t, "test_tenant_001", chat.TenantID, "TenantID should be set")
|
||||
|
||||
// Cleanup
|
||||
chatStore.DeleteChat(chatID)
|
||||
t.Logf("✓ Chat created with permission fields: user=%s, team=%s, tenant=%s",
|
||||
chat.CreatedBy, chat.TeamID, chat.TenantID)
|
||||
})
|
||||
|
||||
t.Run("SkipHistoryEnabled", func(t *testing.T) {
|
||||
chatID := fmt.Sprintf("test_ensure_skip_%s", uuid.New().String()[:8])
|
||||
|
||||
// Create context
|
||||
ctx := agentcontext.New(context.Background(), nil, chatID)
|
||||
|
||||
// Set up stack with Skip.History = true
|
||||
ctx.Stack = &agentcontext.Stack{
|
||||
ID: "test_stack",
|
||||
AssistantID: ast.ID,
|
||||
Depth: 0,
|
||||
Options: &agentcontext.Options{
|
||||
Skip: &agentcontext.Skip{
|
||||
History: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// EnsureChat should NOT create chat when Skip.History is true
|
||||
err := ast.EnsureChat(ctx)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify chat was NOT created
|
||||
_, err = chatStore.GetChat(chatID)
|
||||
assert.Error(t, err, "Chat should not be created when Skip.History is true")
|
||||
t.Logf("✓ Chat not created when Skip.History is true")
|
||||
})
|
||||
}
|
||||
|
||||
// TestEnsureChatMetadata verifies that ctx.Metadata is persisted to the chat record.
|
||||
// This is required for Host Agent: robot_id is passed in metadata so that
|
||||
// ListChats with chat_id_prefix=robot_{id}_ can filter by robot.
|
||||
func TestEnsureChatMetadata(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ast, err := assistant.Get("mohe")
|
||||
require.NoError(t, err)
|
||||
|
||||
chatStore := assistant.GetChatStore()
|
||||
if chatStore == nil {
|
||||
t.Skip("Chat store not configured, skipping metadata tests")
|
||||
}
|
||||
|
||||
t.Run("MetadataPersisted", func(t *testing.T) {
|
||||
chatID := fmt.Sprintf("robot_test_meta_%s", uuid.New().String()[:8])
|
||||
ctx := agentcontext.New(context.Background(), &oauthtypes.AuthorizedInfo{
|
||||
UserID: "test_user_meta",
|
||||
TeamID: "test_team_meta",
|
||||
}, chatID)
|
||||
ctx.Metadata = map[string]interface{}{
|
||||
"robot_id": "robot_member_001",
|
||||
}
|
||||
|
||||
err := ast.EnsureChat(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
chat, err := chatStore.GetChat(chatID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, chat)
|
||||
require.NotNil(t, chat.Metadata, "Metadata should be persisted")
|
||||
assert.Equal(t, "robot_member_001", chat.Metadata["robot_id"],
|
||||
"robot_id should be stored in chat metadata")
|
||||
|
||||
// Cleanup
|
||||
chatStore.DeleteChat(chatID)
|
||||
t.Logf("✓ Chat metadata persisted: robot_id=%v", chat.Metadata["robot_id"])
|
||||
})
|
||||
|
||||
t.Run("MetadataPersistedWithRobotChatIDPrefix", func(t *testing.T) {
|
||||
// Simulate robot host chat_id format: robot_{member_id}_{timestamp}
|
||||
memberID := "120004485525"
|
||||
chatID := fmt.Sprintf("robot_%s_%d", memberID, time.Now().UnixMilli())
|
||||
ctx := agentcontext.New(context.Background(), &oauthtypes.AuthorizedInfo{
|
||||
UserID: "test_user_robot",
|
||||
TeamID: "test_team_robot",
|
||||
}, chatID)
|
||||
ctx.Metadata = map[string]interface{}{
|
||||
"robot_id": memberID,
|
||||
}
|
||||
|
||||
err := ast.EnsureChat(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
chat, err := chatStore.GetChat(chatID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, chat)
|
||||
require.NotNil(t, chat.Metadata)
|
||||
assert.Equal(t, memberID, chat.Metadata["robot_id"])
|
||||
|
||||
// Cleanup
|
||||
chatStore.DeleteChat(chatID)
|
||||
t.Logf("✓ Robot-prefix chat persisted with metadata: chat_id=%s", chatID)
|
||||
})
|
||||
|
||||
t.Run("NilMetadataHandled", func(t *testing.T) {
|
||||
chatID := fmt.Sprintf("test_meta_nil_%s", uuid.New().String()[:8])
|
||||
ctx := agentcontext.New(context.Background(), nil, chatID)
|
||||
ctx.Metadata = nil
|
||||
|
||||
err := ast.EnsureChat(ctx)
|
||||
assert.NoError(t, err)
|
||||
|
||||
chat, err := chatStore.GetChat(chatID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, chat)
|
||||
// Metadata nil is acceptable
|
||||
t.Logf("✓ Nil metadata handled gracefully")
|
||||
|
||||
// Cleanup
|
||||
chatStore.DeleteChat(chatID)
|
||||
})
|
||||
|
||||
t.Run("MetadataMultipleFields", func(t *testing.T) {
|
||||
chatID := fmt.Sprintf("test_meta_multi_%s", uuid.New().String()[:8])
|
||||
ctx := agentcontext.New(context.Background(), &oauthtypes.AuthorizedInfo{
|
||||
UserID: "test_user_multi",
|
||||
TeamID: "test_team_multi",
|
||||
}, chatID)
|
||||
ctx.Metadata = map[string]interface{}{
|
||||
"robot_id": "robot_multi_001",
|
||||
"source": "mission_control",
|
||||
}
|
||||
|
||||
err := ast.EnsureChat(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
chat, err := chatStore.GetChat(chatID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, chat)
|
||||
require.NotNil(t, chat.Metadata)
|
||||
assert.Equal(t, "robot_multi_001", chat.Metadata["robot_id"])
|
||||
assert.Equal(t, "mission_control", chat.Metadata["source"])
|
||||
|
||||
// Cleanup
|
||||
chatStore.DeleteChat(chatID)
|
||||
t.Logf("✓ Multiple metadata fields persisted correctly")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConvertBufferedTypes(t *testing.T) {
|
||||
t.Run("ConvertBufferedMessages", func(t *testing.T) {
|
||||
// Create buffered messages
|
||||
buffered := []*agentcontext.BufferedMessage{
|
||||
{
|
||||
MessageID: "msg_001",
|
||||
ChatID: "chat_001",
|
||||
RequestID: "req_001",
|
||||
Role: "user",
|
||||
Type: "user_input",
|
||||
Props: map[string]interface{}{"content": "Hello"},
|
||||
Sequence: 1,
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
{
|
||||
MessageID: "msg_002",
|
||||
ChatID: "chat_001",
|
||||
RequestID: "req_001",
|
||||
Role: "assistant",
|
||||
Type: "text",
|
||||
Props: map[string]interface{}{"content": "Hi there!"},
|
||||
BlockID: "block_001",
|
||||
AssistantID: "test_assistant",
|
||||
Sequence: 2,
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
// Verify structure matches store types
|
||||
assert.Len(t, buffered, 2)
|
||||
assert.Equal(t, "user", buffered[0].Role)
|
||||
assert.Equal(t, "assistant", buffered[1].Role)
|
||||
assert.Equal(t, "block_001", buffered[1].BlockID)
|
||||
t.Logf("✓ Buffered messages have correct structure")
|
||||
})
|
||||
|
||||
t.Run("ConvertBufferedSteps", func(t *testing.T) {
|
||||
// Create buffered steps
|
||||
buffered := []*agentcontext.BufferedStep{
|
||||
{
|
||||
ResumeID: "resume_001",
|
||||
ChatID: "chat_001",
|
||||
RequestID: "req_001",
|
||||
AssistantID: "test_assistant",
|
||||
StackID: "stack_001",
|
||||
StackDepth: 0,
|
||||
Type: agentcontext.StepTypeLLM,
|
||||
Status: agentcontext.ResumeStatusFailed,
|
||||
Input: map[string]interface{}{"messages": []string{"Hello"}},
|
||||
SpaceSnapshot: map[string]interface{}{"key": "value"},
|
||||
Error: "Test error",
|
||||
Sequence: 1,
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
}
|
||||
|
||||
// Verify structure
|
||||
assert.Len(t, buffered, 1)
|
||||
assert.Equal(t, agentcontext.StepTypeLLM, buffered[0].Type)
|
||||
assert.Equal(t, agentcontext.ResumeStatusFailed, buffered[0].Status)
|
||||
assert.Equal(t, "Test error", buffered[0].Error)
|
||||
assert.Equal(t, "value", buffered[0].SpaceSnapshot["key"])
|
||||
t.Logf("✓ Buffered steps have correct structure")
|
||||
})
|
||||
}
|
||||
|
|
@ -1,513 +0,0 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
"github.com/yaoapp/kun/log"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/i18n"
|
||||
"github.com/yaoapp/yao/agent/output"
|
||||
"github.com/yaoapp/yao/agent/output/message"
|
||||
)
|
||||
|
||||
// DefaultStreamHandler creates a default stream handler that sends messages via context
|
||||
// This handler is used when no custom handler is provided
|
||||
func DefaultStreamHandler(ctx *context.Context) message.StreamFunc {
|
||||
|
||||
// Create stream state manager
|
||||
state := &streamState{
|
||||
ctx: ctx,
|
||||
inGroup: false,
|
||||
currentGroupID: "",
|
||||
messageSeq: 0,
|
||||
}
|
||||
|
||||
return func(chunkType message.StreamChunkType, data []byte) int {
|
||||
trace, _ := ctx.Trace()
|
||||
if trace != nil {
|
||||
trace.Info(i18n.T(ctx.Locale, "llm.handlers.stream.info"), map[string]any{"data": string(data)})
|
||||
}
|
||||
|
||||
// Handle different chunk types
|
||||
switch chunkType {
|
||||
case message.ChunkStreamStart:
|
||||
return state.handleStreamStart(data)
|
||||
|
||||
case message.ChunkMessageStart:
|
||||
return state.handleMessageStart(data)
|
||||
|
||||
case message.ChunkText:
|
||||
return state.handleText(data)
|
||||
|
||||
case message.ChunkThinking:
|
||||
return state.handleThinking(data)
|
||||
|
||||
case message.ChunkToolCall:
|
||||
return state.handleToolCall(data)
|
||||
|
||||
case message.ChunkExecute:
|
||||
return state.handleExecute(data)
|
||||
|
||||
case message.ChunkMetadata:
|
||||
return state.handleMetadata(data)
|
||||
|
||||
case message.ChunkError:
|
||||
return state.handleError(data)
|
||||
|
||||
case message.ChunkMessageEnd:
|
||||
return state.handleMessageEnd(data)
|
||||
|
||||
case message.ChunkStreamEnd:
|
||||
return state.handleStreamEnd(data)
|
||||
|
||||
default:
|
||||
// Unknown chunk type, continue
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// streamState manages the state of the streaming process
|
||||
type streamState struct {
|
||||
ctx *context.Context
|
||||
inGroup bool
|
||||
currentGroupID string // Current group ID (shared by all chunks in the group)
|
||||
currentType string // Track the current message type (text, thinking, tool_call)
|
||||
buffer []byte
|
||||
chunkCount int // Track number of chunks in current group
|
||||
messageSeq int // Message sequence number (for generating readable IDs)
|
||||
groupStartTime time.Time // Track when group started
|
||||
lastExecStatus string // Last observed execute status in current group ("running", "completed", "error")
|
||||
lastExecProps map[string]interface{} // Accumulated execute props for the current group (merged across chunks)
|
||||
}
|
||||
|
||||
// handleStreamStart handles stream start event
|
||||
func (s *streamState) handleStreamStart(data []byte) int {
|
||||
// Send event message to indicate stream has started
|
||||
// This is a lifecycle event, CUI clients can show it, OpenAI clients will ignore it
|
||||
var startData message.EventStreamStartData
|
||||
err := jsoniter.Unmarshal(data, &startData)
|
||||
if err != nil {
|
||||
log.Error("Failed to unmarshal stream start data: %v", err)
|
||||
}
|
||||
msg := output.NewEventMessage("stream_start", "Stream started", startData)
|
||||
s.ctx.Send(msg)
|
||||
return 0
|
||||
}
|
||||
|
||||
// handleMessageStart handles message start event
|
||||
func (s *streamState) handleMessageStart(data []byte) int {
|
||||
// Parse message start data first to get the message ID
|
||||
var startData message.EventMessageStartData
|
||||
if err := jsoniter.Unmarshal(data, &startData); err != nil {
|
||||
log.Error("Failed to unmarshal message start data: %v", err)
|
||||
return 0
|
||||
}
|
||||
|
||||
// Use the message ID from the start data, or generate one if not provided
|
||||
messageID := startData.MessageID
|
||||
if messageID == "" {
|
||||
messageID = s.ctx.IDGenerator.GenerateMessageID()
|
||||
startData.MessageID = messageID
|
||||
}
|
||||
|
||||
// Auto-set ThreadID from Stack for nested agent calls
|
||||
if startData.ThreadID == "" && s.ctx.Stack != nil && !s.ctx.Stack.IsRoot() {
|
||||
startData.ThreadID = s.ctx.Stack.ID
|
||||
}
|
||||
|
||||
s.inGroup = true
|
||||
s.currentGroupID = messageID
|
||||
s.buffer = []byte{}
|
||||
s.chunkCount = 0
|
||||
s.messageSeq = 0 // Reset message sequence for each message
|
||||
s.groupStartTime = time.Now()
|
||||
|
||||
// Send message_start event
|
||||
msg := output.NewEventMessage(message.EventMessageStart, "Message started", startData)
|
||||
s.ctx.Send(msg)
|
||||
|
||||
return 0 // Continue
|
||||
}
|
||||
|
||||
// handleText handles text content chunks
|
||||
func (s *streamState) handleText(data []byte) int {
|
||||
if len(data) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Track current message type
|
||||
s.currentType = message.TypeText
|
||||
|
||||
// Append to buffer
|
||||
s.buffer = append(s.buffer, data...)
|
||||
s.chunkCount++
|
||||
s.messageSeq++
|
||||
|
||||
// Send delta message
|
||||
// - ChunkID: Unique chunk ID (C1, C2, C3...) for this fragment
|
||||
// - MessageID: Same for all chunks of this logical message (frontend merges by message_id)
|
||||
msg := &message.Message{
|
||||
ChunkID: s.ctx.IDGenerator.GenerateChunkID(), // Unique chunk ID
|
||||
MessageID: s.currentGroupID, // Message ID for merging (all chunks share this)
|
||||
Type: message.TypeText,
|
||||
Delta: true,
|
||||
Props: map[string]interface{}{
|
||||
"content": string(data),
|
||||
},
|
||||
}
|
||||
|
||||
if err := s.ctx.Send(msg); err != nil {
|
||||
// Log error but continue streaming
|
||||
return 0
|
||||
}
|
||||
|
||||
return 0 // Continue
|
||||
}
|
||||
|
||||
// handleThinking handles thinking/reasoning chunks
|
||||
func (s *streamState) handleThinking(data []byte) int {
|
||||
if len(data) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Track current message type
|
||||
s.currentType = message.TypeThinking
|
||||
|
||||
// Append to buffer
|
||||
s.buffer = append(s.buffer, data...)
|
||||
s.chunkCount++
|
||||
s.messageSeq++
|
||||
|
||||
// Send delta message
|
||||
// - ChunkID: Unique chunk ID (C1, C2, C3...) for this fragment
|
||||
// - MessageID: Same for all chunks of this logical message (frontend merges by message_id)
|
||||
msg := &message.Message{
|
||||
ChunkID: s.ctx.IDGenerator.GenerateChunkID(), // Unique chunk ID
|
||||
MessageID: s.currentGroupID, // Message ID for merging (all chunks share this)
|
||||
Type: message.TypeThinking,
|
||||
Delta: true,
|
||||
Props: map[string]interface{}{
|
||||
"content": string(data),
|
||||
},
|
||||
}
|
||||
|
||||
if err := s.ctx.Send(msg); err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return 0 // Continue
|
||||
}
|
||||
|
||||
// handleToolCall handles tool call chunks
|
||||
func (s *streamState) handleToolCall(data []byte) int {
|
||||
if len(data) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Track current message type
|
||||
s.currentType = message.TypeToolCall
|
||||
|
||||
// Append to buffer for message_end event
|
||||
s.buffer = append(s.buffer, data...)
|
||||
s.chunkCount++
|
||||
s.messageSeq++
|
||||
|
||||
// Parse the tool call delta data (JSON array from OpenAI)
|
||||
var toolCallArray []map[string]interface{}
|
||||
if err := jsoniter.Unmarshal(data, &toolCallArray); err != nil {
|
||||
// If parse fails, log and skip this chunk
|
||||
return 0
|
||||
}
|
||||
|
||||
// Extract tool call fields from delta
|
||||
// OpenAI delta typically has one element, but we handle arrays safely
|
||||
var props map[string]interface{}
|
||||
var deltaAction string
|
||||
var deltaPath string
|
||||
|
||||
if len(toolCallArray) == 1 {
|
||||
tc := toolCallArray[0]
|
||||
props = map[string]interface{}{}
|
||||
|
||||
hasIdentity := false
|
||||
if id, ok := tc["id"].(string); ok {
|
||||
props["id"] = id
|
||||
hasIdentity = true
|
||||
}
|
||||
if typ, ok := tc["type"].(string); ok {
|
||||
props["type"] = typ
|
||||
hasIdentity = true
|
||||
}
|
||||
if index, ok := tc["index"].(float64); ok {
|
||||
props["index"] = int(index)
|
||||
}
|
||||
if fn, ok := tc["function"].(map[string]interface{}); ok {
|
||||
if name, ok := fn["name"].(string); ok {
|
||||
props["name"] = name
|
||||
hasIdentity = true
|
||||
}
|
||||
if args, ok := fn["arguments"].(string); ok {
|
||||
props["arguments"] = args
|
||||
}
|
||||
}
|
||||
|
||||
if hasIdentity {
|
||||
// First chunk with id/name/type: merge so all fields are applied.
|
||||
deltaAction = "merge"
|
||||
} else if _, ok := props["arguments"]; ok {
|
||||
// Subsequent chunk with only arguments fragment: append to arguments.
|
||||
deltaAction = "append"
|
||||
deltaPath = "arguments"
|
||||
} else {
|
||||
deltaAction = "merge"
|
||||
}
|
||||
} else {
|
||||
// Multiple tool calls in delta (rare) - keep as array
|
||||
props = map[string]interface{}{
|
||||
"calls": toolCallArray,
|
||||
}
|
||||
deltaAction = "merge"
|
||||
}
|
||||
|
||||
// Send delta message
|
||||
// - ChunkID: Unique chunk ID (C1, C2, C3...) for this fragment
|
||||
// - MessageID: Same for all chunks of this logical message (frontend merges by message_id)
|
||||
// - DeltaAction: "append" for arguments chunks, "merge" for id/type/name chunks
|
||||
// - DeltaPath: "arguments" when appending arguments field
|
||||
// OpenAI sends: first chunk has id/type/name, subsequent chunks only have arguments fragments
|
||||
msg := &message.Message{
|
||||
ChunkID: s.ctx.IDGenerator.GenerateChunkID(), // Unique chunk ID
|
||||
MessageID: s.currentGroupID, // Message ID for merging (all chunks share this)
|
||||
Type: message.TypeToolCall,
|
||||
Delta: true,
|
||||
DeltaAction: deltaAction, // "append" for arguments, "merge" for static fields
|
||||
DeltaPath: deltaPath, // "arguments" when appending
|
||||
Props: props, // Flattened tool call fields
|
||||
}
|
||||
|
||||
if err := s.ctx.Send(msg); err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return 0 // Continue
|
||||
}
|
||||
|
||||
// handleExecute handles execute observation chunks from sandbox CLI agents.
|
||||
// These represent tool actions observed inside the agent runtime (e.g., Bash, Read, Write).
|
||||
func (s *streamState) handleExecute(data []byte) int {
|
||||
if len(data) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
s.currentType = message.TypeExecute
|
||||
s.buffer = append(s.buffer, data...)
|
||||
s.chunkCount++
|
||||
s.messageSeq++
|
||||
|
||||
var props map[string]interface{}
|
||||
if err := jsoniter.Unmarshal(data, &props); err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
if st, ok := props["status"].(string); ok {
|
||||
s.lastExecStatus = st
|
||||
}
|
||||
|
||||
if s.lastExecProps == nil {
|
||||
s.lastExecProps = make(map[string]interface{})
|
||||
}
|
||||
for k, v := range props {
|
||||
s.lastExecProps[k] = v
|
||||
}
|
||||
|
||||
deltaAction := "merge"
|
||||
|
||||
msg := &message.Message{
|
||||
ChunkID: s.ctx.IDGenerator.GenerateChunkID(),
|
||||
MessageID: s.currentGroupID,
|
||||
Type: message.TypeExecute,
|
||||
Delta: true,
|
||||
DeltaAction: deltaAction,
|
||||
Props: props,
|
||||
}
|
||||
|
||||
if err := s.ctx.Send(msg); err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// handleMetadata handles metadata chunks (usage, finish_reason, result_summary, etc.)
|
||||
// For sandbox CLI agents, this carries token usage and result summaries.
|
||||
func (s *streamState) handleMetadata(data []byte) int {
|
||||
if len(data) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
var meta map[string]interface{}
|
||||
if err := jsoniter.Unmarshal(data, &meta); err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
if usage, ok := meta["usage"]; ok {
|
||||
msg := output.NewEventMessage("token/usage", "", usage)
|
||||
s.ctx.Send(msg)
|
||||
}
|
||||
|
||||
if summary, ok := meta["result_summary"]; ok {
|
||||
msg := output.NewEventMessage("result/summary", "", summary)
|
||||
s.ctx.Send(msg)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// handleError handles error chunks
|
||||
func (s *streamState) handleError(data []byte) int {
|
||||
// Send error message
|
||||
msg := output.NewErrorMessage(string(data), "stream_error")
|
||||
s.ctx.Send(msg)
|
||||
|
||||
return 1 // Stop streaming on error
|
||||
}
|
||||
|
||||
// handleMessageEnd handles message end event
|
||||
func (s *streamState) handleMessageEnd(data []byte) int {
|
||||
if !s.inGroup {
|
||||
return 0
|
||||
}
|
||||
|
||||
durationMs := time.Since(s.groupStartTime).Milliseconds()
|
||||
|
||||
// Use the tracked message type (thinking, text, tool_call, etc.)
|
||||
msgType := s.currentType
|
||||
if msgType == "" {
|
||||
msgType = message.TypeText // Fallback to text if type not set
|
||||
}
|
||||
|
||||
// Get ThreadID from Stack for nested agent calls
|
||||
var threadID string
|
||||
if s.ctx.Stack != nil && !s.ctx.Stack.IsRoot() {
|
||||
threadID = s.ctx.Stack.ID
|
||||
}
|
||||
|
||||
// Get BlockID from metadata if available
|
||||
var blockID string
|
||||
if s.ctx != nil {
|
||||
if metadata := s.ctx.GetMessageMetadata(s.currentGroupID); metadata != nil {
|
||||
blockID = metadata.BlockID
|
||||
}
|
||||
}
|
||||
|
||||
// Buffer the complete LLM message for storage
|
||||
// Delta chunks are not stored, but we need to save the final complete content
|
||||
// Skip if History is disabled in options
|
||||
shouldSkipHistory := s.ctx.Stack != nil && s.ctx.Stack.Options != nil &&
|
||||
s.ctx.Stack.Options.Skip != nil && s.ctx.Stack.Options.Skip.History
|
||||
|
||||
// Execute messages have two (or more) phases sharing the same message_id:
|
||||
// 1. running / suspended / resumed — streamed for UI display only, NOT persisted
|
||||
// 2. completed / error — the final state, persisted to the buffer
|
||||
// Only persist when we have an explicit terminal status.
|
||||
isExecuteFinal := msgType == message.TypeExecute &&
|
||||
(s.lastExecStatus == "completed" || s.lastExecStatus == "error")
|
||||
skipExecute := msgType == message.TypeExecute && !isExecuteFinal
|
||||
|
||||
if s.ctx.Buffer != nil && len(s.buffer) > 0 && !shouldSkipHistory && !skipExecute {
|
||||
assistantID := ""
|
||||
if s.ctx.Stack != nil {
|
||||
assistantID = s.ctx.Stack.AssistantID
|
||||
}
|
||||
|
||||
var props map[string]interface{}
|
||||
switch msgType {
|
||||
case message.TypeToolCall:
|
||||
var toolCallData interface{}
|
||||
if err := jsoniter.Unmarshal(s.buffer, &toolCallData); err == nil {
|
||||
props = map[string]interface{}{
|
||||
"calls": toolCallData,
|
||||
}
|
||||
} else {
|
||||
props = map[string]interface{}{
|
||||
"content": string(s.buffer),
|
||||
}
|
||||
}
|
||||
case message.TypeExecute:
|
||||
if s.lastExecProps != nil {
|
||||
props = make(map[string]interface{}, len(s.lastExecProps))
|
||||
for k, v := range s.lastExecProps {
|
||||
props[k] = v
|
||||
}
|
||||
} else {
|
||||
props = map[string]interface{}{
|
||||
"content": string(s.buffer),
|
||||
}
|
||||
}
|
||||
default:
|
||||
props = map[string]interface{}{
|
||||
"content": string(s.buffer),
|
||||
}
|
||||
}
|
||||
|
||||
s.ctx.Buffer.AddAssistantMessage(
|
||||
s.currentGroupID,
|
||||
msgType,
|
||||
props,
|
||||
blockID,
|
||||
threadID,
|
||||
assistantID,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
// Build EventMessageEndData with complete content
|
||||
endData := message.EventMessageEndData{
|
||||
MessageID: s.currentGroupID, // Use the message ID
|
||||
Type: msgType,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
ThreadID: threadID, // Include ThreadID for concurrent stream identification
|
||||
DurationMs: durationMs,
|
||||
ChunkCount: s.chunkCount,
|
||||
Status: "completed",
|
||||
Extra: map[string]interface{}{
|
||||
"content": string(s.buffer), // Include complete content in the event
|
||||
},
|
||||
}
|
||||
|
||||
// Send message_end event
|
||||
msg := output.NewEventMessage(message.EventMessageEnd, "Message completed", endData)
|
||||
s.ctx.Send(msg)
|
||||
|
||||
// Reset state
|
||||
s.inGroup = false
|
||||
s.currentGroupID = ""
|
||||
s.currentType = ""
|
||||
s.buffer = []byte{}
|
||||
s.chunkCount = 0
|
||||
s.lastExecStatus = ""
|
||||
s.lastExecProps = nil
|
||||
|
||||
return 0 // Continue
|
||||
}
|
||||
|
||||
// handleStreamEnd handles stream end event
|
||||
func (s *streamState) handleStreamEnd(data []byte) int {
|
||||
// Parse the stream end data
|
||||
var endData message.EventStreamEndData
|
||||
if err := jsoniter.Unmarshal(data, &endData); err != nil {
|
||||
log.Error("Failed to parse stream_end data: %v", err)
|
||||
s.ctx.Flush()
|
||||
return 0
|
||||
}
|
||||
|
||||
// Send stream_end event as a message to frontend
|
||||
msg := output.NewEventMessage("stream_end", "Stream completed", endData)
|
||||
s.ctx.Send(msg)
|
||||
|
||||
// Flush any remaining data
|
||||
s.ctx.Flush()
|
||||
return 0 // Continue (stream will end naturally)
|
||||
}
|
||||
|
|
@ -1,432 +0,0 @@
|
|||
package assistant
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
agentcontext "github.com/yaoapp/yao/agent/context"
|
||||
storetypes "github.com/yaoapp/yao/agent/store/types"
|
||||
"github.com/yaoapp/yao/trace/types"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// Chat History Management
|
||||
// =============================================================================
|
||||
|
||||
// HistoryResult represents the result of history processing
|
||||
type HistoryResult struct {
|
||||
InputMessages []agentcontext.Message // Clean input messages (without overlap)
|
||||
FullMessages []agentcontext.Message // Full messages (history + clean input)
|
||||
}
|
||||
|
||||
// getHistorySize returns the history size with priority: opts.HistorySize > storeSetting.MaxSize > default (20)
|
||||
func getHistorySize(opts *agentcontext.Options) int {
|
||||
const defaultHistorySize = 20
|
||||
if opts != nil && opts.HistorySize > 0 {
|
||||
return opts.HistorySize
|
||||
}
|
||||
if setting := GetStoreSetting(); setting != nil && setting.MaxSize > 0 {
|
||||
return setting.MaxSize
|
||||
}
|
||||
return defaultHistorySize
|
||||
}
|
||||
|
||||
// WithHistory merges the input messages with chat history and traces it
|
||||
// Returns HistoryResult containing:
|
||||
// - InputMessages: cleaned input (overlap removed)
|
||||
// - FullMessages: history + clean input merged
|
||||
func (ast *Assistant) WithHistory(ctx *agentcontext.Context, input []agentcontext.Message, agentNode types.Node, options ...*agentcontext.Options) (*HistoryResult, error) {
|
||||
|
||||
// Get options
|
||||
var opts *agentcontext.Options
|
||||
if len(options) > 0 && options[0] != nil {
|
||||
opts = options[0]
|
||||
}
|
||||
|
||||
// SKIP: History (for internal calls like title/prompt etc.)
|
||||
if opts != nil && opts.Skip != nil && opts.Skip.History {
|
||||
result := &HistoryResult{
|
||||
InputMessages: input,
|
||||
FullMessages: input,
|
||||
}
|
||||
ast.traceAgentHistory(ctx, agentNode, result.FullMessages)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Resolve history size: opts.HistorySize > storeSetting.MaxSize > default (20)
|
||||
maxSize := getHistorySize(opts)
|
||||
|
||||
// Load history from store
|
||||
historyMessages, err := ast.loadHistory(ctx, maxSize)
|
||||
if err != nil {
|
||||
// Log warning but continue without history
|
||||
ctx.Logger.Warn("Failed to load history for chat=%s: %v", ctx.ChatID, err)
|
||||
result := &HistoryResult{
|
||||
InputMessages: input,
|
||||
FullMessages: input,
|
||||
}
|
||||
ast.traceAgentHistory(ctx, agentNode, result.FullMessages)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// If no history, return input as is
|
||||
if len(historyMessages) == 0 {
|
||||
ctx.Logger.HistoryLoad(0, maxSize)
|
||||
result := &HistoryResult{
|
||||
InputMessages: input,
|
||||
FullMessages: input,
|
||||
}
|
||||
ast.traceAgentHistory(ctx, agentNode, result.FullMessages)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Log history loaded
|
||||
ctx.Logger.HistoryLoad(len(historyMessages), maxSize)
|
||||
|
||||
// Find overlap between history and input
|
||||
// Some external clients may include history in their requests
|
||||
overlapIndex := ast.findOverlapIndex(historyMessages, input)
|
||||
|
||||
// Remove overlap from input
|
||||
cleanInput := input
|
||||
if overlapIndex > 0 {
|
||||
cleanInput = input[overlapIndex:]
|
||||
ctx.Logger.HistoryOverlap(overlapIndex)
|
||||
}
|
||||
|
||||
// Merge history with clean input
|
||||
fullMessages := make([]agentcontext.Message, 0, len(historyMessages)+len(cleanInput))
|
||||
fullMessages = append(fullMessages, historyMessages...)
|
||||
fullMessages = append(fullMessages, cleanInput...)
|
||||
|
||||
result := &HistoryResult{
|
||||
InputMessages: cleanInput,
|
||||
FullMessages: fullMessages,
|
||||
}
|
||||
|
||||
// Log the chat history
|
||||
ast.traceAgentHistory(ctx, agentNode, result.FullMessages)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// loadHistory loads chat history from the store
|
||||
// Returns the most recent maxSize messages, ordered by time (oldest first)
|
||||
func (ast *Assistant) loadHistory(ctx *agentcontext.Context, maxSize int) ([]agentcontext.Message, error) {
|
||||
// Check if chat ID is available
|
||||
if ctx.ChatID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Get chat store
|
||||
chatStore := GetChatStore()
|
||||
if chatStore == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Load messages from store with limit
|
||||
filter := storetypes.MessageFilter{
|
||||
Limit: maxSize,
|
||||
}
|
||||
|
||||
storeMessages, err := chatStore.GetMessages(ctx.ChatID, filter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get messages: %w", err)
|
||||
}
|
||||
|
||||
if len(storeMessages) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Convert store messages to context messages
|
||||
messages := make([]agentcontext.Message, 0, len(storeMessages))
|
||||
for _, msg := range storeMessages {
|
||||
// Only include user and assistant messages for LLM context
|
||||
// Skip internal types like loading, event, etc.
|
||||
if msg.Role != "user" && msg.Role != "assistant" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Convert store message to context message
|
||||
ctxMsg := ast.convertStoreMessageToContext(msg)
|
||||
if ctxMsg != nil {
|
||||
messages = append(messages, *ctxMsg)
|
||||
}
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// convertStoreMessageToContext converts a store message to a context message
|
||||
func (ast *Assistant) convertStoreMessageToContext(msg *storetypes.Message) *agentcontext.Message {
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle special message types:
|
||||
// - tool_call/action: convert to historical summary text for LLM context
|
||||
// - loading/event: skip (pure UI/lifecycle signals, no semantic value)
|
||||
// - error: kept as-is so LLM can help troubleshoot issues
|
||||
switch msg.Type {
|
||||
case "tool_call":
|
||||
return ast.convertToolCallToContext(msg)
|
||||
case "action":
|
||||
return ast.convertActionToContext(msg)
|
||||
case "loading", "event":
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract content from Props
|
||||
content := ast.extractContentFromProps(msg.Props, msg.Type)
|
||||
if content == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build context message
|
||||
ctxMsg := &agentcontext.Message{
|
||||
Role: agentcontext.MessageRole(msg.Role),
|
||||
Content: content,
|
||||
}
|
||||
|
||||
// Handle name field
|
||||
if msg.Props != nil {
|
||||
if name, ok := msg.Props["name"].(string); ok && name != "" {
|
||||
ctxMsg.Name = &name
|
||||
}
|
||||
}
|
||||
|
||||
return ctxMsg
|
||||
}
|
||||
|
||||
// extractContentFromProps extracts the content from message Props based on message type
|
||||
func (ast *Assistant) extractContentFromProps(props map[string]interface{}, msgType string) interface{} {
|
||||
if props == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// For user input, content is stored directly in props["content"]
|
||||
if msgType == "user_input" {
|
||||
return props["content"]
|
||||
}
|
||||
|
||||
// For text type messages
|
||||
if msgType == "text" {
|
||||
if text, ok := props["text"].(string); ok {
|
||||
return text
|
||||
}
|
||||
// Also try content field
|
||||
if content, ok := props["content"].(string); ok {
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
// For other types, try to extract content or text
|
||||
if content, ok := props["content"]; ok {
|
||||
return content
|
||||
}
|
||||
if text, ok := props["text"]; ok {
|
||||
return text
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// convertToolCallToContext converts a tool_call store message to a historical summary text message.
|
||||
// This allows the LLM to understand what tools were previously called without re-invoking them.
|
||||
//
|
||||
// Supports two Props formats:
|
||||
// - Standard ToolCallProps: {"name": "tool_name", "arguments": "{...}"}
|
||||
// - Raw stream chunks: {"content": "[{\"index\":0,\"id\":\"call_...\",\"function\":{\"name\":\"tool\"}}][...]"}
|
||||
func (ast *Assistant) convertToolCallToContext(msg *storetypes.Message) *agentcontext.Message {
|
||||
if msg.Props == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try standard ToolCallProps format first
|
||||
if name, ok := msg.Props["name"].(string); ok && name != "" {
|
||||
args, _ := msg.Props["arguments"].(string)
|
||||
const maxArgsLen = 500
|
||||
if len(args) > maxArgsLen {
|
||||
args = args[:maxArgsLen] + "..."
|
||||
}
|
||||
return &agentcontext.Message{
|
||||
Role: agentcontext.RoleAssistant,
|
||||
Content: fmt.Sprintf("[Historical Tool Call Summary] Called tool \"%s\" with arguments: %s", name, args),
|
||||
}
|
||||
}
|
||||
|
||||
// Try raw stream chunk format: {"content": "[...][...]..."}
|
||||
// Each chunk is a JSON array like [{"index":0,"id":"call_...","function":{"name":"echo__ping"}}]
|
||||
// Subsequent chunks append arguments: [{"index":0,"function":{"arguments":"..."}}]
|
||||
if raw, ok := msg.Props["content"].(string); ok && raw != "" {
|
||||
name, args := parseToolCallRawChunks(raw)
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
const maxArgsLen = 500
|
||||
if len(args) > maxArgsLen {
|
||||
args = args[:maxArgsLen] + "..."
|
||||
}
|
||||
return &agentcontext.Message{
|
||||
Role: agentcontext.RoleAssistant,
|
||||
Content: fmt.Sprintf("[Historical Tool Call Summary] Called tool \"%s\" with arguments: %s", name, args),
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseToolCallRawChunks parses concatenated raw stream chunks to extract tool name and arguments.
|
||||
// Input format: "[{...}][{...}][{...}]" — multiple JSON arrays concatenated without separator.
|
||||
func parseToolCallRawChunks(raw string) (name, args string) {
|
||||
// Split concatenated JSON arrays: "][" is the boundary
|
||||
// e.g. "[{...}][{...}]" → ["[{...}]", "[{...}]"]
|
||||
chunks := splitJSONArrays(raw)
|
||||
|
||||
var argParts []string
|
||||
for _, chunk := range chunks {
|
||||
var items []map[string]interface{}
|
||||
if err := jsoniter.UnmarshalFromString(chunk, &items); err != nil || len(items) == 0 {
|
||||
continue
|
||||
}
|
||||
item := items[0]
|
||||
if fn, ok := item["function"].(map[string]interface{}); ok {
|
||||
if n, ok := fn["name"].(string); ok && n != "" && name == "" {
|
||||
name = n
|
||||
}
|
||||
if a, ok := fn["arguments"].(string); ok && a != "" {
|
||||
argParts = append(argParts, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
args = ""
|
||||
for _, part := range argParts {
|
||||
args += part
|
||||
}
|
||||
return name, args
|
||||
}
|
||||
|
||||
// splitJSONArrays splits a string of concatenated JSON arrays "[...][...][...]" into individual arrays.
|
||||
func splitJSONArrays(s string) []string {
|
||||
var result []string
|
||||
depth := 0
|
||||
start := -1
|
||||
for i, ch := range s {
|
||||
switch ch {
|
||||
case '[':
|
||||
if depth == 0 {
|
||||
start = i
|
||||
}
|
||||
depth++
|
||||
case ']':
|
||||
depth--
|
||||
if depth == 0 && start >= 0 {
|
||||
result = append(result, s[start:i+1])
|
||||
start = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// convertActionToContext converts an action store message to a historical summary text message.
|
||||
// This allows the LLM to understand what system actions were previously executed.
|
||||
func (ast *Assistant) convertActionToContext(msg *storetypes.Message) *agentcontext.Message {
|
||||
if msg.Props == nil {
|
||||
return nil
|
||||
}
|
||||
name, _ := msg.Props["name"].(string)
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
payload := ""
|
||||
if msg.Props["payload"] != nil {
|
||||
if payloadStr, err := jsoniter.MarshalToString(msg.Props["payload"]); err == nil {
|
||||
const maxPayloadLen = 500
|
||||
if len(payloadStr) > maxPayloadLen {
|
||||
payloadStr = payloadStr[:maxPayloadLen] + "..."
|
||||
}
|
||||
payload = payloadStr
|
||||
}
|
||||
}
|
||||
if payload != "" {
|
||||
return &agentcontext.Message{
|
||||
Role: agentcontext.RoleAssistant,
|
||||
Content: fmt.Sprintf("[Historical Action Summary] Executed action \"%s\" with payload: %s", name, payload),
|
||||
}
|
||||
}
|
||||
return &agentcontext.Message{
|
||||
Role: agentcontext.RoleAssistant,
|
||||
Content: fmt.Sprintf("[Historical Action Summary] Executed action \"%s\"", name),
|
||||
}
|
||||
}
|
||||
|
||||
// findOverlapIndex finds the index in input where history messages end
|
||||
// Returns the number of input messages that overlap with history
|
||||
func (ast *Assistant) findOverlapIndex(history, input []agentcontext.Message) int {
|
||||
if len(history) == 0 || len(input) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// We need to find the longest suffix of history that matches a prefix of input
|
||||
// Start from the end of history and try to match with the beginning of input
|
||||
|
||||
maxOverlap := len(history)
|
||||
if maxOverlap > len(input) {
|
||||
maxOverlap = len(input)
|
||||
}
|
||||
|
||||
// Try different overlap lengths, starting from the largest possible
|
||||
for overlapLen := maxOverlap; overlapLen > 0; overlapLen-- {
|
||||
// Check if the last 'overlapLen' messages of history match the first 'overlapLen' of input
|
||||
historyStart := len(history) - overlapLen
|
||||
matched := true
|
||||
|
||||
for i := 0; i < overlapLen; i++ {
|
||||
if !ast.messagesMatch(history[historyStart+i], input[i]) {
|
||||
matched = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if matched {
|
||||
return overlapLen
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// messagesMatch checks if two messages are equivalent
|
||||
func (ast *Assistant) messagesMatch(a, b agentcontext.Message) bool {
|
||||
// Must have same role
|
||||
if a.Role != b.Role {
|
||||
return false
|
||||
}
|
||||
|
||||
// Compare content
|
||||
return ast.contentMatches(a.Content, b.Content)
|
||||
}
|
||||
|
||||
// contentMatches compares two content values for equality
|
||||
func (ast *Assistant) contentMatches(a, b interface{}) bool {
|
||||
// Handle nil cases
|
||||
if a == nil && b == nil {
|
||||
return true
|
||||
}
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// If both are strings, compare directly
|
||||
aStr, aIsStr := a.(string)
|
||||
bStr, bIsStr := b.(string)
|
||||
if aIsStr && bIsStr {
|
||||
return aStr == bStr
|
||||
}
|
||||
|
||||
// For complex content (arrays, etc.), use deep equal
|
||||
return reflect.DeepEqual(a, b)
|
||||
}
|
||||
|
|
@ -1,994 +0,0 @@
|
|||
package assistant_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
agentcontext "github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/output/message"
|
||||
storetypes "github.com/yaoapp/yao/agent/store/types"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// Helper Functions
|
||||
// =============================================================================
|
||||
|
||||
// newHistoryTestContext creates a test context for history tests
|
||||
func newHistoryTestContext(chatID string) *agentcontext.Context {
|
||||
authorized := &oauthtypes.AuthorizedInfo{
|
||||
Subject: "test-user",
|
||||
UserID: "history-test-user",
|
||||
TeamID: "history-test-team",
|
||||
TenantID: "history-test-tenant",
|
||||
}
|
||||
|
||||
ctx := agentcontext.New(context.Background(), authorized, chatID)
|
||||
ctx.AssistantID = "tests.history"
|
||||
ctx.Locale = "en-us"
|
||||
ctx.Client = agentcontext.Client{
|
||||
Type: "web",
|
||||
IP: "127.0.0.1",
|
||||
}
|
||||
ctx.Referer = agentcontext.RefererAPI
|
||||
ctx.Accept = agentcontext.AcceptWebCUI
|
||||
ctx.IDGenerator = message.NewIDGenerator()
|
||||
ctx.Metadata = make(map[string]interface{})
|
||||
return ctx
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// WithHistory Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestWithHistory(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
// Get assistant
|
||||
ast, err := assistant.Get("tests.history")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
// Get chat store for setup/cleanup
|
||||
chatStore := assistant.GetChatStore()
|
||||
if chatStore == nil {
|
||||
t.Skip("Chat store not configured, skipping history tests")
|
||||
}
|
||||
|
||||
t.Run("NoHistory", func(t *testing.T) {
|
||||
chatID := fmt.Sprintf("test_history_none_%s", uuid.New().String()[:8])
|
||||
ctx := newHistoryTestContext(chatID)
|
||||
|
||||
// Create chat without any messages
|
||||
err := chatStore.CreateChat(&storetypes.Chat{
|
||||
ChatID: chatID,
|
||||
AssistantID: ast.ID,
|
||||
Status: "active",
|
||||
Share: "private",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer chatStore.DeleteChat(chatID)
|
||||
|
||||
input := []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: "Hello, this is my first message"},
|
||||
}
|
||||
|
||||
result, err := ast.WithHistory(ctx, input, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// With no history, InputMessages and FullMessages should be the same as input
|
||||
assert.Equal(t, input, result.InputMessages)
|
||||
assert.Equal(t, input, result.FullMessages)
|
||||
t.Log("✓ No history: input returned as is")
|
||||
})
|
||||
|
||||
t.Run("WithExistingHistory", func(t *testing.T) {
|
||||
chatID := fmt.Sprintf("test_history_exist_%s", uuid.New().String()[:8])
|
||||
ctx := newHistoryTestContext(chatID)
|
||||
reqID := uuid.New().String()[:8]
|
||||
|
||||
// Create chat
|
||||
err := chatStore.CreateChat(&storetypes.Chat{
|
||||
ChatID: chatID,
|
||||
AssistantID: ast.ID,
|
||||
Status: "active",
|
||||
Share: "private",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
chatStore.DeleteMessages(chatID, nil)
|
||||
chatStore.DeleteChat(chatID)
|
||||
}()
|
||||
|
||||
// Add history messages
|
||||
historyMessages := []*storetypes.Message{
|
||||
{
|
||||
MessageID: fmt.Sprintf("hist_msg_1_%s", reqID),
|
||||
ChatID: chatID,
|
||||
RequestID: fmt.Sprintf("req_%s", reqID),
|
||||
Role: "user",
|
||||
Type: "user_input",
|
||||
Props: map[string]interface{}{"content": "Previous question"},
|
||||
Sequence: 1,
|
||||
AssistantID: ast.ID,
|
||||
CreatedAt: time.Now().Add(-2 * time.Minute),
|
||||
},
|
||||
{
|
||||
MessageID: fmt.Sprintf("hist_msg_2_%s", reqID),
|
||||
ChatID: chatID,
|
||||
RequestID: fmt.Sprintf("req_%s", reqID),
|
||||
Role: "assistant",
|
||||
Type: "text",
|
||||
Props: map[string]interface{}{"text": "Previous answer"},
|
||||
Sequence: 2,
|
||||
AssistantID: ast.ID,
|
||||
CreatedAt: time.Now().Add(-1 * time.Minute),
|
||||
},
|
||||
}
|
||||
|
||||
err = chatStore.SaveMessages(chatID, historyMessages)
|
||||
require.NoError(t, err)
|
||||
|
||||
// New input message
|
||||
input := []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: "New question"},
|
||||
}
|
||||
|
||||
result, err := ast.WithHistory(ctx, input, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// InputMessages should be unchanged (no overlap)
|
||||
assert.Equal(t, input, result.InputMessages)
|
||||
|
||||
// FullMessages should have history + input
|
||||
assert.Len(t, result.FullMessages, 3) // 2 history + 1 new
|
||||
|
||||
// Verify order: history first, then input
|
||||
assert.Equal(t, agentcontext.RoleUser, result.FullMessages[0].Role)
|
||||
assert.Equal(t, "Previous question", result.FullMessages[0].Content)
|
||||
assert.Equal(t, agentcontext.RoleAssistant, result.FullMessages[1].Role)
|
||||
assert.Equal(t, "Previous answer", result.FullMessages[1].Content)
|
||||
assert.Equal(t, agentcontext.RoleUser, result.FullMessages[2].Role)
|
||||
assert.Equal(t, "New question", result.FullMessages[2].Content)
|
||||
|
||||
t.Log("✓ History merged correctly with new input")
|
||||
})
|
||||
|
||||
t.Run("SkipHistoryOption", func(t *testing.T) {
|
||||
chatID := fmt.Sprintf("test_history_skip_%s", uuid.New().String()[:8])
|
||||
ctx := newHistoryTestContext(chatID)
|
||||
reqID := uuid.New().String()[:8]
|
||||
|
||||
// Create chat with history
|
||||
err := chatStore.CreateChat(&storetypes.Chat{
|
||||
ChatID: chatID,
|
||||
AssistantID: ast.ID,
|
||||
Status: "active",
|
||||
Share: "private",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
chatStore.DeleteMessages(chatID, nil)
|
||||
chatStore.DeleteChat(chatID)
|
||||
}()
|
||||
|
||||
// Add history message
|
||||
err = chatStore.SaveMessages(chatID, []*storetypes.Message{
|
||||
{
|
||||
MessageID: fmt.Sprintf("skip_hist_1_%s", reqID),
|
||||
ChatID: chatID,
|
||||
RequestID: fmt.Sprintf("req_skip_%s", reqID),
|
||||
Role: "user",
|
||||
Type: "user_input",
|
||||
Props: map[string]interface{}{"content": "Should be skipped"},
|
||||
Sequence: 1,
|
||||
AssistantID: ast.ID,
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
input := []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: "Only this should appear"},
|
||||
}
|
||||
|
||||
// Use Skip.History option
|
||||
opts := &agentcontext.Options{
|
||||
Skip: &agentcontext.Skip{
|
||||
History: true,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := ast.WithHistory(ctx, input, nil, opts)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// Both should be same as input (history skipped)
|
||||
assert.Equal(t, input, result.InputMessages)
|
||||
assert.Equal(t, input, result.FullMessages)
|
||||
assert.Len(t, result.FullMessages, 1)
|
||||
|
||||
t.Log("✓ History skipped when Skip.History=true")
|
||||
})
|
||||
|
||||
t.Run("OverlapDetection", func(t *testing.T) {
|
||||
chatID := fmt.Sprintf("test_history_overlap_%s", uuid.New().String()[:8])
|
||||
ctx := newHistoryTestContext(chatID)
|
||||
reqID := uuid.New().String()[:8]
|
||||
|
||||
// Create chat
|
||||
err := chatStore.CreateChat(&storetypes.Chat{
|
||||
ChatID: chatID,
|
||||
AssistantID: ast.ID,
|
||||
Status: "active",
|
||||
Share: "private",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
chatStore.DeleteMessages(chatID, nil)
|
||||
chatStore.DeleteChat(chatID)
|
||||
}()
|
||||
|
||||
// Add history messages
|
||||
err = chatStore.SaveMessages(chatID, []*storetypes.Message{
|
||||
{
|
||||
MessageID: fmt.Sprintf("overlap_1_%s", reqID),
|
||||
ChatID: chatID,
|
||||
RequestID: fmt.Sprintf("req_overlap_%s", reqID),
|
||||
Role: "user",
|
||||
Type: "user_input",
|
||||
Props: map[string]interface{}{"content": "Message one"},
|
||||
Sequence: 1,
|
||||
AssistantID: ast.ID,
|
||||
CreatedAt: time.Now().Add(-3 * time.Minute),
|
||||
},
|
||||
{
|
||||
MessageID: fmt.Sprintf("overlap_2_%s", reqID),
|
||||
ChatID: chatID,
|
||||
RequestID: fmt.Sprintf("req_overlap_%s", reqID),
|
||||
Role: "assistant",
|
||||
Type: "text",
|
||||
Props: map[string]interface{}{"text": "Response one"},
|
||||
Sequence: 2,
|
||||
AssistantID: ast.ID,
|
||||
CreatedAt: time.Now().Add(-2 * time.Minute),
|
||||
},
|
||||
{
|
||||
MessageID: fmt.Sprintf("overlap_3_%s", reqID),
|
||||
ChatID: chatID,
|
||||
RequestID: fmt.Sprintf("req_overlap_2_%s", reqID),
|
||||
Role: "user",
|
||||
Type: "user_input",
|
||||
Props: map[string]interface{}{"content": "Message two"},
|
||||
Sequence: 3,
|
||||
AssistantID: ast.ID,
|
||||
CreatedAt: time.Now().Add(-1 * time.Minute),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Input that overlaps with history (includes last messages)
|
||||
// Some clients send full history + new message
|
||||
input := []agentcontext.Message{
|
||||
{Role: agentcontext.RoleAssistant, Content: "Response one"}, // Overlap
|
||||
{Role: agentcontext.RoleUser, Content: "Message two"}, // Overlap
|
||||
{Role: agentcontext.RoleUser, Content: "New message"}, // New
|
||||
}
|
||||
|
||||
result, err := ast.WithHistory(ctx, input, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// InputMessages should have overlap removed
|
||||
assert.Len(t, result.InputMessages, 1, "Should remove 2 overlapping messages")
|
||||
assert.Equal(t, "New message", result.InputMessages[0].Content)
|
||||
|
||||
// FullMessages should be history + clean input
|
||||
assert.Len(t, result.FullMessages, 4) // 3 history + 1 new
|
||||
|
||||
t.Log("✓ Overlap detected and removed from input")
|
||||
})
|
||||
|
||||
t.Run("EmptyChatID", func(t *testing.T) {
|
||||
ctx := newHistoryTestContext("")
|
||||
|
||||
input := []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: "No chat ID"},
|
||||
}
|
||||
|
||||
result, err := ast.WithHistory(ctx, input, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// With empty chat ID, should return input as is
|
||||
assert.Equal(t, input, result.InputMessages)
|
||||
assert.Equal(t, input, result.FullMessages)
|
||||
|
||||
t.Log("✓ Empty chat ID handled gracefully")
|
||||
})
|
||||
|
||||
t.Run("MultipleUserMessages", func(t *testing.T) {
|
||||
chatID := fmt.Sprintf("test_history_multi_%s", uuid.New().String()[:8])
|
||||
ctx := newHistoryTestContext(chatID)
|
||||
reqID := uuid.New().String()[:8]
|
||||
|
||||
// Create chat with history
|
||||
err := chatStore.CreateChat(&storetypes.Chat{
|
||||
ChatID: chatID,
|
||||
AssistantID: ast.ID,
|
||||
Status: "active",
|
||||
Share: "private",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
chatStore.DeleteMessages(chatID, nil)
|
||||
chatStore.DeleteChat(chatID)
|
||||
}()
|
||||
|
||||
// Add history
|
||||
err = chatStore.SaveMessages(chatID, []*storetypes.Message{
|
||||
{
|
||||
MessageID: fmt.Sprintf("multi_1_%s", reqID),
|
||||
ChatID: chatID,
|
||||
RequestID: fmt.Sprintf("req_multi_%s", reqID),
|
||||
Role: "user",
|
||||
Type: "user_input",
|
||||
Props: map[string]interface{}{"content": "First"},
|
||||
Sequence: 1,
|
||||
AssistantID: ast.ID,
|
||||
CreatedAt: time.Now().Add(-1 * time.Minute),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Multiple input messages
|
||||
input := []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: "Second"},
|
||||
{Role: agentcontext.RoleUser, Content: "Third"},
|
||||
}
|
||||
|
||||
result, err := ast.WithHistory(ctx, input, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
assert.Len(t, result.InputMessages, 2)
|
||||
assert.Len(t, result.FullMessages, 3) // 1 history + 2 new
|
||||
|
||||
t.Log("✓ Multiple input messages handled correctly")
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// History Load Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestHistoryLoading(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ast, err := assistant.Get("tests.history")
|
||||
require.NoError(t, err)
|
||||
|
||||
chatStore := assistant.GetChatStore()
|
||||
if chatStore == nil {
|
||||
t.Skip("Chat store not configured")
|
||||
}
|
||||
|
||||
t.Run("FilterNonConversationTypes", func(t *testing.T) {
|
||||
chatID := fmt.Sprintf("test_filter_%s", uuid.New().String()[:8])
|
||||
ctx := newHistoryTestContext(chatID)
|
||||
reqID := uuid.New().String()[:8]
|
||||
|
||||
// Create chat
|
||||
err := chatStore.CreateChat(&storetypes.Chat{
|
||||
ChatID: chatID,
|
||||
AssistantID: ast.ID,
|
||||
Status: "active",
|
||||
Share: "private",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
chatStore.DeleteMessages(chatID, nil)
|
||||
chatStore.DeleteChat(chatID)
|
||||
}()
|
||||
|
||||
// Add various message types (only user/assistant roles allowed by DB constraint)
|
||||
// loadHistory filters by role (user/assistant only) and converts based on type:
|
||||
// - loading/event: skipped (no semantic value)
|
||||
// - tool_call/action: converted to historical summary text
|
||||
// - text/user_input/error: kept as-is
|
||||
err = chatStore.SaveMessages(chatID, []*storetypes.Message{
|
||||
{
|
||||
MessageID: fmt.Sprintf("filter_1_%s", reqID),
|
||||
ChatID: chatID,
|
||||
RequestID: fmt.Sprintf("req_filter_%s", reqID),
|
||||
Role: "user",
|
||||
Type: "user_input",
|
||||
Props: map[string]interface{}{"content": "User message"},
|
||||
Sequence: 1,
|
||||
AssistantID: ast.ID,
|
||||
CreatedAt: time.Now().Add(-3 * time.Minute),
|
||||
},
|
||||
{
|
||||
MessageID: fmt.Sprintf("filter_2_%s", reqID),
|
||||
ChatID: chatID,
|
||||
RequestID: fmt.Sprintf("req_filter_%s", reqID),
|
||||
Role: "assistant",
|
||||
Type: "loading",
|
||||
Props: map[string]interface{}{"text": "Loading..."},
|
||||
Sequence: 2,
|
||||
AssistantID: ast.ID,
|
||||
CreatedAt: time.Now().Add(-2 * time.Minute),
|
||||
},
|
||||
{
|
||||
MessageID: fmt.Sprintf("filter_3_%s", reqID),
|
||||
ChatID: chatID,
|
||||
RequestID: fmt.Sprintf("req_filter_%s", reqID),
|
||||
Role: "assistant",
|
||||
Type: "text",
|
||||
Props: map[string]interface{}{"text": "Assistant response"},
|
||||
Sequence: 3,
|
||||
AssistantID: ast.ID,
|
||||
CreatedAt: time.Now().Add(-1 * time.Minute),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
input := []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: "New input"},
|
||||
}
|
||||
|
||||
result, err := ast.WithHistory(ctx, input, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// loading type is skipped (no semantic value)
|
||||
// History: user_input + text = 2 messages; plus 1 new input = 3 total
|
||||
assert.Len(t, result.FullMessages, 3)
|
||||
|
||||
// Verify only user and assistant roles
|
||||
for _, msg := range result.FullMessages {
|
||||
assert.True(t, msg.Role == agentcontext.RoleUser || msg.Role == agentcontext.RoleAssistant,
|
||||
"Expected user or assistant role, got: %s", msg.Role)
|
||||
}
|
||||
|
||||
t.Log("✓ Loading type filtered, user/assistant roles kept")
|
||||
})
|
||||
|
||||
t.Run("ToolCallConvertedToSummary", func(t *testing.T) {
|
||||
chatID := fmt.Sprintf("test_toolcall_%s", uuid.New().String()[:8])
|
||||
ctx := newHistoryTestContext(chatID)
|
||||
reqID := uuid.New().String()[:8]
|
||||
|
||||
err := chatStore.CreateChat(&storetypes.Chat{
|
||||
ChatID: chatID,
|
||||
AssistantID: ast.ID,
|
||||
Status: "active",
|
||||
Share: "private",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
chatStore.DeleteMessages(chatID, nil)
|
||||
chatStore.DeleteChat(chatID)
|
||||
}()
|
||||
|
||||
// Add tool_call messages in both formats
|
||||
err = chatStore.SaveMessages(chatID, []*storetypes.Message{
|
||||
{
|
||||
MessageID: fmt.Sprintf("tc_1_%s", reqID),
|
||||
ChatID: chatID,
|
||||
RequestID: fmt.Sprintf("req_tc_%s", reqID),
|
||||
Role: "user",
|
||||
Type: "user_input",
|
||||
Props: map[string]interface{}{"content": "echo 3 ping 4"},
|
||||
Sequence: 1,
|
||||
AssistantID: ast.ID,
|
||||
CreatedAt: time.Now().Add(-3 * time.Minute),
|
||||
},
|
||||
// Raw stream chunk format (actual DB format)
|
||||
{
|
||||
MessageID: fmt.Sprintf("tc_2_%s", reqID),
|
||||
ChatID: chatID,
|
||||
RequestID: fmt.Sprintf("req_tc_%s", reqID),
|
||||
Role: "assistant",
|
||||
Type: "tool_call",
|
||||
Props: map[string]interface{}{"content": `[{"index":0,"id":"call_abc","type":"function","function":{"name":"echo__ping"}}][{"index":0,"function":{"arguments":"{\"count\":3}"}}]`},
|
||||
Sequence: 2,
|
||||
AssistantID: ast.ID,
|
||||
CreatedAt: time.Now().Add(-2 * time.Minute),
|
||||
},
|
||||
// Standard ToolCallProps format
|
||||
{
|
||||
MessageID: fmt.Sprintf("tc_3_%s", reqID),
|
||||
ChatID: chatID,
|
||||
RequestID: fmt.Sprintf("req_tc_%s", reqID),
|
||||
Role: "assistant",
|
||||
Type: "tool_call",
|
||||
Props: map[string]interface{}{"name": "echo__echo", "arguments": `{"message":"hello"}`},
|
||||
Sequence: 3,
|
||||
AssistantID: ast.ID,
|
||||
CreatedAt: time.Now().Add(-1 * time.Minute),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
input := []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: "echo 5 ping 6"},
|
||||
}
|
||||
|
||||
result, err := ast.WithHistory(ctx, input, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// 1 user_input + 2 tool_call summaries + 1 new input = 4
|
||||
assert.Len(t, result.FullMessages, 4)
|
||||
|
||||
// Verify tool_call messages are converted to summary text
|
||||
tcMsg1 := result.FullMessages[1]
|
||||
assert.Equal(t, agentcontext.RoleAssistant, tcMsg1.Role)
|
||||
assert.Contains(t, tcMsg1.Content, "[Historical Tool Call Summary]")
|
||||
assert.Contains(t, tcMsg1.Content, "echo__ping")
|
||||
assert.Contains(t, tcMsg1.Content, `{"count":3}`)
|
||||
|
||||
tcMsg2 := result.FullMessages[2]
|
||||
assert.Equal(t, agentcontext.RoleAssistant, tcMsg2.Role)
|
||||
assert.Contains(t, tcMsg2.Content, "[Historical Tool Call Summary]")
|
||||
assert.Contains(t, tcMsg2.Content, "echo__echo")
|
||||
assert.Contains(t, tcMsg2.Content, `{"message":"hello"}`)
|
||||
|
||||
t.Log("✓ Tool call messages converted to historical summaries")
|
||||
})
|
||||
|
||||
t.Run("ActionConvertedToSummary", func(t *testing.T) {
|
||||
chatID := fmt.Sprintf("test_action_%s", uuid.New().String()[:8])
|
||||
ctx := newHistoryTestContext(chatID)
|
||||
reqID := uuid.New().String()[:8]
|
||||
|
||||
err := chatStore.CreateChat(&storetypes.Chat{
|
||||
ChatID: chatID,
|
||||
AssistantID: ast.ID,
|
||||
Status: "active",
|
||||
Share: "private",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
chatStore.DeleteMessages(chatID, nil)
|
||||
chatStore.DeleteChat(chatID)
|
||||
}()
|
||||
|
||||
err = chatStore.SaveMessages(chatID, []*storetypes.Message{
|
||||
{
|
||||
MessageID: fmt.Sprintf("act_1_%s", reqID),
|
||||
ChatID: chatID,
|
||||
RequestID: fmt.Sprintf("req_act_%s", reqID),
|
||||
Role: "user",
|
||||
Type: "user_input",
|
||||
Props: map[string]interface{}{"content": "Do something"},
|
||||
Sequence: 1,
|
||||
AssistantID: ast.ID,
|
||||
CreatedAt: time.Now().Add(-2 * time.Minute),
|
||||
},
|
||||
// Action with payload
|
||||
{
|
||||
MessageID: fmt.Sprintf("act_2_%s", reqID),
|
||||
ChatID: chatID,
|
||||
RequestID: fmt.Sprintf("req_act_%s", reqID),
|
||||
Role: "assistant",
|
||||
Type: "action",
|
||||
Props: map[string]interface{}{
|
||||
"name": "robot.execute",
|
||||
"payload": map[string]interface{}{"goals": "test goal", "robot_id": "12345"},
|
||||
},
|
||||
Sequence: 2,
|
||||
AssistantID: ast.ID,
|
||||
CreatedAt: time.Now().Add(-1 * time.Minute),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
input := []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: "What happened?"},
|
||||
}
|
||||
|
||||
result, err := ast.WithHistory(ctx, input, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// 1 user_input + 1 action summary + 1 new input = 3
|
||||
assert.Len(t, result.FullMessages, 3)
|
||||
|
||||
actMsg := result.FullMessages[1]
|
||||
assert.Equal(t, agentcontext.RoleAssistant, actMsg.Role)
|
||||
assert.Contains(t, actMsg.Content, "[Historical Action Summary]")
|
||||
assert.Contains(t, actMsg.Content, "robot.execute")
|
||||
assert.Contains(t, actMsg.Content, "test goal")
|
||||
assert.Contains(t, actMsg.Content, "12345")
|
||||
|
||||
t.Log("✓ Action messages converted to historical summaries with payload")
|
||||
})
|
||||
|
||||
t.Run("ActionWithoutPayload", func(t *testing.T) {
|
||||
chatID := fmt.Sprintf("test_action_nopay_%s", uuid.New().String()[:8])
|
||||
ctx := newHistoryTestContext(chatID)
|
||||
reqID := uuid.New().String()[:8]
|
||||
|
||||
err := chatStore.CreateChat(&storetypes.Chat{
|
||||
ChatID: chatID,
|
||||
AssistantID: ast.ID,
|
||||
Status: "active",
|
||||
Share: "private",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
chatStore.DeleteMessages(chatID, nil)
|
||||
chatStore.DeleteChat(chatID)
|
||||
}()
|
||||
|
||||
err = chatStore.SaveMessages(chatID, []*storetypes.Message{
|
||||
{
|
||||
MessageID: fmt.Sprintf("actnp_1_%s", reqID),
|
||||
ChatID: chatID,
|
||||
RequestID: fmt.Sprintf("req_actnp_%s", reqID),
|
||||
Role: "assistant",
|
||||
Type: "action",
|
||||
Props: map[string]interface{}{"name": "navigate"},
|
||||
Sequence: 1,
|
||||
AssistantID: ast.ID,
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
input := []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: "What happened?"},
|
||||
}
|
||||
|
||||
result, err := ast.WithHistory(ctx, input, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// 1 action summary + 1 new input = 2
|
||||
assert.Len(t, result.FullMessages, 2)
|
||||
|
||||
actMsg := result.FullMessages[0]
|
||||
assert.Equal(t, agentcontext.RoleAssistant, actMsg.Role)
|
||||
assert.Contains(t, actMsg.Content, "[Historical Action Summary]")
|
||||
assert.Contains(t, actMsg.Content, "navigate")
|
||||
assert.NotContains(t, actMsg.Content, "payload")
|
||||
|
||||
t.Log("✓ Action without payload handled correctly")
|
||||
})
|
||||
|
||||
t.Run("ContentExtraction", func(t *testing.T) {
|
||||
chatID := fmt.Sprintf("test_extract_%s", uuid.New().String()[:8])
|
||||
ctx := newHistoryTestContext(chatID)
|
||||
reqID := uuid.New().String()[:8]
|
||||
|
||||
// Create chat
|
||||
err := chatStore.CreateChat(&storetypes.Chat{
|
||||
ChatID: chatID,
|
||||
AssistantID: ast.ID,
|
||||
Status: "active",
|
||||
Share: "private",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
chatStore.DeleteMessages(chatID, nil)
|
||||
chatStore.DeleteChat(chatID)
|
||||
}()
|
||||
|
||||
// Add messages with different content formats
|
||||
err = chatStore.SaveMessages(chatID, []*storetypes.Message{
|
||||
{
|
||||
MessageID: fmt.Sprintf("extract_1_%s", reqID),
|
||||
ChatID: chatID,
|
||||
RequestID: fmt.Sprintf("req_extract_%s", reqID),
|
||||
Role: "user",
|
||||
Type: "user_input",
|
||||
Props: map[string]interface{}{"content": "User content from props.content"},
|
||||
Sequence: 1,
|
||||
AssistantID: ast.ID,
|
||||
CreatedAt: time.Now().Add(-2 * time.Minute),
|
||||
},
|
||||
{
|
||||
MessageID: fmt.Sprintf("extract_2_%s", reqID),
|
||||
ChatID: chatID,
|
||||
RequestID: fmt.Sprintf("req_extract_%s", reqID),
|
||||
Role: "assistant",
|
||||
Type: "text",
|
||||
Props: map[string]interface{}{"text": "Assistant content from props.text"},
|
||||
Sequence: 2,
|
||||
AssistantID: ast.ID,
|
||||
CreatedAt: time.Now().Add(-1 * time.Minute),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
input := []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: "New message"},
|
||||
}
|
||||
|
||||
result, err := ast.WithHistory(ctx, input, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// Verify content was extracted correctly
|
||||
assert.Len(t, result.FullMessages, 3)
|
||||
assert.Equal(t, "User content from props.content", result.FullMessages[0].Content)
|
||||
assert.Equal(t, "Assistant content from props.text", result.FullMessages[1].Content)
|
||||
|
||||
t.Log("✓ Content extracted correctly from different formats")
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Edge Cases Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestHistoryEdgeCases(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ast, err := assistant.Get("tests.history")
|
||||
require.NoError(t, err)
|
||||
|
||||
chatStore := assistant.GetChatStore()
|
||||
if chatStore == nil {
|
||||
t.Skip("Chat store not configured")
|
||||
}
|
||||
|
||||
t.Run("EmptyInput", func(t *testing.T) {
|
||||
chatID := fmt.Sprintf("test_empty_input_%s", uuid.New().String()[:8])
|
||||
ctx := newHistoryTestContext(chatID)
|
||||
reqID := uuid.New().String()[:8]
|
||||
|
||||
// Create chat with history
|
||||
err := chatStore.CreateChat(&storetypes.Chat{
|
||||
ChatID: chatID,
|
||||
AssistantID: ast.ID,
|
||||
Status: "active",
|
||||
Share: "private",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
chatStore.DeleteMessages(chatID, nil)
|
||||
chatStore.DeleteChat(chatID)
|
||||
}()
|
||||
|
||||
err = chatStore.SaveMessages(chatID, []*storetypes.Message{
|
||||
{
|
||||
MessageID: fmt.Sprintf("empty_input_1_%s", reqID),
|
||||
ChatID: chatID,
|
||||
RequestID: fmt.Sprintf("req_empty_%s", reqID),
|
||||
Role: "user",
|
||||
Type: "user_input",
|
||||
Props: map[string]interface{}{"content": "Previous"},
|
||||
Sequence: 1,
|
||||
AssistantID: ast.ID,
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Empty input
|
||||
input := []agentcontext.Message{}
|
||||
|
||||
result, err := ast.WithHistory(ctx, input, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// Should return history only
|
||||
assert.Empty(t, result.InputMessages)
|
||||
assert.Len(t, result.FullMessages, 1)
|
||||
|
||||
t.Log("✓ Empty input handled correctly")
|
||||
})
|
||||
|
||||
t.Run("FullOverlap", func(t *testing.T) {
|
||||
chatID := fmt.Sprintf("test_full_overlap_%s", uuid.New().String()[:8])
|
||||
ctx := newHistoryTestContext(chatID)
|
||||
reqID := uuid.New().String()[:8]
|
||||
|
||||
// Create chat
|
||||
err := chatStore.CreateChat(&storetypes.Chat{
|
||||
ChatID: chatID,
|
||||
AssistantID: ast.ID,
|
||||
Status: "active",
|
||||
Share: "private",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
chatStore.DeleteMessages(chatID, nil)
|
||||
chatStore.DeleteChat(chatID)
|
||||
}()
|
||||
|
||||
// Add history
|
||||
err = chatStore.SaveMessages(chatID, []*storetypes.Message{
|
||||
{
|
||||
MessageID: fmt.Sprintf("full_overlap_1_%s", reqID),
|
||||
ChatID: chatID,
|
||||
RequestID: fmt.Sprintf("req_full_%s", reqID),
|
||||
Role: "user",
|
||||
Type: "user_input",
|
||||
Props: map[string]interface{}{"content": "Exact same message"},
|
||||
Sequence: 1,
|
||||
AssistantID: ast.ID,
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Input is exactly the same as history
|
||||
input := []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: "Exact same message"},
|
||||
}
|
||||
|
||||
result, err := ast.WithHistory(ctx, input, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// Full overlap: clean input should be empty
|
||||
assert.Empty(t, result.InputMessages)
|
||||
// FullMessages should be just history (no duplicates)
|
||||
assert.Len(t, result.FullMessages, 1)
|
||||
|
||||
t.Log("✓ Full overlap handled correctly")
|
||||
})
|
||||
|
||||
t.Run("NonExistentChat", func(t *testing.T) {
|
||||
chatID := "non_existent_chat_12345"
|
||||
ctx := newHistoryTestContext(chatID)
|
||||
|
||||
input := []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: "Message to non-existent chat"},
|
||||
}
|
||||
|
||||
result, err := ast.WithHistory(ctx, input, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// Should return input as is (no history found)
|
||||
assert.Equal(t, input, result.InputMessages)
|
||||
assert.Equal(t, input, result.FullMessages)
|
||||
|
||||
t.Log("✓ Non-existent chat handled gracefully")
|
||||
})
|
||||
|
||||
t.Run("MessageWithName", func(t *testing.T) {
|
||||
chatID := fmt.Sprintf("test_name_%s", uuid.New().String()[:8])
|
||||
ctx := newHistoryTestContext(chatID)
|
||||
reqID := uuid.New().String()[:8]
|
||||
|
||||
// Create chat
|
||||
err := chatStore.CreateChat(&storetypes.Chat{
|
||||
ChatID: chatID,
|
||||
AssistantID: ast.ID,
|
||||
Status: "active",
|
||||
Share: "private",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
chatStore.DeleteMessages(chatID, nil)
|
||||
chatStore.DeleteChat(chatID)
|
||||
}()
|
||||
|
||||
// Add message with name
|
||||
err = chatStore.SaveMessages(chatID, []*storetypes.Message{
|
||||
{
|
||||
MessageID: fmt.Sprintf("name_msg_1_%s", reqID),
|
||||
ChatID: chatID,
|
||||
RequestID: fmt.Sprintf("req_name_%s", reqID),
|
||||
Role: "user",
|
||||
Type: "user_input",
|
||||
Props: map[string]interface{}{"content": "Message with name", "name": "John"},
|
||||
Sequence: 1,
|
||||
AssistantID: ast.ID,
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
input := []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: "New message"},
|
||||
}
|
||||
|
||||
result, err := ast.WithHistory(ctx, input, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// First message should have name
|
||||
assert.Len(t, result.FullMessages, 2)
|
||||
assert.NotNil(t, result.FullMessages[0].Name)
|
||||
assert.Equal(t, "John", *result.FullMessages[0].Name)
|
||||
|
||||
t.Log("✓ Message name field preserved")
|
||||
})
|
||||
|
||||
t.Run("EmptyContent", func(t *testing.T) {
|
||||
chatID := fmt.Sprintf("test_empty_content_%s", uuid.New().String()[:8])
|
||||
ctx := newHistoryTestContext(chatID)
|
||||
reqID := uuid.New().String()[:8]
|
||||
|
||||
// Create chat
|
||||
err := chatStore.CreateChat(&storetypes.Chat{
|
||||
ChatID: chatID,
|
||||
AssistantID: ast.ID,
|
||||
Status: "active",
|
||||
Share: "private",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
chatStore.DeleteMessages(chatID, nil)
|
||||
chatStore.DeleteChat(chatID)
|
||||
}()
|
||||
|
||||
// Add message with empty content in props
|
||||
err = chatStore.SaveMessages(chatID, []*storetypes.Message{
|
||||
{
|
||||
MessageID: fmt.Sprintf("empty_content_1_%s", reqID),
|
||||
ChatID: chatID,
|
||||
RequestID: fmt.Sprintf("req_empty_content_%s", reqID),
|
||||
Role: "user",
|
||||
Type: "user_input",
|
||||
Props: map[string]interface{}{}, // empty props (no content)
|
||||
Sequence: 1,
|
||||
AssistantID: ast.ID,
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
input := []agentcontext.Message{
|
||||
{Role: agentcontext.RoleUser, Content: "New message"},
|
||||
}
|
||||
|
||||
result, err := ast.WithHistory(ctx, input, nil)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// Message with empty props should be skipped (no content extractable)
|
||||
// Only new input should be present
|
||||
assert.Len(t, result.FullMessages, 1)
|
||||
assert.Equal(t, "New message", result.FullMessages[0].Content)
|
||||
|
||||
t.Log("✓ Empty content handled gracefully (message skipped)")
|
||||
})
|
||||
}
|
||||
|
|
@ -1,357 +0,0 @@
|
|||
# Performance Test Report
|
||||
|
||||
**Test Date**: November 28, 2025
|
||||
**System**: Yao Agent Assistant - Create Hook
|
||||
**Hardware**: Apple M2 Max, ARM64, macOS 25.1.0
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
All tests passed with 100% success rate. The system demonstrates production-ready performance with stable memory usage and predictable response times.
|
||||
|
||||
**Key Metrics:**
|
||||
|
||||
- ✅ **Concurrent Capacity**: 1,000 operations @ 100 goroutines
|
||||
- ✅ **Response Time**: 1.57ms average (hook execution only)
|
||||
- ✅ **Memory Stable**: ≤1 MB growth under load
|
||||
- ✅ **Success Rate**: 100% (1,000/1,000 validated)
|
||||
|
||||
---
|
||||
|
||||
## Performance Benchmarks
|
||||
|
||||
### Single Request Performance
|
||||
|
||||
| Scenario | Mode | Time/op | Memory/op | Allocs/op |
|
||||
| -------- | ----------- | ------- | --------- | --------- |
|
||||
| Simple | Standard | 1.44 ms | 45 KB | 827 |
|
||||
| Simple | Performance | 0.33 ms | 33 KB | 789 |
|
||||
| Business | Standard | 3.33 ms | 95 KB | 1,570 |
|
||||
| Business | Performance | 0.35 ms | 33 KB | 805 |
|
||||
|
||||
**Note**: Standard mode creates/disposes V8 isolate per request. Performance mode reuses isolates from pool.
|
||||
|
||||
### Concurrent Performance
|
||||
|
||||
| Scenario | Mode | Time/op | Memory/op | Allocs/op |
|
||||
| ------------------- | ----------- | ------- | --------- | --------- |
|
||||
| Simple Concurrent | Standard | 0.42 ms | 46 KB | 829 |
|
||||
| Simple Concurrent | Performance | 0.35 ms | 33 KB | 789 |
|
||||
| Business Concurrent | Standard | 0.64 ms | 89 KB | 1,457 |
|
||||
| Business Concurrent | Performance | 0.35 ms | 33 KB | 786 |
|
||||
|
||||
**Observation**: Concurrent execution shows better performance than sequential in standard mode due to parallel isolate creation.
|
||||
|
||||
---
|
||||
|
||||
## Stress Test Results
|
||||
|
||||
### Basic Tests
|
||||
|
||||
**Simple Scenario** (100 iterations):
|
||||
|
||||
- Duration: 0.34s
|
||||
- Memory: 470 MB → 471 MB (0 MB growth)
|
||||
- Result: ✅ Stable
|
||||
|
||||
**MCP Integration** (50 iterations):
|
||||
|
||||
- Duration: 0.40s
|
||||
- Memory: 472 MB → 471 MB (0 MB growth)
|
||||
- Result: ✅ No leaks
|
||||
|
||||
**Full Workflow** (30 iterations, MCP + DB + Trace):
|
||||
|
||||
- Duration: 0.39s
|
||||
- Average: 12.90 ms/op
|
||||
- Memory: 472 MB → 471 MB (0 MB growth)
|
||||
- Result: ✅ All components working
|
||||
|
||||
### Concurrent Stress Test ⭐
|
||||
|
||||
**Configuration:**
|
||||
|
||||
- Goroutines: 100
|
||||
- Iterations: 10 per goroutine
|
||||
- Total operations: 1,000
|
||||
- Scenarios: Mixed (simple, mcp_health, mcp_tools, full_workflow)
|
||||
|
||||
**Results:**
|
||||
|
||||
- Duration: 1.57 seconds
|
||||
- Average: 1.57 ms/op
|
||||
- Throughput: ~636 ops/second
|
||||
- Success: 1,000/1,000 (100%)
|
||||
- Memory: 472 MB → 473 MB (1 MB growth)
|
||||
- Validation: All responses correct
|
||||
|
||||
**Scenario Distribution:**
|
||||
|
||||
- simple: 250 ops (25%)
|
||||
- mcp_health: 250 ops (25%)
|
||||
- mcp_tools: 250 ops (25%)
|
||||
- full_workflow: 250 ops (25%)
|
||||
|
||||
---
|
||||
|
||||
## Memory Analysis
|
||||
|
||||
### Memory Leak Tests
|
||||
|
||||
All memory leak tests passed with acceptable thresholds:
|
||||
|
||||
**Standard Mode** (1,000 iterations):
|
||||
|
||||
- Growth: 11.65 MB (12.2 KB/iteration)
|
||||
- Threshold: <15 KB/iteration
|
||||
- Status: ✅ Pass
|
||||
|
||||
**Performance Mode** (1,000 iterations):
|
||||
|
||||
- Growth: -0.15 MB (negative = GC working)
|
||||
- Status: ✅ Pass
|
||||
|
||||
**Business Scenarios** (200 iterations each):
|
||||
|
||||
- Growth: 12-15 KB/iteration
|
||||
- Status: ✅ All pass
|
||||
|
||||
**Concurrent Load** (1,000 iterations):
|
||||
|
||||
- Growth: 1.73 MB (1.8 KB/iteration)
|
||||
- Status: ✅ Excellent
|
||||
|
||||
### Goroutine Behavior
|
||||
|
||||
**Observation**: Each request creates 2 goroutines (trace pubsub + state worker) that exit asynchronously after `Release()`.
|
||||
|
||||
**Measured Growth**: 2.0 goroutines/iteration
|
||||
|
||||
- Initial: 106 → Final: 122 (after 10 iterations)
|
||||
- Threshold: <5 goroutines/iteration
|
||||
- Status: ✅ Expected behavior (not a leak)
|
||||
|
||||
**Root Cause**: Asynchronous cleanup - goroutines exit when channels close, but scheduling takes time. This is normal Go concurrency behavior.
|
||||
|
||||
---
|
||||
|
||||
## Capacity Planning
|
||||
|
||||
### Single Instance Capacity
|
||||
|
||||
**Hook Execution Only** (measured):
|
||||
|
||||
```
|
||||
Response Time: 1.57ms
|
||||
Goroutines: 100 tested, stable
|
||||
Throughput: ~636 ops/second actual
|
||||
```
|
||||
|
||||
**Complete Request Flow** (estimated):
|
||||
|
||||
```
|
||||
Hook Execution: 1.57ms
|
||||
LLM API Call: 500-2000ms (typical)
|
||||
Network + Parsing: 50-100ms
|
||||
Total: ~1000ms per request
|
||||
```
|
||||
|
||||
### Production Estimates
|
||||
|
||||
**Conservative Capacity** (50% safety factor):
|
||||
|
||||
| User Activity | Requests/Min | Concurrent Online Users |
|
||||
| ------------------- | ------------ | ----------------------- |
|
||||
| Light (3 req/min) | 3,000 total | 1,000 online |
|
||||
| Normal (6 req/min) | 3,000 total | 500 online |
|
||||
| Active (15 req/min) | 3,000 total | 200 online |
|
||||
| Heavy (30 req/min) | 3,000 total | 100 online |
|
||||
|
||||
**Calculation Basis:**
|
||||
|
||||
- 100 goroutines proven stable
|
||||
- ~1 request/second per goroutine
|
||||
- Base: 100 req/s = 6,000 req/min
|
||||
- With 50% safety: 3,000 req/min sustained
|
||||
|
||||
**Recommendation**: Start with 500-1,000 concurrent online users per instance, monitor and scale horizontally as needed.
|
||||
|
||||
**Note**: "Concurrent online users" means users actively using the system at the same time, not total registered users.
|
||||
|
||||
### Horizontal Scaling
|
||||
|
||||
```
|
||||
1 instance → 500-1,000 concurrent online users
|
||||
2 instances → 1,000-2,000 concurrent online users
|
||||
5 instances → 2,500-5,000 concurrent online users
|
||||
10 instances → 5,000-10,000 concurrent online users
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Verification
|
||||
|
||||
### MCP Integration ✅
|
||||
|
||||
- ListTools: Working
|
||||
- CallTool: Working (ping, status)
|
||||
- Resource operations: Working
|
||||
- Prompt operations: Working
|
||||
- Performance: <3ms per operation
|
||||
|
||||
### Trace Management ✅
|
||||
|
||||
- Node creation: <1ms
|
||||
- 20+ nodes per operation: No issues
|
||||
- Memory cleanup: Effective
|
||||
- Goroutine cleanup: Asynchronous (expected)
|
||||
|
||||
### Context Management ✅
|
||||
|
||||
- Creation: Fast
|
||||
- Release: Working (cascading cleanup)
|
||||
- Memory: No leaks detected
|
||||
- Thread-safe: Yes
|
||||
|
||||
### Database Integration ✅
|
||||
|
||||
- Query execution: Working
|
||||
- Connection pooling: Efficient
|
||||
- Error handling: Robust
|
||||
|
||||
---
|
||||
|
||||
## Reliability Metrics
|
||||
|
||||
**Test Coverage:**
|
||||
|
||||
- Total tests: 21
|
||||
- Tests passed: 21 (100%)
|
||||
- Tests failed: 0
|
||||
- Flaky tests: 0
|
||||
|
||||
**Error Rate:**
|
||||
|
||||
- Operations: 1,200+
|
||||
- Errors: 0
|
||||
- Rate: 0.00%
|
||||
|
||||
**Data Integrity:**
|
||||
|
||||
- Message validation: 100%
|
||||
- Metadata validation: 100%
|
||||
- Scenario matching: 100%
|
||||
|
||||
---
|
||||
|
||||
## Known Behaviors
|
||||
|
||||
### Goroutine Accumulation
|
||||
|
||||
**Observation**: ~2 goroutines created per request that exit asynchronously.
|
||||
|
||||
**Root Cause**:
|
||||
|
||||
- Trace creates 2 background goroutines: `pubsub.forward()` + `stateWorker()`
|
||||
- These exit when channels close (via `Release()`)
|
||||
- Exit is asynchronous - takes 5-15ms after `Release()`
|
||||
- In rapid iterations, new goroutines start before old ones finish exiting
|
||||
|
||||
**Impact**:
|
||||
|
||||
- Temporary accumulation during high load
|
||||
- No unbounded growth (goroutines eventually exit)
|
||||
- Go runtime handles this efficiently
|
||||
- Not a memory leak
|
||||
|
||||
**Status**: ✅ Expected behavior, no action needed
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
### Production Deployment
|
||||
|
||||
**Ready to Deploy**: Yes
|
||||
|
||||
**Suggested Configuration:**
|
||||
|
||||
- Start with 1-2 instances
|
||||
- Target: 500-1,000 concurrent users per instance
|
||||
- V8 Mode: Standard (safer) or Performance (faster)
|
||||
- Health check: Monitor goroutine count (<10,000)
|
||||
|
||||
### Monitoring
|
||||
|
||||
**Key Metrics to Track:**
|
||||
|
||||
1. Response time (alert if >100ms sustained)
|
||||
2. Goroutine count (alert if >10,000)
|
||||
3. Memory usage (alert if >1GB growth/hour)
|
||||
4. Error rate (alert if >1%)
|
||||
|
||||
### Scaling Triggers
|
||||
|
||||
**Scale Up When:**
|
||||
|
||||
- Response time >50ms average (sustained 5 min)
|
||||
- Goroutine count >5,000 (approaching limits)
|
||||
- CPU >70% (need more capacity)
|
||||
|
||||
**Scale Out When:**
|
||||
|
||||
- Need >1,000 concurrent users
|
||||
- Multi-region deployment required
|
||||
- Geographic latency optimization needed
|
||||
|
||||
---
|
||||
|
||||
## Conclusions
|
||||
|
||||
### System Status: **Production Ready** ✅
|
||||
|
||||
**Strengths:**
|
||||
|
||||
- Fast response times (1-3ms for hook execution)
|
||||
- Stable memory usage (no leaks detected)
|
||||
- Excellent concurrent performance (100+ goroutines stable)
|
||||
- 100% test success rate with validation
|
||||
- Clean resource management with proper cleanup
|
||||
|
||||
**Suitable For:**
|
||||
|
||||
- SaaS platforms (500-1,000 concurrent online users per instance)
|
||||
- Enterprise applications requiring high reliability
|
||||
- Systems with 100-1,000 concurrent online users
|
||||
- Mission-critical AI agent deployments
|
||||
|
||||
**Performance Rating**: A (Excellent)
|
||||
|
||||
**Capacity Rating**: Mid-stage SaaS (Series A/B ready)
|
||||
|
||||
---
|
||||
|
||||
## Test Execution Summary
|
||||
|
||||
```
|
||||
Platform: darwin/arm64
|
||||
CPU: Apple M2 Max
|
||||
Go Version: 1.25.0
|
||||
Test Duration: 19.8 seconds
|
||||
|
||||
Unit Tests: 21 passed
|
||||
Benchmarks: 8 completed
|
||||
Stress Tests: 5 passed (1,000 ops validated)
|
||||
Memory Tests: 7 passed
|
||||
Goroutine Tests: 4 passed (behavior documented)
|
||||
|
||||
Overall: 100% PASS ✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Report Generated**: November 28, 2025
|
||||
**Test Framework**: Go testing + testify
|
||||
**Validation**: Complete (all responses verified)
|
||||
**Status**: PRODUCTION READY
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
package hook
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/yaoapp/gou/runtime/v8/bridge"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
)
|
||||
|
||||
// Create create a new assistant
|
||||
// opts is optional - if provided, will be adjusted based on hook response
|
||||
func (s *Script) Create(ctx *context.Context, messages []context.Message, opts ...*context.Options) (*context.HookCreateResponse, *context.Options, error) {
|
||||
// Get or create options
|
||||
var options *context.Options
|
||||
if len(opts) > 0 && opts[0] != nil {
|
||||
options = opts[0]
|
||||
} else {
|
||||
options = &context.Options{}
|
||||
}
|
||||
|
||||
// Execute hook with ctx, messages, and options (convert options to map for JS)
|
||||
optionsMap := options.ToMap()
|
||||
res, err := s.Execute(ctx, "Create", messages, optionsMap)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
response, err := s.getHookCreateResponse(res)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Apply adjustments from the response
|
||||
if response != nil {
|
||||
s.applyContextAdjustments(ctx, response)
|
||||
s.applyOptionsAdjustments(options, response)
|
||||
}
|
||||
|
||||
return response, options, nil
|
||||
}
|
||||
|
||||
// applyContextAdjustments applies session-level field overrides from the hook response back to the context
|
||||
func (s *Script) applyContextAdjustments(ctx *context.Context, response *context.HookCreateResponse) {
|
||||
// Note: AssistantID cannot be overridden - it's set at initialization and immutable
|
||||
|
||||
// Override locale if provided (session-level)
|
||||
if response.Locale != "" {
|
||||
ctx.Locale = response.Locale
|
||||
}
|
||||
|
||||
// Override theme if provided (session-level)
|
||||
if response.Theme != "" {
|
||||
ctx.Theme = response.Theme
|
||||
}
|
||||
|
||||
// Override route if provided (session-level)
|
||||
if response.Route != "" {
|
||||
ctx.Route = response.Route
|
||||
}
|
||||
|
||||
// Merge or override metadata if provided (session-level)
|
||||
if len(response.Metadata) > 0 {
|
||||
if ctx.Metadata == nil {
|
||||
ctx.Metadata = make(map[string]interface{})
|
||||
}
|
||||
// Merge metadata - response metadata takes precedence
|
||||
for key, value := range response.Metadata {
|
||||
ctx.Metadata[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// applyOptionsAdjustments applies call-level field overrides from the hook response to options
|
||||
func (s *Script) applyOptionsAdjustments(opts *context.Options, response *context.HookCreateResponse) {
|
||||
// Override connector if provided (call-level parameter)
|
||||
if response.Connector != "" {
|
||||
opts.Connector = response.Connector
|
||||
}
|
||||
}
|
||||
|
||||
// getHookCreateResponse convert the result to a HookCreateResponse
|
||||
func (s *Script) getHookCreateResponse(res interface{}) (*context.HookCreateResponse, error) {
|
||||
// Handle nil result
|
||||
if res == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Handle undefined result (treat as nil)
|
||||
if _, ok := res.(bridge.UndefinedT); ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Marshal to JSON and unmarshal to HookCreateResponse
|
||||
raw, err := json.Marshal(res)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal result: %w", err)
|
||||
}
|
||||
|
||||
var response context.HookCreateResponse
|
||||
if err := json.Unmarshal(raw, &response); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal to HookCreateResponse: %w", err)
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
|
|
@ -1,326 +0,0 @@
|
|||
package hook_test
|
||||
|
||||
import (
|
||||
stdContext "context"
|
||||
"testing"
|
||||
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
"github.com/yaoapp/yao/test"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Simple Scenario Benchmarks
|
||||
// ============================================================================
|
||||
|
||||
// BenchmarkSimpleStandardMode benchmarks simple scenario in standard V8 mode
|
||||
// Run with: go test -bench=BenchmarkSimpleStandardMode -benchmem -benchtime=100x
|
||||
func BenchmarkSimpleStandardMode(b *testing.B) {
|
||||
testutils.Prepare(&testing.T{})
|
||||
defer testutils.Clean(&testing.T{})
|
||||
|
||||
agent, err := assistant.Get("tests.create")
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to get assistant: %s", err.Error())
|
||||
}
|
||||
|
||||
if agent.HookScript == nil {
|
||||
b.Fatalf("Assistant has no script")
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
ctx := newBenchContext("bench-simple-standard", "tests.create")
|
||||
_, _, err := agent.HookScript.Create(ctx, []context.Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
})
|
||||
if err != nil {
|
||||
b.Fatalf("Create failed: %s", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSimplePerformanceMode benchmarks simple scenario in performance V8 mode
|
||||
// Run with: go test -bench=BenchmarkSimplePerformanceMode -benchmem -benchtime=100x
|
||||
func BenchmarkSimplePerformanceMode(b *testing.B) {
|
||||
testutils.Prepare(&testing.T{}, test.PrepareOption{V8Mode: "performance"})
|
||||
defer testutils.Clean(&testing.T{})
|
||||
|
||||
agent, err := assistant.Get("tests.create")
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to get assistant: %s", err.Error())
|
||||
}
|
||||
|
||||
if agent.HookScript == nil {
|
||||
b.Fatalf("Assistant has no script")
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
ctx := newBenchContext("bench-simple-performance", "tests.create")
|
||||
_, _, err := agent.HookScript.Create(ctx, []context.Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
})
|
||||
if err != nil {
|
||||
b.Fatalf("Create failed: %s", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Business Scenario Benchmarks (with Process calls, DB access, etc.)
|
||||
// ============================================================================
|
||||
|
||||
// BenchmarkBusinessStandardMode benchmarks business scenarios in standard V8 mode
|
||||
// Run with: go test -bench=BenchmarkBusinessStandardMode -benchmem -benchtime=100x
|
||||
func BenchmarkBusinessStandardMode(b *testing.B) {
|
||||
testutils.Prepare(&testing.T{})
|
||||
defer testutils.Clean(&testing.T{})
|
||||
|
||||
agent, err := assistant.Get("tests.create")
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to get assistant: %s", err.Error())
|
||||
}
|
||||
|
||||
if agent.HookScript == nil {
|
||||
b.Fatalf("Assistant has no script")
|
||||
}
|
||||
|
||||
scenarios := getBusinessScenarios()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
scenario := scenarios[i%len(scenarios)]
|
||||
ctx := newBenchContext("bench-business-standard", "tests.create")
|
||||
_, _, err := agent.HookScript.Create(ctx, []context.Message{
|
||||
{Role: "user", Content: scenario.content},
|
||||
})
|
||||
if err != nil {
|
||||
b.Errorf("%s failed: %s", scenario.name, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkBusinessPerformanceMode benchmarks business scenarios in performance V8 mode
|
||||
// Run with: go test -bench=BenchmarkBusinessPerformanceMode -benchmem -benchtime=100x
|
||||
func BenchmarkBusinessPerformanceMode(b *testing.B) {
|
||||
testutils.Prepare(&testing.T{}, test.PrepareOption{V8Mode: "performance"})
|
||||
defer testutils.Clean(&testing.T{})
|
||||
|
||||
agent, err := assistant.Get("tests.create")
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to get assistant: %s", err.Error())
|
||||
}
|
||||
|
||||
if agent.HookScript == nil {
|
||||
b.Fatalf("Assistant has no script")
|
||||
}
|
||||
|
||||
scenarios := getBusinessScenarios()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
scenario := scenarios[i%len(scenarios)]
|
||||
ctx := newBenchContext("bench-business-performance", "tests.create")
|
||||
_, _, err := agent.HookScript.Create(ctx, []context.Message{
|
||||
{Role: "user", Content: scenario.content},
|
||||
})
|
||||
if err != nil {
|
||||
b.Errorf("%s failed: %s", scenario.name, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Concurrent Benchmarks
|
||||
// ============================================================================
|
||||
|
||||
// BenchmarkConcurrentSimpleStandardMode benchmarks simple concurrent scenario in standard V8 mode
|
||||
// Simulates concurrent users with isolate creation/disposal per request
|
||||
// Run with: go test -bench=BenchmarkConcurrentSimpleStandardMode -benchmem -benchtime=100x
|
||||
func BenchmarkConcurrentSimpleStandardMode(b *testing.B) {
|
||||
testutils.Prepare(&testing.T{})
|
||||
defer testutils.Clean(&testing.T{})
|
||||
|
||||
agent, err := assistant.Get("tests.create")
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to get assistant: %s", err.Error())
|
||||
}
|
||||
|
||||
if agent.HookScript == nil {
|
||||
b.Fatalf("Assistant has no script")
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
i := 0
|
||||
for pb.Next() {
|
||||
ctx := newBenchContext("bench-concurrent-simple-standard", "tests.create")
|
||||
_, _, err := agent.HookScript.Create(ctx, []context.Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
})
|
||||
if err != nil {
|
||||
b.Errorf("Create failed (iteration %d): %s", i, err.Error())
|
||||
}
|
||||
i++
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkConcurrentSimplePerformanceMode benchmarks simple concurrent scenario in performance V8 mode
|
||||
// Simulates 100 users simultaneously using the system with isolate pool
|
||||
// Run with: go test -bench=BenchmarkConcurrentSimplePerformanceMode -benchmem -benchtime=100x
|
||||
func BenchmarkConcurrentSimplePerformanceMode(b *testing.B) {
|
||||
testutils.Prepare(&testing.T{}, test.PrepareOption{V8Mode: "performance"})
|
||||
defer testutils.Clean(&testing.T{})
|
||||
|
||||
agent, err := assistant.Get("tests.create")
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to get assistant: %s", err.Error())
|
||||
}
|
||||
|
||||
if agent.HookScript == nil {
|
||||
b.Fatalf("Assistant has no script")
|
||||
}
|
||||
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
i := 0
|
||||
for pb.Next() {
|
||||
ctx := newBenchContext("bench-concurrent-simple", "tests.create")
|
||||
_, _, err := agent.HookScript.Create(ctx, []context.Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
})
|
||||
if err != nil {
|
||||
b.Errorf("Create failed (iteration %d): %s", i, err.Error())
|
||||
}
|
||||
i++
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkConcurrentBusinessStandardMode benchmarks concurrent business scenarios in standard V8 mode
|
||||
// Tests various scenarios with concurrent users and isolate creation/disposal per request
|
||||
// Run with: go test -bench=BenchmarkConcurrentBusinessStandardMode -benchmem -benchtime=100x
|
||||
func BenchmarkConcurrentBusinessStandardMode(b *testing.B) {
|
||||
testutils.Prepare(&testing.T{})
|
||||
defer testutils.Clean(&testing.T{})
|
||||
|
||||
agent, err := assistant.Get("tests.create")
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to get assistant: %s", err.Error())
|
||||
}
|
||||
|
||||
if agent.HookScript == nil {
|
||||
b.Fatalf("Assistant has no script")
|
||||
}
|
||||
|
||||
scenarios := getBusinessScenarios()
|
||||
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
i := 0
|
||||
for pb.Next() {
|
||||
scenario := scenarios[i%len(scenarios)]
|
||||
ctx := newBenchContext("bench-concurrent-business-standard", "tests.create")
|
||||
_, _, err := agent.HookScript.Create(ctx, []context.Message{
|
||||
{Role: "user", Content: scenario.content},
|
||||
})
|
||||
if err != nil {
|
||||
b.Errorf("%s failed (iteration %d): %s", scenario.name, i, err.Error())
|
||||
}
|
||||
i++
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkConcurrentBusinessPerformanceMode benchmarks concurrent business scenarios in performance V8 mode
|
||||
// Tests various scenarios with 100 concurrent users with isolate pool
|
||||
// Run with: go test -bench=BenchmarkConcurrentBusinessPerformanceMode -benchmem -benchtime=100x
|
||||
func BenchmarkConcurrentBusinessPerformanceMode(b *testing.B) {
|
||||
testutils.Prepare(&testing.T{}, test.PrepareOption{V8Mode: "performance"})
|
||||
defer testutils.Clean(&testing.T{})
|
||||
|
||||
agent, err := assistant.Get("tests.create")
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to get assistant: %s", err.Error())
|
||||
}
|
||||
|
||||
if agent.HookScript == nil {
|
||||
b.Fatalf("Assistant has no script")
|
||||
}
|
||||
|
||||
scenarios := getBusinessScenarios()
|
||||
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
i := 0
|
||||
for pb.Next() {
|
||||
scenario := scenarios[i%len(scenarios)]
|
||||
ctx := newBenchContext("bench-concurrent-business", "tests.create")
|
||||
_, _, err := agent.HookScript.Create(ctx, []context.Message{
|
||||
{Role: "user", Content: scenario.content},
|
||||
})
|
||||
if err != nil {
|
||||
b.Errorf("%s failed (iteration %d): %s", scenario.name, i, err.Error())
|
||||
}
|
||||
i++
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
// getBusinessScenarios returns the business test scenarios
|
||||
func getBusinessScenarios() []struct {
|
||||
name string
|
||||
content string
|
||||
} {
|
||||
return []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{name: "FullResponse", content: "return_full"},
|
||||
{name: "PartialResponse", content: "return_partial"},
|
||||
{name: "ProcessCall", content: "return_process"},
|
||||
{name: "ContextAdjustment", content: "adjust_context"},
|
||||
{name: "NestedScriptCall", content: "nested_script_call"},
|
||||
{name: "DeepNestedCall", content: "deep_nested_call"},
|
||||
}
|
||||
}
|
||||
|
||||
// newBenchContext creates a minimal context for benchmarking
|
||||
func newBenchContext(chatID, assistantID string) *context.Context {
|
||||
authorized := &types.AuthorizedInfo{
|
||||
Subject: "bench-user",
|
||||
ClientID: "bench-client",
|
||||
UserID: "bench-user-123",
|
||||
TeamID: "bench-team-456",
|
||||
TenantID: "bench-tenant-789",
|
||||
Constraints: types.DataConstraints{
|
||||
TeamOnly: true,
|
||||
Extra: map[string]interface{}{
|
||||
"department": "engineering",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.New(stdContext.Background(), authorized, chatID)
|
||||
ctx.AssistantID = assistantID
|
||||
ctx.Locale = "en-us"
|
||||
ctx.Theme = "light"
|
||||
ctx.Client = context.Client{
|
||||
Type: "web",
|
||||
UserAgent: "BenchAgent/1.0",
|
||||
IP: "127.0.0.1",
|
||||
}
|
||||
ctx.Referer = context.RefererAPI
|
||||
ctx.Accept = context.AcceptWebCUI
|
||||
ctx.Route = ""
|
||||
ctx.Metadata = make(map[string]interface{})
|
||||
return ctx
|
||||
}
|
||||
|
|
@ -1,642 +0,0 @@
|
|||
package hook_test
|
||||
|
||||
import (
|
||||
stdContext "context"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
"github.com/yaoapp/yao/test"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Memory Leak Detection Tests
|
||||
// ============================================================================
|
||||
|
||||
// TestMemoryLeakStandardMode checks for memory leaks in standard V8 mode
|
||||
// Run with: go test -run=TestMemoryLeakStandardMode -v
|
||||
func TestMemoryLeakStandardMode(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.create")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get assistant: %s", err.Error())
|
||||
}
|
||||
|
||||
if agent.HookScript == nil {
|
||||
t.Fatalf("Assistant has no script")
|
||||
}
|
||||
|
||||
// Warm up - execute a few times to stabilize memory
|
||||
for i := 0; i < 10; i++ {
|
||||
ctx := newMemTestContext("warmup", "tests.create")
|
||||
_, _, _ = agent.HookScript.Create(ctx, []context.Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
})
|
||||
ctx.Release()
|
||||
}
|
||||
|
||||
// Force GC and get baseline memory
|
||||
runtime.GC()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
var baseline runtime.MemStats
|
||||
runtime.ReadMemStats(&baseline)
|
||||
|
||||
// Execute many iterations
|
||||
iterations := 1000
|
||||
for i := 0; i < iterations; i++ {
|
||||
ctx := newMemTestContext("mem-test-standard", "tests.create")
|
||||
_, _, err := agent.HookScript.Create(ctx, []context.Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("Create failed at iteration %d: %s", i, err.Error())
|
||||
}
|
||||
|
||||
// Release context resources
|
||||
ctx.Release()
|
||||
|
||||
// Periodic GC to help detect leaks faster
|
||||
if i%100 == 0 {
|
||||
runtime.GC()
|
||||
}
|
||||
}
|
||||
|
||||
// Force GC and check final memory
|
||||
runtime.GC()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
var final runtime.MemStats
|
||||
runtime.ReadMemStats(&final)
|
||||
|
||||
// Calculate memory growth
|
||||
baselineHeap := baseline.HeapAlloc
|
||||
finalHeap := final.HeapAlloc
|
||||
growth := int64(finalHeap) - int64(baselineHeap)
|
||||
growthPerIteration := float64(growth) / float64(iterations)
|
||||
|
||||
t.Logf("Memory Statistics (Standard Mode):")
|
||||
t.Logf(" Iterations: %d", iterations)
|
||||
t.Logf(" Baseline HeapAlloc: %d bytes (%.2f MB)", baselineHeap, float64(baselineHeap)/1024/1024)
|
||||
t.Logf(" Final HeapAlloc: %d bytes (%.2f MB)", finalHeap, float64(finalHeap)/1024/1024)
|
||||
t.Logf(" Total Growth: %d bytes (%.2f MB)", growth, float64(growth)/1024/1024)
|
||||
t.Logf(" Growth per iteration: %.2f bytes", growthPerIteration)
|
||||
t.Logf(" Total Alloc: %d bytes (%.2f MB)", final.TotalAlloc, float64(final.TotalAlloc)/1024/1024)
|
||||
t.Logf(" Mallocs: %d", final.Mallocs)
|
||||
t.Logf(" Frees: %d", final.Frees)
|
||||
t.Logf(" Live Objects: %d", final.Mallocs-final.Frees)
|
||||
t.Logf(" GC Runs: %d", final.NumGC-baseline.NumGC)
|
||||
|
||||
// Check for memory leak
|
||||
// Standard mode creates/disposes isolates per request, so some overhead is expected
|
||||
// Allow up to 20KB growth per iteration as threshold
|
||||
// This accounts for V8 isolate creation/disposal overhead and bridge management
|
||||
// Significant leaks would show much higher growth rates (50KB+)
|
||||
maxGrowthPerIteration := 20480.0 // 20 KB
|
||||
if growthPerIteration > maxGrowthPerIteration {
|
||||
t.Errorf("Possible memory leak detected: %.2f bytes/iteration (threshold: %.2f bytes/iteration)",
|
||||
growthPerIteration, maxGrowthPerIteration)
|
||||
} else {
|
||||
t.Logf("✓ Memory growth is within acceptable range (%.2f bytes/iteration)", growthPerIteration)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMemoryLeakPerformanceMode checks for memory leaks in performance V8 mode
|
||||
// Run with: go test -run=TestMemoryLeakPerformanceMode -v
|
||||
func TestMemoryLeakPerformanceMode(t *testing.T) {
|
||||
testutils.Prepare(t, test.PrepareOption{V8Mode: "performance"})
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.create")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get assistant: %s", err.Error())
|
||||
}
|
||||
|
||||
if agent.HookScript == nil {
|
||||
t.Fatalf("Assistant has no script")
|
||||
}
|
||||
|
||||
// Warm up - execute a few times to stabilize memory and fill isolate pool
|
||||
for i := 0; i < 20; i++ {
|
||||
ctx := newMemTestContext("warmup", "tests.create")
|
||||
_, _, _ = agent.HookScript.Create(ctx, []context.Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
})
|
||||
ctx.Release()
|
||||
}
|
||||
|
||||
// Force GC and get baseline memory
|
||||
runtime.GC()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
var baseline runtime.MemStats
|
||||
runtime.ReadMemStats(&baseline)
|
||||
|
||||
// Execute many iterations
|
||||
iterations := 1000
|
||||
for i := 0; i < iterations; i++ {
|
||||
ctx := newMemTestContext("mem-test-performance", "tests.create")
|
||||
_, _, err := agent.HookScript.Create(ctx, []context.Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("Create failed at iteration %d: %s", i, err.Error())
|
||||
}
|
||||
|
||||
// Release context resources
|
||||
ctx.Release()
|
||||
|
||||
// Periodic GC
|
||||
if i%100 == 0 {
|
||||
runtime.GC()
|
||||
}
|
||||
}
|
||||
|
||||
// Force GC and check final memory
|
||||
runtime.GC()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
var final runtime.MemStats
|
||||
runtime.ReadMemStats(&final)
|
||||
|
||||
// Calculate memory growth
|
||||
baselineHeap := baseline.HeapAlloc
|
||||
finalHeap := final.HeapAlloc
|
||||
growth := int64(finalHeap) - int64(baselineHeap)
|
||||
growthPerIteration := float64(growth) / float64(iterations)
|
||||
|
||||
t.Logf("Memory Statistics (Performance Mode):")
|
||||
t.Logf(" Iterations: %d", iterations)
|
||||
t.Logf(" Baseline HeapAlloc: %d bytes (%.2f MB)", baselineHeap, float64(baselineHeap)/1024/1024)
|
||||
t.Logf(" Final HeapAlloc: %d bytes (%.2f MB)", finalHeap, float64(finalHeap)/1024/1024)
|
||||
t.Logf(" Total Growth: %d bytes (%.2f MB)", growth, float64(growth)/1024/1024)
|
||||
t.Logf(" Growth per iteration: %.2f bytes", growthPerIteration)
|
||||
t.Logf(" Total Alloc: %d bytes (%.2f MB)", final.TotalAlloc, float64(final.TotalAlloc)/1024/1024)
|
||||
t.Logf(" Mallocs: %d", final.Mallocs)
|
||||
t.Logf(" Frees: %d", final.Frees)
|
||||
t.Logf(" Live Objects: %d", final.Mallocs-final.Frees)
|
||||
t.Logf(" GC Runs: %d", final.NumGC-baseline.NumGC)
|
||||
|
||||
// Performance mode should have less growth due to isolate reuse
|
||||
// Allow up to 5KB per iteration as threshold
|
||||
maxGrowthPerIteration := 5120.0
|
||||
if growthPerIteration > maxGrowthPerIteration {
|
||||
t.Errorf("Possible memory leak detected: %.2f bytes/iteration (threshold: %.2f bytes/iteration)",
|
||||
growthPerIteration, maxGrowthPerIteration)
|
||||
} else {
|
||||
t.Logf("✓ Memory growth is within acceptable range")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMemoryLeakBusinessScenarios checks for memory leaks with business logic
|
||||
// Run with: go test -run=TestMemoryLeakBusinessScenarios -v
|
||||
func TestMemoryLeakBusinessScenarios(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.create")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get assistant: %s", err.Error())
|
||||
}
|
||||
|
||||
if agent.HookScript == nil {
|
||||
t.Fatalf("Assistant has no script")
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{name: "FullResponse", content: "return_full"},
|
||||
{name: "PartialResponse", content: "return_partial"},
|
||||
{name: "ProcessCall", content: "return_process"},
|
||||
{name: "ContextAdjustment", content: "adjust_context"},
|
||||
{name: "NestedScriptCall", content: "nested_script_call"},
|
||||
{name: "DeepNestedCall", content: "deep_nested_call"},
|
||||
}
|
||||
|
||||
// Warm up
|
||||
for i := 0; i < 10; i++ {
|
||||
ctx := newMemTestContext("warmup", "tests.create")
|
||||
_, _, _ = agent.HookScript.Create(ctx, []context.Message{
|
||||
{Role: "user", Content: "return_full"},
|
||||
})
|
||||
ctx.Release()
|
||||
}
|
||||
|
||||
// Test each scenario
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
// Get baseline
|
||||
runtime.GC()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
var baseline runtime.MemStats
|
||||
runtime.ReadMemStats(&baseline)
|
||||
|
||||
// Execute iterations (reduced to avoid V8 OOM)
|
||||
iterations := 200
|
||||
for i := 0; i < iterations; i++ {
|
||||
ctx := newMemTestContext("mem-test-business", "tests.create")
|
||||
_, _, err := agent.HookScript.Create(ctx, []context.Message{
|
||||
{Role: "user", Content: scenario.content},
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("Create failed at iteration %d: %s", i, err.Error())
|
||||
}
|
||||
ctx.Release()
|
||||
|
||||
if i%50 == 0 {
|
||||
runtime.GC()
|
||||
}
|
||||
}
|
||||
|
||||
// Check final memory
|
||||
runtime.GC()
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
var final runtime.MemStats
|
||||
runtime.ReadMemStats(&final)
|
||||
|
||||
growth := int64(final.HeapAlloc) - int64(baseline.HeapAlloc)
|
||||
growthPerIteration := float64(growth) / float64(iterations)
|
||||
|
||||
t.Logf(" Baseline HeapAlloc: %d bytes (%.2f MB)", baseline.HeapAlloc, float64(baseline.HeapAlloc)/1024/1024)
|
||||
t.Logf(" Final HeapAlloc: %d bytes (%.2f MB)", final.HeapAlloc, float64(final.HeapAlloc)/1024/1024)
|
||||
t.Logf(" Growth: %d bytes (%.2f MB)", growth, float64(growth)/1024/1024)
|
||||
t.Logf(" Growth/iteration: %.2f bytes", growthPerIteration)
|
||||
|
||||
// Business scenarios may have more memory usage due to complex operations
|
||||
// Allow up to 20KB per iteration as threshold
|
||||
// Note: Some scenarios like ContextAdjustment generate dynamic timestamps,
|
||||
// causing slightly higher memory usage. Real leaks would show 50KB+ growth.
|
||||
maxGrowthPerIteration := 20480.0
|
||||
if growthPerIteration > maxGrowthPerIteration {
|
||||
t.Errorf("Possible memory leak: %.2f bytes/iteration (threshold: %.2f)",
|
||||
growthPerIteration, maxGrowthPerIteration)
|
||||
} else {
|
||||
t.Logf(" ✓ Memory growth is within acceptable range")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMemoryLeakConcurrent checks for memory leaks under concurrent load
|
||||
// Run with: go test -run=TestMemoryLeakConcurrent -v
|
||||
func TestMemoryLeakConcurrent(t *testing.T) {
|
||||
testutils.Prepare(t, test.PrepareOption{V8Mode: "performance"})
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.create")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get assistant: %s", err.Error())
|
||||
}
|
||||
|
||||
if agent.HookScript == nil {
|
||||
t.Fatalf("Assistant has no script")
|
||||
}
|
||||
|
||||
// Warm up
|
||||
for i := 0; i < 20; i++ {
|
||||
ctx := newMemTestContext("warmup", "tests.create")
|
||||
_, _, _ = agent.HookScript.Create(ctx, []context.Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
})
|
||||
ctx.Release()
|
||||
}
|
||||
|
||||
// Get baseline
|
||||
runtime.GC()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
var baseline runtime.MemStats
|
||||
runtime.ReadMemStats(&baseline)
|
||||
|
||||
// Run concurrent load
|
||||
iterations := 1000
|
||||
concurrency := 10
|
||||
iterPerGoroutine := iterations / concurrency
|
||||
|
||||
done := make(chan bool, concurrency)
|
||||
for g := 0; g < concurrency; g++ {
|
||||
go func(id int) {
|
||||
defer func() { done <- true }()
|
||||
for i := 0; i < iterPerGoroutine; i++ {
|
||||
ctx := newMemTestContext("mem-test-concurrent", "tests.create")
|
||||
_, _, err := agent.HookScript.Create(ctx, []context.Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("Goroutine %d failed at iteration %d: %s", id, i, err.Error())
|
||||
}
|
||||
ctx.Release()
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
|
||||
// Wait for all goroutines
|
||||
for g := 0; g < concurrency; g++ {
|
||||
<-done
|
||||
}
|
||||
|
||||
// Check final memory
|
||||
runtime.GC()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
var final runtime.MemStats
|
||||
runtime.ReadMemStats(&final)
|
||||
|
||||
growth := int64(final.HeapAlloc) - int64(baseline.HeapAlloc)
|
||||
growthPerIteration := float64(growth) / float64(iterations)
|
||||
|
||||
t.Logf("Memory Statistics (Concurrent Load):")
|
||||
t.Logf(" Iterations: %d", iterations)
|
||||
t.Logf(" Concurrency: %d", concurrency)
|
||||
t.Logf(" Baseline HeapAlloc: %d bytes (%.2f MB)", baseline.HeapAlloc, float64(baseline.HeapAlloc)/1024/1024)
|
||||
t.Logf(" Final HeapAlloc: %d bytes (%.2f MB)", final.HeapAlloc, float64(final.HeapAlloc)/1024/1024)
|
||||
t.Logf(" Growth: %d bytes (%.2f MB)", growth, float64(growth)/1024/1024)
|
||||
t.Logf(" Growth/iteration: %.2f bytes", growthPerIteration)
|
||||
t.Logf(" GC Runs: %d", final.NumGC-baseline.NumGC)
|
||||
|
||||
// Concurrent scenarios may have slightly more overhead
|
||||
maxGrowthPerIteration := 10240.0
|
||||
if growthPerIteration > maxGrowthPerIteration {
|
||||
t.Errorf("Possible memory leak: %.2f bytes/iteration (threshold: %.2f)",
|
||||
growthPerIteration, maxGrowthPerIteration)
|
||||
} else {
|
||||
t.Logf("✓ Memory growth is within acceptable range")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMemoryLeakNestedCalls checks for memory leaks with nested script calls
|
||||
// Run with: go test -run=TestMemoryLeakNestedCalls -v
|
||||
func TestMemoryLeakNestedCalls(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.create")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get assistant: %s", err.Error())
|
||||
}
|
||||
|
||||
if agent.HookScript == nil {
|
||||
t.Fatalf("Assistant has no script")
|
||||
}
|
||||
|
||||
// Warm up
|
||||
for i := 0; i < 10; i++ {
|
||||
ctx := newMemTestContext("warmup", "tests.create")
|
||||
_, _, _ = agent.HookScript.Create(ctx, []context.Message{
|
||||
{Role: "user", Content: "nested_script_call"},
|
||||
})
|
||||
ctx.Release()
|
||||
}
|
||||
|
||||
// Get baseline
|
||||
runtime.GC()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
var baseline runtime.MemStats
|
||||
runtime.ReadMemStats(&baseline)
|
||||
|
||||
// Execute iterations with nested calls
|
||||
// Nested calls: hook -> scripts.tests.create.NestedCall -> GetRoles/GetRole -> models
|
||||
iterations := 200
|
||||
for i := 0; i < iterations; i++ {
|
||||
ctx := newMemTestContext("mem-test-nested", "tests.create")
|
||||
_, _, err := agent.HookScript.Create(ctx, []context.Message{
|
||||
{Role: "user", Content: "deep_nested_call"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("Nested call failed at iteration %d: %s", i, err.Error())
|
||||
}
|
||||
ctx.Release()
|
||||
|
||||
if i%50 == 0 {
|
||||
runtime.GC()
|
||||
}
|
||||
}
|
||||
|
||||
// Check final memory
|
||||
runtime.GC()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
var final runtime.MemStats
|
||||
runtime.ReadMemStats(&final)
|
||||
|
||||
growth := int64(final.HeapAlloc) - int64(baseline.HeapAlloc)
|
||||
growthPerIteration := float64(growth) / float64(iterations)
|
||||
|
||||
t.Logf("Memory Statistics (Nested Calls):")
|
||||
t.Logf(" Iterations: %d", iterations)
|
||||
t.Logf(" Baseline HeapAlloc: %d bytes (%.2f MB)", baseline.HeapAlloc, float64(baseline.HeapAlloc)/1024/1024)
|
||||
t.Logf(" Final HeapAlloc: %d bytes (%.2f MB)", final.HeapAlloc, float64(final.HeapAlloc)/1024/1024)
|
||||
t.Logf(" Growth: %d bytes (%.2f MB)", growth, float64(growth)/1024/1024)
|
||||
t.Logf(" Growth/iteration: %.2f bytes", growthPerIteration)
|
||||
t.Logf(" GC Runs: %d", final.NumGC-baseline.NumGC)
|
||||
|
||||
// Nested calls involve database operations, so allow more overhead
|
||||
// Allow up to 20KB per iteration as threshold
|
||||
maxGrowthPerIteration := 20480.0
|
||||
if growthPerIteration > maxGrowthPerIteration {
|
||||
t.Errorf("Possible memory leak: %.2f bytes/iteration (threshold: %.2f)",
|
||||
growthPerIteration, maxGrowthPerIteration)
|
||||
} else {
|
||||
t.Logf("✓ Memory growth is within acceptable range")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMemoryLeakNestedConcurrent checks for memory leaks with concurrent nested calls
|
||||
// Run with: go test -run=TestMemoryLeakNestedConcurrent -v
|
||||
func TestMemoryLeakNestedConcurrent(t *testing.T) {
|
||||
testutils.Prepare(t, test.PrepareOption{V8Mode: "performance"})
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.create")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get assistant: %s", err.Error())
|
||||
}
|
||||
|
||||
if agent.HookScript == nil {
|
||||
t.Fatalf("Assistant has no script")
|
||||
}
|
||||
|
||||
// Warm up
|
||||
for i := 0; i < 20; i++ {
|
||||
ctx := newMemTestContext("warmup", "tests.create")
|
||||
_, _, _ = agent.HookScript.Create(ctx, []context.Message{
|
||||
{Role: "user", Content: "nested_script_call"},
|
||||
})
|
||||
ctx.Release()
|
||||
}
|
||||
|
||||
// Get baseline
|
||||
runtime.GC()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
var baseline runtime.MemStats
|
||||
runtime.ReadMemStats(&baseline)
|
||||
|
||||
// Run concurrent nested calls
|
||||
iterations := 500
|
||||
concurrency := 10
|
||||
iterPerGoroutine := iterations / concurrency
|
||||
|
||||
done := make(chan bool, concurrency)
|
||||
for g := 0; g < concurrency; g++ {
|
||||
go func(id int) {
|
||||
defer func() { done <- true }()
|
||||
for i := 0; i < iterPerGoroutine; i++ {
|
||||
ctx := newMemTestContext("mem-test-nested-concurrent", "tests.create")
|
||||
_, _, err := agent.HookScript.Create(ctx, []context.Message{
|
||||
{Role: "user", Content: "deep_nested_call"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("Goroutine %d nested call failed at iteration %d: %s", id, i, err.Error())
|
||||
}
|
||||
ctx.Release()
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
|
||||
// Wait for all goroutines
|
||||
for g := 0; g < concurrency; g++ {
|
||||
<-done
|
||||
}
|
||||
|
||||
// Check final memory
|
||||
runtime.GC()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
var final runtime.MemStats
|
||||
runtime.ReadMemStats(&final)
|
||||
|
||||
growth := int64(final.HeapAlloc) - int64(baseline.HeapAlloc)
|
||||
growthPerIteration := float64(growth) / float64(iterations)
|
||||
|
||||
t.Logf("Memory Statistics (Concurrent Nested Calls):")
|
||||
t.Logf(" Iterations: %d", iterations)
|
||||
t.Logf(" Concurrency: %d", concurrency)
|
||||
t.Logf(" Baseline HeapAlloc: %d bytes (%.2f MB)", baseline.HeapAlloc, float64(baseline.HeapAlloc)/1024/1024)
|
||||
t.Logf(" Final HeapAlloc: %d bytes (%.2f MB)", final.HeapAlloc, float64(final.HeapAlloc)/1024/1024)
|
||||
t.Logf(" Growth: %d bytes (%.2f MB)", growth, float64(growth)/1024/1024)
|
||||
t.Logf(" Growth/iteration: %.2f bytes", growthPerIteration)
|
||||
t.Logf(" GC Runs: %d", final.NumGC-baseline.NumGC)
|
||||
|
||||
// Concurrent nested calls with database operations
|
||||
// Allow up to 25KB per iteration as threshold
|
||||
maxGrowthPerIteration := 25600.0
|
||||
if growthPerIteration > maxGrowthPerIteration {
|
||||
t.Errorf("Possible memory leak: %.2f bytes/iteration (threshold: %.2f)",
|
||||
growthPerIteration, maxGrowthPerIteration)
|
||||
} else {
|
||||
t.Logf("✓ Memory growth is within acceptable range")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsolateDisposal verifies that isolates are properly disposed in standard mode
|
||||
// Run with: go test -run=TestIsolateDisposal -v
|
||||
func TestIsolateDisposal(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.create")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get assistant: %s", err.Error())
|
||||
}
|
||||
|
||||
if agent.HookScript == nil {
|
||||
t.Fatalf("Assistant has no script")
|
||||
}
|
||||
|
||||
// Track goroutine count to detect goroutine leaks
|
||||
initialGoroutines := runtime.NumGoroutine()
|
||||
|
||||
// Execute multiple iterations
|
||||
iterations := 100
|
||||
for i := 0; i < iterations; i++ {
|
||||
ctx := newMemTestContext("disposal-test", "tests.create")
|
||||
_, _, err := agent.HookScript.Create(ctx, []context.Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("Create failed at iteration %d: %s", i, err.Error())
|
||||
}
|
||||
ctx.Release()
|
||||
}
|
||||
|
||||
// Give time for cleanup
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
runtime.GC()
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
finalGoroutines := runtime.NumGoroutine()
|
||||
goroutineGrowth := finalGoroutines - initialGoroutines
|
||||
|
||||
t.Logf("Goroutine Statistics:")
|
||||
t.Logf(" Initial: %d", initialGoroutines)
|
||||
t.Logf(" Final: %d", finalGoroutines)
|
||||
t.Logf(" Growth: %d", goroutineGrowth)
|
||||
|
||||
// Allow some goroutine growth for runtime internals
|
||||
//
|
||||
// ROOT CAUSE ANALYSIS:
|
||||
// Each Create() call creates a Trace, which starts 2 goroutines:
|
||||
// 1. trace/pubsub.(*PubSub).forward() - PubSub event forwarding
|
||||
// 2. trace.(*manager).startStateWorker() - State machine worker
|
||||
//
|
||||
// These goroutines exit when Release() closes their channels, but:
|
||||
// - Exit is ASYNCHRONOUS (goroutine needs to reach select statement)
|
||||
// - Go runtime needs time to schedule and cleanup
|
||||
// - In rapid iterations, new goroutines are created before old ones fully exit
|
||||
//
|
||||
// This is NOT a true leak:
|
||||
// ✓ Goroutines eventually exit (channels are closed)
|
||||
// ✓ No unbounded growth (they will be GC'd)
|
||||
// ✓ Typical pattern for async cleanup in Go
|
||||
//
|
||||
// Acceptable: ~2 goroutines per iteration (trace pubsub + state worker)
|
||||
// Concerning: >5 goroutines per iteration (indicates goroutines NOT exiting)
|
||||
maxGoroutineGrowthPerIteration := 5.0
|
||||
growthPerIteration := float64(goroutineGrowth) / float64(iterations)
|
||||
|
||||
if growthPerIteration > maxGoroutineGrowthPerIteration {
|
||||
t.Errorf("Goroutine leak detected: %.2f goroutines per iteration (threshold: %.2f)",
|
||||
growthPerIteration, maxGoroutineGrowthPerIteration)
|
||||
t.Errorf("This indicates goroutines are NOT being cleaned up properly")
|
||||
} else {
|
||||
t.Logf("✓ Goroutine growth is acceptable: %.2f per iteration", growthPerIteration)
|
||||
t.Logf(" (Trace creates 2 goroutines per call: pubsub.forward + stateWorker)")
|
||||
t.Logf(" (These exit asynchronously after Release(), causing temporary accumulation)")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
// newMemTestContext creates a context for memory leak testing
|
||||
func newMemTestContext(chatID, assistantID string) *context.Context {
|
||||
authorized := &types.AuthorizedInfo{
|
||||
Subject: "mem-test-user",
|
||||
ClientID: "mem-test-client",
|
||||
UserID: "mem-user-123",
|
||||
TeamID: "mem-team-456",
|
||||
TenantID: "mem-tenant-789",
|
||||
Constraints: types.DataConstraints{
|
||||
TeamOnly: true,
|
||||
Extra: map[string]interface{}{
|
||||
"department": "engineering",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.New(stdContext.Background(), authorized, chatID)
|
||||
ctx.AssistantID = assistantID
|
||||
ctx.Locale = "en-us"
|
||||
ctx.Theme = "light"
|
||||
ctx.Client = context.Client{
|
||||
Type: "web",
|
||||
UserAgent: "MemTestAgent/1.0",
|
||||
IP: "127.0.0.1",
|
||||
}
|
||||
ctx.Referer = context.RefererAPI
|
||||
ctx.Accept = context.AcceptWebCUI
|
||||
ctx.Route = ""
|
||||
ctx.Metadata = make(map[string]interface{})
|
||||
return ctx
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
package hook_test
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
)
|
||||
|
||||
// TestNestedScriptCall tests nested script calls with V8 context sharing
|
||||
// This test calls: hook -> scripts.tests.create.NestedCall -> GetRoles/GetRole -> models
|
||||
func TestNestedScriptCall(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.create")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get assistant: %s", err.Error())
|
||||
}
|
||||
|
||||
if agent.HookScript == nil {
|
||||
t.Fatalf("Assistant has no script")
|
||||
}
|
||||
|
||||
// Create context
|
||||
ctx := newTestContext("test-nested-call", "tests.create")
|
||||
|
||||
// Call with deep_nested_call scenario
|
||||
// This will: hook -> scripts.tests.create.NestedCall -> GetRoles -> model
|
||||
res, _, err := agent.HookScript.Create(ctx, []context.Message{
|
||||
{Role: "user", Content: "deep_nested_call"},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Nested call failed: %s", err.Error())
|
||||
}
|
||||
|
||||
if res == nil {
|
||||
t.Fatal("Expected non-nil response")
|
||||
}
|
||||
|
||||
// Verify messages
|
||||
if len(res.Messages) == 0 {
|
||||
t.Fatal("Expected messages in response")
|
||||
}
|
||||
|
||||
t.Logf("✓ Nested script call completed successfully")
|
||||
t.Logf(" Messages count: %d", len(res.Messages))
|
||||
if res.Metadata != nil {
|
||||
t.Logf(" Metadata: %+v", res.Metadata)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNestedScriptCallConcurrent tests nested script calls under high concurrency
|
||||
// Simulates 100 concurrent users making nested script calls
|
||||
func TestNestedScriptCallConcurrent(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.create")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get assistant: %s", err.Error())
|
||||
}
|
||||
|
||||
if agent.HookScript == nil {
|
||||
t.Fatalf("Assistant has no script")
|
||||
}
|
||||
|
||||
// High concurrency test: 100 concurrent users (testing race condition)
|
||||
concurrency := 100
|
||||
iterations := 1 // Each user makes 1 call
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errors := make(chan error, concurrency*iterations)
|
||||
|
||||
t.Logf("Starting concurrent test: %d users × %d iterations = %d total calls",
|
||||
concurrency, iterations, concurrency*iterations)
|
||||
|
||||
for i := 0; i < concurrency; i++ {
|
||||
wg.Add(1)
|
||||
go func(userID int) {
|
||||
defer wg.Done()
|
||||
|
||||
for j := 0; j < iterations; j++ {
|
||||
ctx := newTestContext("test-concurrent", "tests.create")
|
||||
|
||||
_, _, err := agent.HookScript.Create(ctx, []context.Message{
|
||||
{Role: "user", Content: "deep_nested_call"},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
errors <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all goroutines to complete
|
||||
wg.Wait()
|
||||
close(errors)
|
||||
|
||||
// Check for errors
|
||||
errorCount := 0
|
||||
for err := range errors {
|
||||
errorCount++
|
||||
t.Errorf("Concurrent call failed: %s", err.Error())
|
||||
}
|
||||
|
||||
if errorCount > 0 {
|
||||
t.Fatalf("Failed with %d errors out of %d total calls", errorCount, concurrency*iterations)
|
||||
}
|
||||
|
||||
t.Logf("✓ All %d concurrent nested calls completed successfully", concurrency*iterations)
|
||||
}
|
||||
|
|
@ -1,478 +0,0 @@
|
|||
package hook_test
|
||||
|
||||
import (
|
||||
stdContext "context"
|
||||
"testing"
|
||||
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
// newTestContext creates a Context for testing with commonly used fields pre-populated.
|
||||
// You can override any fields after creation as needed for specific test scenarios.
|
||||
func newTestContext(chatID, assistantID string) *context.Context {
|
||||
authorized := &types.AuthorizedInfo{
|
||||
Subject: "test-user",
|
||||
ClientID: "test-client-id",
|
||||
Scope: "openid profile email",
|
||||
SessionID: "test-session-id",
|
||||
UserID: "test-user-123",
|
||||
TeamID: "test-team-456",
|
||||
TenantID: "test-tenant-789",
|
||||
RememberMe: true,
|
||||
Constraints: types.DataConstraints{
|
||||
OwnerOnly: false,
|
||||
CreatorOnly: false,
|
||||
EditorOnly: false,
|
||||
TeamOnly: true,
|
||||
Extra: map[string]interface{}{
|
||||
"department": "engineering",
|
||||
"region": "us-west",
|
||||
"project": "yao",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.New(stdContext.Background(), authorized, chatID)
|
||||
ctx.AssistantID = assistantID
|
||||
ctx.Locale = "en-us"
|
||||
ctx.Theme = "light"
|
||||
ctx.Client = context.Client{
|
||||
Type: "web",
|
||||
UserAgent: "TestAgent/1.0",
|
||||
IP: "127.0.0.1",
|
||||
}
|
||||
ctx.Referer = context.RefererAPI
|
||||
ctx.Accept = context.AcceptWebCUI
|
||||
ctx.Route = ""
|
||||
ctx.Metadata = make(map[string]interface{})
|
||||
return ctx
|
||||
}
|
||||
|
||||
// TestCreate test the create hook
|
||||
func TestCreate(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.create")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get the tests.create assistant: %s", err.Error())
|
||||
}
|
||||
|
||||
if agent.HookScript == nil {
|
||||
t.Fatalf("The tests.create assistant has no script")
|
||||
}
|
||||
|
||||
// Use the helper function to create a test context
|
||||
ctx := newTestContext("chat-test-create-hook", "tests.create")
|
||||
|
||||
// Test scenario 1: Return null (should get nil response)
|
||||
t.Run("ReturnNull", func(t *testing.T) {
|
||||
res, _, err := agent.HookScript.Create(ctx, []context.Message{{Role: "user", Content: "return_null"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create with null return: %s", err.Error())
|
||||
}
|
||||
if res != nil {
|
||||
t.Errorf("Expected nil response for null return, got: %v", res)
|
||||
}
|
||||
})
|
||||
|
||||
// Test scenario 2: Return undefined (should get nil response)
|
||||
t.Run("ReturnUndefined", func(t *testing.T) {
|
||||
res, _, err := agent.HookScript.Create(ctx, []context.Message{{Role: "user", Content: "return_undefined"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create with undefined return: %s", err.Error())
|
||||
}
|
||||
if res != nil {
|
||||
t.Errorf("Expected nil response for undefined return, got: %v", res)
|
||||
}
|
||||
})
|
||||
|
||||
// Test scenario 3: Return empty object (should get empty HookCreateResponse)
|
||||
t.Run("ReturnEmpty", func(t *testing.T) {
|
||||
res, _, err := agent.HookScript.Create(ctx, []context.Message{{Role: "user", Content: "return_empty"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create with empty return: %s", err.Error())
|
||||
}
|
||||
if res == nil {
|
||||
t.Fatalf("Expected non-nil response for empty object, got nil")
|
||||
}
|
||||
if len(res.Messages) != 0 {
|
||||
t.Errorf("Expected empty messages, got: %d messages", len(res.Messages))
|
||||
}
|
||||
})
|
||||
|
||||
// Test scenario 4: Return full response with all fields
|
||||
t.Run("ReturnFull", func(t *testing.T) {
|
||||
res, _, err := agent.HookScript.Create(ctx, []context.Message{{Role: "user", Content: "return_full"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create with full return: %s", err.Error())
|
||||
}
|
||||
if res == nil {
|
||||
t.Fatalf("Expected non-nil response, got nil")
|
||||
}
|
||||
|
||||
// Verify messages
|
||||
if len(res.Messages) != 2 {
|
||||
t.Errorf("Expected 2 messages, got: %d", len(res.Messages))
|
||||
} else {
|
||||
if res.Messages[0].Role != context.RoleSystem {
|
||||
t.Errorf("Expected system role for first message, got: %s", res.Messages[0].Role)
|
||||
}
|
||||
if res.Messages[1].Role != context.RoleUser {
|
||||
t.Errorf("Expected user role for second message, got: %s", res.Messages[1].Role)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify audio config
|
||||
if res.Audio == nil {
|
||||
t.Error("Expected audio config, got nil")
|
||||
} else {
|
||||
if res.Audio.Voice != "alloy" {
|
||||
t.Errorf("Expected voice 'alloy', got: %s", res.Audio.Voice)
|
||||
}
|
||||
if res.Audio.Format != "mp3" {
|
||||
t.Errorf("Expected format 'mp3', got: %s", res.Audio.Format)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify temperature
|
||||
if res.Temperature == nil {
|
||||
t.Error("Expected temperature, got nil")
|
||||
} else if *res.Temperature != 0.7 {
|
||||
t.Errorf("Expected temperature 0.7, got: %f", *res.Temperature)
|
||||
}
|
||||
|
||||
// Verify max_tokens
|
||||
if res.MaxTokens == nil {
|
||||
t.Error("Expected max_tokens, got nil")
|
||||
} else if *res.MaxTokens != 2000 {
|
||||
t.Errorf("Expected max_tokens 2000, got: %d", *res.MaxTokens)
|
||||
}
|
||||
|
||||
// Verify max_completion_tokens
|
||||
if res.MaxCompletionTokens == nil {
|
||||
t.Error("Expected max_completion_tokens, got nil")
|
||||
} else if *res.MaxCompletionTokens != 1500 {
|
||||
t.Errorf("Expected max_completion_tokens 1500, got: %d", *res.MaxCompletionTokens)
|
||||
}
|
||||
})
|
||||
|
||||
// Test scenario 5: Return partial response
|
||||
t.Run("ReturnPartial", func(t *testing.T) {
|
||||
res, _, err := agent.HookScript.Create(ctx, []context.Message{{Role: "user", Content: "return_partial"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create with partial return: %s", err.Error())
|
||||
}
|
||||
if res == nil {
|
||||
t.Fatalf("Expected non-nil response, got nil")
|
||||
}
|
||||
|
||||
// Verify messages
|
||||
if len(res.Messages) != 1 {
|
||||
t.Errorf("Expected 1 message, got: %d", len(res.Messages))
|
||||
}
|
||||
|
||||
// Verify temperature
|
||||
if res.Temperature == nil {
|
||||
t.Error("Expected temperature, got nil")
|
||||
} else if *res.Temperature != 0.5 {
|
||||
t.Errorf("Expected temperature 0.5, got: %f", *res.Temperature)
|
||||
}
|
||||
|
||||
// Verify optional fields are nil
|
||||
if res.Audio != nil {
|
||||
t.Errorf("Expected audio to be nil, got: %v", res.Audio)
|
||||
}
|
||||
if res.MaxTokens != nil {
|
||||
t.Errorf("Expected max_tokens to be nil, got: %d", *res.MaxTokens)
|
||||
}
|
||||
})
|
||||
|
||||
// Test scenario 6: Process call - calls models.__yao.role.Get and adds to messages
|
||||
t.Run("ReturnProcess", func(t *testing.T) {
|
||||
res, _, err := agent.HookScript.Create(ctx, []context.Message{{Role: "user", Content: "return_process"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create with process return: %s", err.Error())
|
||||
}
|
||||
if res == nil {
|
||||
t.Fatalf("Expected non-nil response, got nil")
|
||||
}
|
||||
|
||||
// Verify messages - should have at least 1 (system message)
|
||||
if len(res.Messages) < 1 {
|
||||
t.Errorf("Expected at least 1 message, got: %d", len(res.Messages))
|
||||
} else {
|
||||
// First message should be system role
|
||||
if res.Messages[0].Role != context.RoleSystem {
|
||||
t.Errorf("Expected system role for first message, got: %s", res.Messages[0].Role)
|
||||
}
|
||||
// Check system message content
|
||||
if content, ok := res.Messages[0].Content.(string); ok {
|
||||
if content != "Here are the available roles in the system:" {
|
||||
t.Errorf("Unexpected system message content: %s", content)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Test scenario 7: Default response
|
||||
t.Run("ReturnDefault", func(t *testing.T) {
|
||||
testContent := "Hello, how are you?"
|
||||
res, _, err := agent.HookScript.Create(ctx, []context.Message{{Role: "user", Content: testContent}})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create with default return: %s", err.Error())
|
||||
}
|
||||
if res == nil {
|
||||
t.Fatalf("Expected non-nil response, got nil")
|
||||
}
|
||||
|
||||
// Verify messages
|
||||
if len(res.Messages) != 1 {
|
||||
t.Errorf("Expected 1 message, got: %d", len(res.Messages))
|
||||
} else {
|
||||
if res.Messages[0].Role != context.RoleUser {
|
||||
t.Errorf("Expected user role, got: %s", res.Messages[0].Role)
|
||||
}
|
||||
if content, ok := res.Messages[0].Content.(string); ok {
|
||||
if content != testContent {
|
||||
t.Errorf("Expected content '%s', got: '%s'", testContent, content)
|
||||
}
|
||||
} else {
|
||||
t.Errorf("Expected string content, got: %T", res.Messages[0].Content)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Test scenario 8: Verify context fields - validates all context fields in JavaScript
|
||||
t.Run("VerifyContext", func(t *testing.T) {
|
||||
res, _, err := agent.HookScript.Create(ctx, []context.Message{{Role: "user", Content: "verify_context"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create with verify_context: %s", err.Error())
|
||||
}
|
||||
if res == nil {
|
||||
t.Fatalf("Expected non-nil response, got nil")
|
||||
}
|
||||
|
||||
// Verify we have messages
|
||||
if len(res.Messages) < 1 {
|
||||
t.Fatalf("Expected at least 1 message, got: %d", len(res.Messages))
|
||||
}
|
||||
|
||||
// First message should be system role with success/failure indicator
|
||||
if res.Messages[0].Role != context.RoleSystem {
|
||||
t.Errorf("Expected system role for first message, got: %s", res.Messages[0].Role)
|
||||
}
|
||||
|
||||
// Check the validation result
|
||||
content, ok := res.Messages[0].Content.(string)
|
||||
if !ok {
|
||||
t.Fatalf("Expected string content for system message, got: %T", res.Messages[0].Content)
|
||||
}
|
||||
|
||||
// The content should be "success:all_fields_validated"
|
||||
if content != "success:all_fields_validated" {
|
||||
t.Errorf("Context validation failed: %s", content)
|
||||
|
||||
// Print detailed validation results if available
|
||||
if len(res.Messages) > 1 {
|
||||
if details, ok := res.Messages[1].Content.(string); ok {
|
||||
t.Logf("Validation details:\n%s", details)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
t.Log("✓ All context fields validated successfully in JavaScript")
|
||||
|
||||
// Optionally print validation details
|
||||
if len(res.Messages) > 1 {
|
||||
if details, ok := res.Messages[1].Content.(string); ok {
|
||||
t.Logf("Validation details:\n%s", details)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Test scenario 9: Adjust context fields - tests that context fields can be modified by the hook
|
||||
t.Run("AdjustContext", func(t *testing.T) {
|
||||
// Create a fresh context for this test
|
||||
adjustCtx := newTestContext("chat-test-adjust", "tests.create")
|
||||
|
||||
// Call the hook which should adjust context fields
|
||||
res, _, err := agent.HookScript.Create(adjustCtx, []context.Message{{Role: "user", Content: "adjust_context"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create with adjust_context: %s", err.Error())
|
||||
}
|
||||
if res == nil {
|
||||
t.Fatalf("Expected non-nil response, got nil")
|
||||
}
|
||||
|
||||
// Verify the response contains adjusted fields
|
||||
// Note: AssistantID cannot be overridden by hooks, removed from HookCreateResponse
|
||||
if res.Connector != "adjusted-connector" {
|
||||
t.Errorf("Expected adjusted connector 'adjusted-connector', got: %s", res.Connector)
|
||||
}
|
||||
if res.Locale != "zh-cn" {
|
||||
t.Errorf("Expected adjusted locale 'zh-cn', got: %s", res.Locale)
|
||||
}
|
||||
if res.Theme != "dark" {
|
||||
t.Errorf("Expected adjusted theme 'dark', got: %s", res.Theme)
|
||||
}
|
||||
if res.Route != "/adjusted/route" {
|
||||
t.Errorf("Expected adjusted route '/adjusted/route', got: %s", res.Route)
|
||||
}
|
||||
|
||||
// Verify metadata
|
||||
if res.Metadata == nil {
|
||||
t.Fatalf("Expected metadata, got nil")
|
||||
}
|
||||
if adjusted, ok := res.Metadata["adjusted"].(bool); !ok || !adjusted {
|
||||
t.Errorf("Expected metadata['adjusted'] = true, got: %v", res.Metadata["adjusted"])
|
||||
}
|
||||
|
||||
// Verify context fields were actually updated
|
||||
// Note: AssistantID is immutable and cannot be overridden
|
||||
// Note: Connector is now in Options, not in Context
|
||||
if adjustCtx.Locale != "zh-cn" {
|
||||
t.Errorf("Context locale not updated. Expected 'zh-cn', got: %s", adjustCtx.Locale)
|
||||
}
|
||||
if adjustCtx.Theme != "dark" {
|
||||
t.Errorf("Context theme not updated. Expected 'dark', got: %s", adjustCtx.Theme)
|
||||
}
|
||||
if adjustCtx.Route != "/adjusted/route" {
|
||||
t.Errorf("Context route not updated. Expected '/adjusted/route', got: %s", adjustCtx.Route)
|
||||
}
|
||||
if adjustCtx.Metadata["adjusted"] != true {
|
||||
t.Errorf("Context metadata not updated. Expected metadata['adjusted'] = true, got: %v", adjustCtx.Metadata["adjusted"])
|
||||
}
|
||||
|
||||
t.Log("✓ Context fields successfully adjusted by hook")
|
||||
})
|
||||
|
||||
// Test scenario 10: Adjust uses configuration - tests that uses can be modified by the hook
|
||||
t.Run("AdjustUses", func(t *testing.T) {
|
||||
// Create a fresh context for this test
|
||||
usesCtx := newTestContext("chat-test-uses", "tests.create")
|
||||
|
||||
// Call the hook which should adjust uses configuration
|
||||
res, _, err := agent.HookScript.Create(usesCtx, []context.Message{{Role: "user", Content: "adjust_uses"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create with adjust_uses: %s", err.Error())
|
||||
}
|
||||
if res == nil {
|
||||
t.Fatalf("Expected non-nil response, got nil")
|
||||
}
|
||||
|
||||
// Verify the response contains uses configuration
|
||||
if res.Uses == nil {
|
||||
t.Fatalf("Expected uses configuration, got nil")
|
||||
}
|
||||
|
||||
// Verify each uses field
|
||||
if res.Uses.Vision != "mcp:vision-server" {
|
||||
t.Errorf("Expected vision 'mcp:vision-server', got: %s", res.Uses.Vision)
|
||||
}
|
||||
if res.Uses.Audio != "mcp:audio-server" {
|
||||
t.Errorf("Expected audio 'mcp:audio-server', got: %s", res.Uses.Audio)
|
||||
}
|
||||
if res.Uses.Search != "agent" {
|
||||
t.Errorf("Expected search 'agent', got: %s", res.Uses.Search)
|
||||
}
|
||||
if res.Uses.Fetch != "mcp:fetch-server" {
|
||||
t.Errorf("Expected fetch 'mcp:fetch-server', got: %s", res.Uses.Fetch)
|
||||
}
|
||||
|
||||
// Verify metadata
|
||||
if res.Metadata == nil {
|
||||
t.Fatalf("Expected metadata, got nil")
|
||||
}
|
||||
if usesAdjusted, ok := res.Metadata["uses_adjusted"].(bool); !ok || !usesAdjusted {
|
||||
t.Errorf("Expected metadata['uses_adjusted'] = true, got: %v", res.Metadata["uses_adjusted"])
|
||||
}
|
||||
|
||||
// Now test that BuildRequest properly applies the uses configuration
|
||||
inputMessages := []context.Message{{Role: "user", Content: "test uses"}}
|
||||
_, options, err := agent.BuildRequest(usesCtx, inputMessages, res)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to build request: %s", err.Error())
|
||||
}
|
||||
|
||||
// Verify that options.Uses has the values from createResponse
|
||||
if options.Uses == nil {
|
||||
t.Fatalf("Expected options.Uses to be set, got nil")
|
||||
}
|
||||
if options.Uses.Vision != "mcp:vision-server" {
|
||||
t.Errorf("Expected options.Uses.Vision 'mcp:vision-server', got: %s", options.Uses.Vision)
|
||||
}
|
||||
if options.Uses.Audio != "mcp:audio-server" {
|
||||
t.Errorf("Expected options.Uses.Audio 'mcp:audio-server', got: %s", options.Uses.Audio)
|
||||
}
|
||||
if options.Uses.Search != "agent" {
|
||||
t.Errorf("Expected options.Uses.Search 'agent', got: %s", options.Uses.Search)
|
||||
}
|
||||
if options.Uses.Fetch != "mcp:fetch-server" {
|
||||
t.Errorf("Expected options.Uses.Fetch 'mcp:fetch-server', got: %s", options.Uses.Fetch)
|
||||
}
|
||||
|
||||
t.Log("✓ Uses configuration successfully adjusted by hook and applied to options")
|
||||
})
|
||||
|
||||
// Test scenario 11: Adjust uses configuration with force_uses flag
|
||||
t.Run("AdjustUsesForce", func(t *testing.T) {
|
||||
// Create a fresh context for this test
|
||||
usesCtx := newTestContext("chat-test-uses-force", "tests.create")
|
||||
|
||||
// Call the hook which should adjust uses configuration and set force_uses
|
||||
res, _, err := agent.HookScript.Create(usesCtx, []context.Message{{Role: "user", Content: "adjust_uses_force"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create with adjust_uses_force: %s", err.Error())
|
||||
}
|
||||
if res == nil {
|
||||
t.Fatalf("Expected non-nil response, got nil")
|
||||
}
|
||||
|
||||
// Verify the response contains uses configuration
|
||||
if res.Uses == nil {
|
||||
t.Fatalf("Expected uses configuration, got nil")
|
||||
}
|
||||
|
||||
// Verify uses fields
|
||||
if res.Uses.Vision != "tests.vision-helper" {
|
||||
t.Errorf("Expected vision 'tests.vision-helper', got: %s", res.Uses.Vision)
|
||||
}
|
||||
if res.Uses.Audio != "mcp:audio-server" {
|
||||
t.Errorf("Expected audio 'mcp:audio-server', got: %s", res.Uses.Audio)
|
||||
}
|
||||
|
||||
// Verify force_uses flag
|
||||
if res.ForceUses == nil {
|
||||
t.Fatalf("Expected force_uses to be set, got nil")
|
||||
}
|
||||
if !*res.ForceUses {
|
||||
t.Errorf("Expected force_uses to be true, got: %v", *res.ForceUses)
|
||||
}
|
||||
|
||||
// Verify metadata
|
||||
if res.Metadata == nil {
|
||||
t.Fatalf("Expected metadata, got nil")
|
||||
}
|
||||
if usesForced, ok := res.Metadata["uses_forced"].(bool); !ok || !usesForced {
|
||||
t.Errorf("Expected metadata['uses_forced'] = true, got: %v", res.Metadata["uses_forced"])
|
||||
}
|
||||
|
||||
// Now test that BuildRequest properly applies the force_uses flag
|
||||
inputMessages := []context.Message{{Role: "user", Content: "test force uses"}}
|
||||
_, options, err := agent.BuildRequest(usesCtx, inputMessages, res)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to build request: %s", err.Error())
|
||||
}
|
||||
|
||||
// Verify that options.ForceUses is true
|
||||
if !options.ForceUses {
|
||||
t.Errorf("Expected options.ForceUses to be true, got: %v", options.ForceUses)
|
||||
}
|
||||
|
||||
t.Log("✓ Uses configuration with force_uses flag successfully adjusted by hook and applied to options")
|
||||
})
|
||||
}
|
||||
|
|
@ -1,324 +0,0 @@
|
|||
package hook_test
|
||||
|
||||
import (
|
||||
stdContext "context"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"runtime/pprof"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
// TestGoroutineLeakDetailed performs detailed goroutine leak analysis
|
||||
func TestGoroutineLeakDetailed(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.create")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get assistant: %s", err.Error())
|
||||
}
|
||||
|
||||
if agent.HookScript == nil {
|
||||
t.Fatalf("Assistant has no script")
|
||||
}
|
||||
|
||||
// Create profile directory
|
||||
os.MkdirAll("/tmp/goroutine_profiles", 0755)
|
||||
|
||||
// Take initial snapshot
|
||||
runtime.GC()
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
initialGoroutines := runtime.NumGoroutine()
|
||||
|
||||
// Save initial profile
|
||||
saveGoroutineProfile("/tmp/goroutine_profiles/00_initial.txt")
|
||||
t.Logf("Initial goroutines: %d", initialGoroutines)
|
||||
|
||||
// Test with just 10 iterations to see the pattern
|
||||
iterations := 10
|
||||
for i := 0; i < iterations; i++ {
|
||||
ctx := newLeakTestContext(fmt.Sprintf("leak-test-%d", i), "tests.create")
|
||||
|
||||
_, _, err := agent.HookScript.Create(ctx, []context.Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("Create failed at iteration %d: %s", i, err.Error())
|
||||
}
|
||||
|
||||
// Release context
|
||||
ctx.Release()
|
||||
|
||||
// Check goroutines after each iteration
|
||||
current := runtime.NumGoroutine()
|
||||
growth := current - initialGoroutines
|
||||
t.Logf("After iteration %d: %d goroutines (growth: %d)", i+1, current, growth)
|
||||
|
||||
// Save profile every 5 iterations
|
||||
if (i+1)%5 == 0 {
|
||||
saveGoroutineProfile(fmt.Sprintf("/tmp/goroutine_profiles/%02d_after_iter_%d.txt", i+1, i+1))
|
||||
}
|
||||
}
|
||||
|
||||
// Force cleanup
|
||||
runtime.GC()
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
finalGoroutines := runtime.NumGoroutine()
|
||||
growth := finalGoroutines - initialGoroutines
|
||||
|
||||
t.Logf("\n=== SUMMARY ===")
|
||||
t.Logf("Initial: %d goroutines", initialGoroutines)
|
||||
t.Logf("Final: %d goroutines", finalGoroutines)
|
||||
t.Logf("Growth: %d goroutines (%.2f per iteration)", growth, float64(growth)/float64(iterations))
|
||||
|
||||
// Save final profile
|
||||
saveGoroutineProfile("/tmp/goroutine_profiles/99_final.txt")
|
||||
|
||||
// Analyze the leak
|
||||
t.Logf("\n=== ANALYSIS ===")
|
||||
analyzeGoroutineProfiles(t, "/tmp/goroutine_profiles")
|
||||
}
|
||||
|
||||
// TestGoroutineLeakByComponent tests each component separately
|
||||
func TestGoroutineLeakByComponent(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.create")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get assistant: %s", err.Error())
|
||||
}
|
||||
|
||||
os.MkdirAll("/tmp/component_profiles", 0755)
|
||||
|
||||
t.Run("ContextCreationOnly", func(t *testing.T) {
|
||||
runtime.GC()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
initial := runtime.NumGoroutine()
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
ctx := newLeakTestContext(fmt.Sprintf("test-%d", i), "tests.create")
|
||||
_ = ctx
|
||||
ctx.Release()
|
||||
}
|
||||
|
||||
runtime.GC()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
final := runtime.NumGoroutine()
|
||||
|
||||
t.Logf("Context creation: initial=%d, final=%d, growth=%d", initial, final, final-initial)
|
||||
})
|
||||
|
||||
t.Run("ScriptExecutionOnly", func(t *testing.T) {
|
||||
runtime.GC()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
initial := runtime.NumGoroutine()
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
ctx := newLeakTestContext(fmt.Sprintf("test-%d", i), "tests.create")
|
||||
_, _, _ = agent.HookScript.Create(ctx, []context.Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
})
|
||||
ctx.Release()
|
||||
}
|
||||
|
||||
runtime.GC()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
final := runtime.NumGoroutine()
|
||||
|
||||
t.Logf("Script execution: initial=%d, final=%d, growth=%d", initial, final, final-initial)
|
||||
saveGoroutineProfile("/tmp/component_profiles/script_execution.txt")
|
||||
})
|
||||
|
||||
t.Run("TraceOperations", func(t *testing.T) {
|
||||
runtime.GC()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
initial := runtime.NumGoroutine()
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
ctx := newLeakTestContext(fmt.Sprintf("test-%d", i), "tests.create")
|
||||
|
||||
// Create trace
|
||||
trace, err := ctx.Trace()
|
||||
if err == nil && trace != nil {
|
||||
// Trace operations
|
||||
_ = trace
|
||||
}
|
||||
|
||||
ctx.Release()
|
||||
}
|
||||
|
||||
runtime.GC()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
final := runtime.NumGoroutine()
|
||||
|
||||
t.Logf("Trace operations: initial=%d, final=%d, growth=%d", initial, final, final-initial)
|
||||
saveGoroutineProfile("/tmp/component_profiles/trace_operations.txt")
|
||||
})
|
||||
}
|
||||
|
||||
// TestGoroutineLeakWithoutRelease tests if Release() fixes the leak
|
||||
func TestGoroutineLeakWithoutRelease(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.create")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get assistant: %s", err.Error())
|
||||
}
|
||||
|
||||
t.Run("WithoutRelease", func(t *testing.T) {
|
||||
runtime.GC()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
initial := runtime.NumGoroutine()
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
ctx := newLeakTestContext(fmt.Sprintf("no-release-%d", i), "tests.create")
|
||||
_, _, _ = agent.HookScript.Create(ctx, []context.Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
})
|
||||
// Intentionally NOT calling ctx.Release()
|
||||
}
|
||||
|
||||
runtime.GC()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
final := runtime.NumGoroutine()
|
||||
|
||||
t.Logf("WITHOUT Release: initial=%d, final=%d, growth=%d (%.1f per iter)",
|
||||
initial, final, final-initial, float64(final-initial)/10.0)
|
||||
})
|
||||
|
||||
t.Run("WithRelease", func(t *testing.T) {
|
||||
runtime.GC()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
initial := runtime.NumGoroutine()
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
ctx := newLeakTestContext(fmt.Sprintf("with-release-%d", i), "tests.create")
|
||||
_, _, _ = agent.HookScript.Create(ctx, []context.Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
})
|
||||
ctx.Release() // WITH Release
|
||||
}
|
||||
|
||||
runtime.GC()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
final := runtime.NumGoroutine()
|
||||
|
||||
t.Logf("WITH Release: initial=%d, final=%d, growth=%d (%.1f per iter)",
|
||||
initial, final, final-initial, float64(final-initial)/10.0)
|
||||
})
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func saveGoroutineProfile(filename string) {
|
||||
f, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
pprof.Lookup("goroutine").WriteTo(f, 2) // detail level 2
|
||||
}
|
||||
|
||||
func analyzeGoroutineProfiles(t *testing.T, dir string) {
|
||||
// Read initial and final profiles
|
||||
initialData, err := os.ReadFile(dir + "/00_initial.txt")
|
||||
if err != nil {
|
||||
t.Logf("Could not read initial profile: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
finalData, err := os.ReadFile(dir + "/99_final.txt")
|
||||
if err != nil {
|
||||
t.Logf("Could not read final profile: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Count goroutines by function
|
||||
initialFuncs := countGoroutinesByFunction(string(initialData))
|
||||
finalFuncs := countGoroutinesByFunction(string(finalData))
|
||||
|
||||
t.Logf("\nGoroutine growth by function:")
|
||||
t.Logf("%-60s %8s %8s %8s", "Function", "Initial", "Final", "Growth")
|
||||
t.Logf("%s", strings.Repeat("-", 90))
|
||||
|
||||
// Find functions that grew
|
||||
for fn, finalCount := range finalFuncs {
|
||||
initialCount := initialFuncs[fn]
|
||||
growth := finalCount - initialCount
|
||||
if growth > 0 {
|
||||
t.Logf("%-60s %8d %8d %8d", truncate(fn, 60), initialCount, finalCount, growth)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("\nProfiles saved to: %s", dir)
|
||||
t.Logf("To compare: diff %s/00_initial.txt %s/99_final.txt | grep '^>'", dir, dir)
|
||||
}
|
||||
|
||||
func countGoroutinesByFunction(profile string) map[string]int {
|
||||
counts := make(map[string]int)
|
||||
lines := strings.Split(profile, "\n")
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
// Look for function names in goroutine stack traces
|
||||
if strings.Contains(line, "(") && !strings.HasPrefix(line, "#") {
|
||||
// Extract function name
|
||||
if idx := strings.Index(line, "("); idx > 0 {
|
||||
fn := strings.TrimSpace(line[:idx])
|
||||
counts[fn]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return counts
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max-3] + "..."
|
||||
}
|
||||
|
||||
func newLeakTestContext(chatID, assistantID string) *context.Context {
|
||||
authorized := &types.AuthorizedInfo{
|
||||
Subject: "leak-test-user",
|
||||
ClientID: "leak-test-client",
|
||||
UserID: "leak-user-123",
|
||||
TeamID: "leak-team-456",
|
||||
TenantID: "leak-tenant-789",
|
||||
Constraints: types.DataConstraints{
|
||||
TeamOnly: true,
|
||||
Extra: map[string]interface{}{
|
||||
"department": "testing",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.New(stdContext.Background(), authorized, chatID)
|
||||
ctx.AssistantID = assistantID
|
||||
ctx.Locale = "en-us"
|
||||
ctx.Theme = "light"
|
||||
ctx.Client = context.Client{
|
||||
Type: "web",
|
||||
UserAgent: "LeakTestAgent/1.0",
|
||||
IP: "127.0.0.1",
|
||||
}
|
||||
ctx.Referer = context.RefererAPI
|
||||
ctx.Accept = context.AcceptWebCUI
|
||||
ctx.Route = ""
|
||||
ctx.Metadata = make(map[string]interface{})
|
||||
return ctx
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
package hook
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
package hook
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/yaoapp/gou/runtime/v8/bridge"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
)
|
||||
|
||||
// Next next hook for the next action after the completion
|
||||
// opts is optional - if provided, will be passed to the hook
|
||||
func (s *Script) Next(ctx *context.Context, payload *context.NextHookPayload, opts ...*context.Options) (*context.NextHookResponse, *context.Options, error) {
|
||||
// Get or create options
|
||||
var options *context.Options
|
||||
if len(opts) > 0 && opts[0] != nil {
|
||||
options = opts[0]
|
||||
} else {
|
||||
options = &context.Options{}
|
||||
}
|
||||
|
||||
// Convert payload to map for JS (use JSON tag names)
|
||||
payloadMap := map[string]interface{}{
|
||||
"messages": payload.Messages,
|
||||
"completion": payload.Completion,
|
||||
"tools": payload.Tools,
|
||||
"error": payload.Error,
|
||||
}
|
||||
|
||||
// Execute hook with ctx, payload, and options (convert options to map for JS)
|
||||
optionsMap := options.ToMap()
|
||||
res, err := s.Execute(ctx, "Next", payloadMap, optionsMap)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
response, err := s.getNextHookResponse(res)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return response, options, nil
|
||||
}
|
||||
|
||||
// getNextHookResponse convert the result to a NextHookResponse
|
||||
func (s *Script) getNextHookResponse(res interface{}) (*context.NextHookResponse, error) {
|
||||
// Handle nil result
|
||||
if res == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Handle undefined result (treat as nil)
|
||||
if _, ok := res.(bridge.UndefinedT); ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Marshal to JSON and unmarshal to NextHookResponse
|
||||
raw, err := json.Marshal(res)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal Next hook result: %w", err)
|
||||
}
|
||||
|
||||
var response context.NextHookResponse
|
||||
if err := json.Unmarshal(raw, &response); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal to NextHookResponse: %w", err)
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
|
|
@ -1,436 +0,0 @@
|
|||
package hook_test
|
||||
|
||||
import (
|
||||
stdContext "context"
|
||||
"testing"
|
||||
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/output/message"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
// newTestContextForNext creates a Context for testing Next Hook with commonly used fields pre-populated.
|
||||
// You can override any fields after creation as needed for specific test scenarios.
|
||||
func newTestContextForNext(chatID, assistantID string) *context.Context {
|
||||
authorized := &types.AuthorizedInfo{
|
||||
Subject: "test-user",
|
||||
ClientID: "test-client-id",
|
||||
Scope: "openid profile email",
|
||||
SessionID: "test-session-id",
|
||||
UserID: "test-user-123",
|
||||
TeamID: "test-team-456",
|
||||
TenantID: "test-tenant-789",
|
||||
RememberMe: true,
|
||||
Constraints: types.DataConstraints{
|
||||
OwnerOnly: false,
|
||||
CreatorOnly: false,
|
||||
EditorOnly: false,
|
||||
TeamOnly: true,
|
||||
Extra: map[string]interface{}{
|
||||
"department": "engineering",
|
||||
"region": "us-west",
|
||||
"project": "yao",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.New(stdContext.Background(), authorized, chatID)
|
||||
ctx.AssistantID = assistantID
|
||||
ctx.Locale = "en-us"
|
||||
ctx.Theme = "light"
|
||||
ctx.Client = context.Client{
|
||||
Type: "web",
|
||||
UserAgent: "TestAgent/1.0",
|
||||
IP: "127.0.0.1",
|
||||
}
|
||||
ctx.Referer = context.RefererAPI
|
||||
ctx.Accept = context.AcceptWebCUI
|
||||
ctx.Route = ""
|
||||
ctx.Metadata = make(map[string]interface{})
|
||||
return ctx
|
||||
}
|
||||
|
||||
// TestNext tests the Next hook
|
||||
func TestNext(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.next")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get the tests.next assistant: %s", err.Error())
|
||||
}
|
||||
|
||||
if agent.HookScript == nil {
|
||||
t.Fatalf("The tests.next assistant has no script")
|
||||
}
|
||||
|
||||
// Use the helper function to create a test context
|
||||
ctx := newTestContextForNext("chat-test-next-hook", "tests.next")
|
||||
|
||||
// Test scenario 1: Return null (should get nil response)
|
||||
t.Run("ReturnNull", func(t *testing.T) {
|
||||
payload := &context.NextHookPayload{
|
||||
Messages: []context.Message{
|
||||
{Role: context.RoleUser, Content: "return_null"},
|
||||
},
|
||||
Completion: &context.CompletionResponse{
|
||||
Content: "Test completion",
|
||||
},
|
||||
Tools: nil,
|
||||
Error: "",
|
||||
}
|
||||
|
||||
res, _, err := agent.HookScript.Next(ctx, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to execute Next hook with null return: %s", err.Error())
|
||||
}
|
||||
if res != nil {
|
||||
t.Errorf("Expected nil response for null return, got: %v", res)
|
||||
}
|
||||
})
|
||||
|
||||
// Test scenario 2: Return undefined (should get nil response)
|
||||
t.Run("ReturnUndefined", func(t *testing.T) {
|
||||
payload := &context.NextHookPayload{
|
||||
Messages: []context.Message{
|
||||
{Role: context.RoleUser, Content: "return_undefined"},
|
||||
},
|
||||
Completion: &context.CompletionResponse{
|
||||
Content: "Test completion",
|
||||
},
|
||||
}
|
||||
|
||||
res, _, err := agent.HookScript.Next(ctx, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to execute Next hook with undefined return: %s", err.Error())
|
||||
}
|
||||
if res != nil {
|
||||
t.Errorf("Expected nil response for undefined return, got: %v", res)
|
||||
}
|
||||
})
|
||||
|
||||
// Test scenario 3: Return empty object (should get empty NextHookResponse)
|
||||
t.Run("ReturnEmpty", func(t *testing.T) {
|
||||
payload := &context.NextHookPayload{
|
||||
Messages: []context.Message{
|
||||
{Role: context.RoleUser, Content: "return_empty"},
|
||||
},
|
||||
Completion: &context.CompletionResponse{
|
||||
Content: "Test completion",
|
||||
},
|
||||
}
|
||||
|
||||
res, _, err := agent.HookScript.Next(ctx, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to execute Next hook with empty return: %s", err.Error())
|
||||
}
|
||||
if res == nil {
|
||||
t.Fatalf("Expected non-nil response for empty object, got nil")
|
||||
}
|
||||
if res.Delegate != nil {
|
||||
t.Errorf("Expected nil Delegate, got: %v", res.Delegate)
|
||||
}
|
||||
if res.Data != nil {
|
||||
t.Errorf("Expected nil Data, got: %v", res.Data)
|
||||
}
|
||||
})
|
||||
|
||||
// Test scenario 4: Return custom data
|
||||
t.Run("ReturnCustomData", func(t *testing.T) {
|
||||
payload := &context.NextHookPayload{
|
||||
Messages: []context.Message{
|
||||
{Role: context.RoleUser, Content: "return_custom_data"},
|
||||
},
|
||||
Completion: &context.CompletionResponse{
|
||||
Content: "Test completion",
|
||||
},
|
||||
}
|
||||
|
||||
res, _, err := agent.HookScript.Next(ctx, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to execute Next hook with custom data: %s", err.Error())
|
||||
}
|
||||
if res == nil {
|
||||
t.Fatalf("Expected non-nil response, got nil")
|
||||
}
|
||||
|
||||
// Verify Data is present
|
||||
if res.Data == nil {
|
||||
t.Fatalf("Expected Data to be present, got nil")
|
||||
}
|
||||
|
||||
// Data should be a map
|
||||
dataMap, ok := res.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected Data to be map[string]interface{}, got: %T", res.Data)
|
||||
}
|
||||
|
||||
// Verify custom data fields
|
||||
if message, ok := dataMap["message"].(string); !ok || message != "Custom response from Next Hook" {
|
||||
t.Errorf("Expected custom message, got: %v", dataMap["message"])
|
||||
}
|
||||
if test, ok := dataMap["test"].(bool); !ok || !test {
|
||||
t.Errorf("Expected test=true, got: %v", dataMap["test"])
|
||||
}
|
||||
if _, ok := dataMap["timestamp"]; !ok {
|
||||
t.Errorf("Expected timestamp field")
|
||||
}
|
||||
|
||||
// Verify Delegate is nil
|
||||
if res.Delegate != nil {
|
||||
t.Errorf("Expected nil Delegate, got: %v", res.Delegate)
|
||||
}
|
||||
})
|
||||
|
||||
// Test scenario 5: Return data with metadata
|
||||
t.Run("ReturnDataWithMetadata", func(t *testing.T) {
|
||||
payload := &context.NextHookPayload{
|
||||
Messages: []context.Message{
|
||||
{Role: context.RoleUser, Content: "return_data_with_metadata"},
|
||||
},
|
||||
Completion: &context.CompletionResponse{
|
||||
Content: "Test completion",
|
||||
},
|
||||
}
|
||||
|
||||
res, _, err := agent.HookScript.Next(ctx, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to execute Next hook: %s", err.Error())
|
||||
}
|
||||
if res == nil {
|
||||
t.Fatalf("Expected non-nil response, got nil")
|
||||
}
|
||||
|
||||
// Verify Data
|
||||
if res.Data == nil {
|
||||
t.Fatalf("Expected Data to be present, got nil")
|
||||
}
|
||||
|
||||
dataMap, ok := res.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected Data to be map[string]interface{}, got: %T", res.Data)
|
||||
}
|
||||
|
||||
if result, ok := dataMap["result"].(string); !ok || result != "success" {
|
||||
t.Errorf("Expected result='success', got: %v", dataMap["result"])
|
||||
}
|
||||
|
||||
// Verify Metadata
|
||||
if res.Metadata == nil {
|
||||
t.Fatalf("Expected Metadata to be present, got nil")
|
||||
}
|
||||
|
||||
if hook, ok := res.Metadata["hook"].(string); !ok || hook != "next" {
|
||||
t.Errorf("Expected hook='next', got: %v", res.Metadata["hook"])
|
||||
}
|
||||
if processed, ok := res.Metadata["processed"].(bool); !ok || !processed {
|
||||
t.Errorf("Expected processed=true, got: %v", res.Metadata["processed"])
|
||||
}
|
||||
})
|
||||
|
||||
// Test scenario 6: Return delegate
|
||||
t.Run("ReturnDelegate", func(t *testing.T) {
|
||||
payload := &context.NextHookPayload{
|
||||
Messages: []context.Message{
|
||||
{Role: context.RoleUser, Content: "return_delegate"},
|
||||
},
|
||||
Completion: &context.CompletionResponse{
|
||||
Content: "Test completion",
|
||||
},
|
||||
}
|
||||
|
||||
res, _, err := agent.HookScript.Next(ctx, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to execute Next hook with delegate: %s", err.Error())
|
||||
}
|
||||
if res == nil {
|
||||
t.Fatalf("Expected non-nil response, got nil")
|
||||
}
|
||||
|
||||
// Verify Delegate is present
|
||||
if res.Delegate == nil {
|
||||
t.Fatalf("Expected Delegate to be present, got nil")
|
||||
}
|
||||
|
||||
// Verify delegate fields
|
||||
if res.Delegate.AgentID != "tests.create" {
|
||||
t.Errorf("Expected AgentID='tests.create', got: %s", res.Delegate.AgentID)
|
||||
}
|
||||
|
||||
if len(res.Delegate.Messages) != 1 {
|
||||
t.Errorf("Expected 1 message, got: %d", len(res.Delegate.Messages))
|
||||
} else {
|
||||
if res.Delegate.Messages[0].Role != context.RoleUser {
|
||||
t.Errorf("Expected user role, got: %s", res.Delegate.Messages[0].Role)
|
||||
}
|
||||
if content, ok := res.Delegate.Messages[0].Content.(string); !ok || content != "Hello from delegated agent" {
|
||||
t.Errorf("Expected specific content, got: %v", res.Delegate.Messages[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify Data is nil (only delegate, no custom data)
|
||||
if res.Data != nil {
|
||||
t.Logf("Note: Data is present alongside Delegate: %v", res.Data)
|
||||
}
|
||||
})
|
||||
|
||||
// Test scenario 7: Verify payload structure
|
||||
t.Run("VerifyPayload", func(t *testing.T) {
|
||||
payload := &context.NextHookPayload{
|
||||
Messages: []context.Message{
|
||||
{Role: context.RoleSystem, Content: "System message"},
|
||||
{Role: context.RoleUser, Content: "verify_payload"},
|
||||
},
|
||||
Completion: &context.CompletionResponse{
|
||||
Content: "Test completion content",
|
||||
Usage: &message.UsageInfo{
|
||||
PromptTokens: 10,
|
||||
CompletionTokens: 20,
|
||||
TotalTokens: 30,
|
||||
},
|
||||
},
|
||||
Tools: []context.ToolCallResponse{
|
||||
{
|
||||
ToolCallID: "call_123",
|
||||
Server: "test-server",
|
||||
Tool: "test-tool",
|
||||
Result: map[string]interface{}{"success": true},
|
||||
Error: "",
|
||||
},
|
||||
},
|
||||
Error: "",
|
||||
}
|
||||
|
||||
res, _, err := agent.HookScript.Next(ctx, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to execute Next hook: %s", err.Error())
|
||||
}
|
||||
if res == nil {
|
||||
t.Fatalf("Expected non-nil response, got nil")
|
||||
}
|
||||
|
||||
// Verify Data contains validation results
|
||||
if res.Data == nil {
|
||||
t.Fatalf("Expected Data with validation results, got nil")
|
||||
}
|
||||
|
||||
dataMap, ok := res.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected Data to be map[string]interface{}, got: %T", res.Data)
|
||||
}
|
||||
|
||||
if validation, ok := dataMap["validation"].(string); !ok || validation != "success" {
|
||||
t.Errorf("Expected validation='success', got: %v", dataMap["validation"])
|
||||
}
|
||||
|
||||
if checks, ok := dataMap["checks"].([]interface{}); !ok {
|
||||
t.Errorf("Expected checks array, got: %T", dataMap["checks"])
|
||||
} else {
|
||||
t.Logf("✓ Payload validation checks: %d items", len(checks))
|
||||
for i, check := range checks {
|
||||
t.Logf(" [%d] %v", i, check)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Test scenario 8: Verify tools processing
|
||||
t.Run("VerifyTools", func(t *testing.T) {
|
||||
payload := &context.NextHookPayload{
|
||||
Messages: []context.Message{
|
||||
{Role: context.RoleUser, Content: "verify_tools"},
|
||||
},
|
||||
Completion: &context.CompletionResponse{
|
||||
Content: "Test completion",
|
||||
},
|
||||
Tools: []context.ToolCallResponse{
|
||||
{
|
||||
ToolCallID: "call_1",
|
||||
Server: "server1",
|
||||
Tool: "tool1",
|
||||
Result: map[string]interface{}{"value": 42},
|
||||
Error: "",
|
||||
},
|
||||
{
|
||||
ToolCallID: "call_2",
|
||||
Server: "server2",
|
||||
Tool: "tool2",
|
||||
Result: nil,
|
||||
Error: "Tool execution failed",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
res, _, err := agent.HookScript.Next(ctx, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to execute Next hook: %s", err.Error())
|
||||
}
|
||||
if res == nil {
|
||||
t.Fatalf("Expected non-nil response, got nil")
|
||||
}
|
||||
|
||||
// Verify Data
|
||||
if res.Data == nil {
|
||||
t.Fatalf("Expected Data, got nil")
|
||||
}
|
||||
|
||||
dataMap, ok := res.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected Data to be map, got: %T", res.Data)
|
||||
}
|
||||
|
||||
// Verify tool statistics
|
||||
if totalTools, ok := dataMap["total_tools"].(float64); !ok || int(totalTools) != 2 {
|
||||
t.Errorf("Expected total_tools=2, got: %v", dataMap["total_tools"])
|
||||
}
|
||||
if successful, ok := dataMap["successful"].(float64); !ok || int(successful) != 1 {
|
||||
t.Errorf("Expected successful=1, got: %v", dataMap["successful"])
|
||||
}
|
||||
if failed, ok := dataMap["failed"].(float64); !ok || int(failed) != 1 {
|
||||
t.Errorf("Expected failed=1, got: %v", dataMap["failed"])
|
||||
}
|
||||
|
||||
t.Log("✓ Tools processing validated successfully")
|
||||
})
|
||||
|
||||
// Test scenario 9: Handle error
|
||||
t.Run("HandleError", func(t *testing.T) {
|
||||
payload := &context.NextHookPayload{
|
||||
Messages: []context.Message{
|
||||
{Role: context.RoleUser, Content: "handle_error"},
|
||||
},
|
||||
Completion: &context.CompletionResponse{
|
||||
Content: "Test completion",
|
||||
},
|
||||
Error: "Tool execution failed: timeout",
|
||||
}
|
||||
|
||||
res, _, err := agent.HookScript.Next(ctx, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to execute Next hook: %s", err.Error())
|
||||
}
|
||||
if res == nil {
|
||||
t.Fatalf("Expected non-nil response, got nil")
|
||||
}
|
||||
|
||||
// Verify error handling
|
||||
if res.Data == nil {
|
||||
t.Fatalf("Expected Data, got nil")
|
||||
}
|
||||
|
||||
dataMap, ok := res.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("Expected Data to be map, got: %T", res.Data)
|
||||
}
|
||||
|
||||
if errorMsg, ok := dataMap["error"].(string); !ok || errorMsg != "Tool execution failed: timeout" {
|
||||
t.Errorf("Expected error message, got: %v", dataMap["error"])
|
||||
}
|
||||
if recovered, ok := dataMap["recovered"].(bool); !ok || !recovered {
|
||||
t.Errorf("Expected recovered=true, got: %v", dataMap["recovered"])
|
||||
}
|
||||
|
||||
t.Log("✓ Error handling validated successfully")
|
||||
})
|
||||
}
|
||||
|
|
@ -1,414 +0,0 @@
|
|||
package hook_test
|
||||
|
||||
import (
|
||||
stdContext "context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
// newRealWorldNextContext creates a Context for real world Next Hook testing
|
||||
func newRealWorldNextContext(chatID, assistantID string) *context.Context {
|
||||
authorized := &types.AuthorizedInfo{
|
||||
Subject: "realworld-test-user",
|
||||
ClientID: "realworld-test-client",
|
||||
Scope: "openid profile",
|
||||
SessionID: "realworld-test-session",
|
||||
UserID: "realworld-user-123",
|
||||
TeamID: "realworld-team-456",
|
||||
TenantID: "realworld-tenant-789",
|
||||
}
|
||||
|
||||
ctx := context.New(stdContext.Background(), authorized, chatID)
|
||||
ctx.AssistantID = assistantID
|
||||
ctx.Locale = "en-us"
|
||||
ctx.Theme = "light"
|
||||
ctx.Client = context.Client{
|
||||
Type: "web",
|
||||
UserAgent: "RealWorldTest/1.0",
|
||||
IP: "127.0.0.1",
|
||||
}
|
||||
ctx.Referer = context.RefererAPI
|
||||
ctx.Accept = context.AcceptWebCUI
|
||||
ctx.Route = ""
|
||||
ctx.Metadata = make(map[string]interface{})
|
||||
return ctx
|
||||
}
|
||||
|
||||
// TestRealWorldNextStandard tests standard response (nil return)
|
||||
func TestRealWorldNextStandard(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping real world Next Hook test in short mode")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.realworld-next")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get assistant: %v", err)
|
||||
}
|
||||
|
||||
ctx := newRealWorldNextContext("test-next-standard", "tests.realworld-next")
|
||||
|
||||
// Simulate completion with scenario marker
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "scenario: standard"},
|
||||
{Role: context.RoleAssistant, Content: "I'll process your request using standard response."},
|
||||
}
|
||||
|
||||
completion := &context.CompletionResponse{
|
||||
Content: "Processing complete. Standard response will be used.",
|
||||
}
|
||||
|
||||
payload := &context.NextHookPayload{
|
||||
Messages: messages,
|
||||
Completion: completion,
|
||||
Tools: nil,
|
||||
Error: "",
|
||||
}
|
||||
|
||||
response, _, err := agent.HookScript.Next(ctx, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("Next hook failed: %v", err)
|
||||
}
|
||||
|
||||
// Should return nil for standard response
|
||||
assert.Nil(t, response, "Standard scenario should return nil")
|
||||
t.Log("✓ Standard response scenario passed")
|
||||
}
|
||||
|
||||
// TestRealWorldNextCustomData tests custom data response
|
||||
func TestRealWorldNextCustomData(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping real world Next Hook test in short mode")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.realworld-next")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get assistant: %v", err)
|
||||
}
|
||||
|
||||
ctx := newRealWorldNextContext("test-next-custom", "tests.realworld-next")
|
||||
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "scenario: custom_data"},
|
||||
{Role: context.RoleAssistant, Content: "Here's some information for you."},
|
||||
}
|
||||
|
||||
completion := &context.CompletionResponse{
|
||||
Content: "This is the LLM completion that will be summarized.",
|
||||
}
|
||||
|
||||
payload := &context.NextHookPayload{
|
||||
Messages: messages,
|
||||
Completion: completion,
|
||||
Tools: nil,
|
||||
Error: "",
|
||||
}
|
||||
|
||||
response, _, err := agent.HookScript.Next(ctx, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("Next hook failed: %v", err)
|
||||
}
|
||||
|
||||
assert.NotNil(t, response, "Custom data scenario should return response")
|
||||
assert.NotNil(t, response.Data, "Response should have Data")
|
||||
|
||||
dataMap, ok := response.Data.(map[string]interface{})
|
||||
assert.True(t, ok, "Data should be a map")
|
||||
assert.Equal(t, "custom_response", dataMap["type"])
|
||||
assert.Contains(t, dataMap, "timestamp")
|
||||
|
||||
t.Log("✓ Custom data response scenario passed")
|
||||
}
|
||||
|
||||
// TestRealWorldNextDelegate tests agent delegation
|
||||
func TestRealWorldNextDelegate(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping real world Next Hook test in short mode")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.realworld-next")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get assistant: %v", err)
|
||||
}
|
||||
|
||||
ctx := newRealWorldNextContext("test-next-delegate", "tests.realworld-next")
|
||||
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "scenario: delegate"},
|
||||
}
|
||||
|
||||
completion := &context.CompletionResponse{
|
||||
Content: "I should delegate this request to another agent.",
|
||||
}
|
||||
|
||||
payload := &context.NextHookPayload{
|
||||
Messages: messages,
|
||||
Completion: completion,
|
||||
Tools: nil,
|
||||
Error: "",
|
||||
}
|
||||
|
||||
response, _, err := agent.HookScript.Next(ctx, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("Next hook failed: %v", err)
|
||||
}
|
||||
|
||||
assert.NotNil(t, response, "Delegate scenario should return response")
|
||||
assert.NotNil(t, response.Delegate, "Response should have Delegate")
|
||||
assert.Equal(t, "tests.create", response.Delegate.AgentID)
|
||||
assert.NotEmpty(t, response.Delegate.Messages)
|
||||
|
||||
t.Log("✓ Delegation scenario passed")
|
||||
}
|
||||
|
||||
// TestRealWorldNextProcessTools tests tool result processing
|
||||
func TestRealWorldNextProcessTools(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping real world Next Hook test in short mode")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.realworld-next")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get assistant: %v", err)
|
||||
}
|
||||
|
||||
ctx := newRealWorldNextContext("test-next-tools", "tests.realworld-next")
|
||||
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "scenario: process_tools"},
|
||||
}
|
||||
|
||||
completion := &context.CompletionResponse{
|
||||
Content: "Tool calls have been executed.",
|
||||
}
|
||||
|
||||
// Simulate tool call results
|
||||
tools := []context.ToolCallResponse{
|
||||
{
|
||||
ToolCallID: "call_1",
|
||||
Server: "test-server",
|
||||
Tool: "test-tool-1",
|
||||
Result: map[string]interface{}{"status": "success"},
|
||||
Error: "",
|
||||
},
|
||||
{
|
||||
ToolCallID: "call_2",
|
||||
Server: "test-server",
|
||||
Tool: "test-tool-2",
|
||||
Result: nil,
|
||||
Error: "Tool execution failed",
|
||||
},
|
||||
}
|
||||
|
||||
payload := &context.NextHookPayload{
|
||||
Messages: messages,
|
||||
Completion: completion,
|
||||
Tools: tools,
|
||||
Error: "",
|
||||
}
|
||||
|
||||
response, _, err := agent.HookScript.Next(ctx, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("Next hook failed: %v", err)
|
||||
}
|
||||
|
||||
assert.NotNil(t, response, "Process tools scenario should return response")
|
||||
assert.NotNil(t, response.Data, "Response should have Data")
|
||||
|
||||
dataMap, ok := response.Data.(map[string]interface{})
|
||||
assert.True(t, ok, "Data should be a map")
|
||||
assert.Equal(t, "Tool execution summary", dataMap["message"])
|
||||
|
||||
// Check summary
|
||||
summary, ok := dataMap["summary"].(map[string]interface{})
|
||||
assert.True(t, ok, "Should have summary")
|
||||
assert.Equal(t, float64(2), summary["total"])
|
||||
assert.Equal(t, float64(1), summary["successful"])
|
||||
assert.Equal(t, float64(1), summary["failed"])
|
||||
|
||||
t.Log("✓ Process tools scenario passed")
|
||||
}
|
||||
|
||||
// TestRealWorldNextErrorRecovery tests error handling and recovery
|
||||
func TestRealWorldNextErrorRecovery(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping real world Next Hook test in short mode")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.realworld-next")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get assistant: %v", err)
|
||||
}
|
||||
|
||||
ctx := newRealWorldNextContext("test-next-error", "tests.realworld-next")
|
||||
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "scenario: error_recovery"},
|
||||
}
|
||||
|
||||
completion := &context.CompletionResponse{
|
||||
Content: "An error occurred during processing.",
|
||||
}
|
||||
|
||||
payload := &context.NextHookPayload{
|
||||
Messages: messages,
|
||||
Completion: completion,
|
||||
Tools: nil,
|
||||
Error: "System error: Database connection timeout",
|
||||
}
|
||||
|
||||
response, _, err := agent.HookScript.Next(ctx, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("Next hook failed: %v", err)
|
||||
}
|
||||
|
||||
assert.NotNil(t, response, "Error recovery scenario should return response")
|
||||
assert.NotNil(t, response.Data, "Response should have Data")
|
||||
|
||||
dataMap, ok := response.Data.(map[string]interface{})
|
||||
assert.True(t, ok, "Data should be a map")
|
||||
assert.Equal(t, "Error was handled by Next Hook", dataMap["message"])
|
||||
assert.Contains(t, dataMap, "error")
|
||||
assert.Contains(t, dataMap, "recovery_action")
|
||||
|
||||
t.Log("✓ Error recovery scenario passed")
|
||||
}
|
||||
|
||||
// TestRealWorldNextConditional tests conditional logic based on completion
|
||||
func TestRealWorldNextConditional(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping real world Next Hook test in short mode")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.realworld-next")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get assistant: %v", err)
|
||||
}
|
||||
|
||||
ctx := newRealWorldNextContext("test-next-conditional", "tests.realworld-next")
|
||||
|
||||
t.Run("ConditionalSuccess", func(t *testing.T) {
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "scenario: conditional"},
|
||||
}
|
||||
|
||||
completion := &context.CompletionResponse{
|
||||
Content: "The operation completed successfully. All tasks are done.",
|
||||
}
|
||||
|
||||
payload := &context.NextHookPayload{
|
||||
Messages: messages,
|
||||
Completion: completion,
|
||||
Tools: nil,
|
||||
Error: "",
|
||||
}
|
||||
|
||||
response, _, err := agent.HookScript.Next(ctx, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("Next hook failed: %v", err)
|
||||
}
|
||||
|
||||
assert.NotNil(t, response, "Conditional scenario should return response")
|
||||
assert.NotNil(t, response.Data, "Response should have Data")
|
||||
|
||||
dataMap, ok := response.Data.(map[string]interface{})
|
||||
assert.True(t, ok, "Data should be a map")
|
||||
assert.Equal(t, "Conditional analysis complete", dataMap["message"])
|
||||
assert.Contains(t, dataMap, "action")
|
||||
assert.Contains(t, dataMap, "conditions")
|
||||
|
||||
t.Log("✓ Conditional (success) scenario passed")
|
||||
})
|
||||
|
||||
t.Run("ConditionalDelegate", func(t *testing.T) {
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "scenario: conditional"},
|
||||
}
|
||||
|
||||
completion := &context.CompletionResponse{
|
||||
Content: "I should delegate this request to another service for better handling.",
|
||||
}
|
||||
|
||||
payload := &context.NextHookPayload{
|
||||
Messages: messages,
|
||||
Completion: completion,
|
||||
Tools: nil,
|
||||
Error: "",
|
||||
}
|
||||
|
||||
response, _, err := agent.HookScript.Next(ctx, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("Next hook failed: %v", err)
|
||||
}
|
||||
|
||||
assert.NotNil(t, response, "Conditional delegate should return response")
|
||||
assert.NotNil(t, response.Delegate, "Should delegate based on condition")
|
||||
assert.Equal(t, "tests.create", response.Delegate.AgentID)
|
||||
|
||||
t.Log("✓ Conditional (delegate) scenario passed")
|
||||
})
|
||||
}
|
||||
|
||||
// TestRealWorldNextDefault tests default behavior
|
||||
func TestRealWorldNextDefault(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping real world Next Hook test in short mode")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.realworld-next")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get assistant: %v", err)
|
||||
}
|
||||
|
||||
ctx := newRealWorldNextContext("test-next-default", "tests.realworld-next")
|
||||
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "Just a normal request"},
|
||||
}
|
||||
|
||||
completion := &context.CompletionResponse{
|
||||
Content: "Here's the response to your request.",
|
||||
}
|
||||
|
||||
payload := &context.NextHookPayload{
|
||||
Messages: messages,
|
||||
Completion: completion,
|
||||
Tools: nil,
|
||||
Error: "",
|
||||
}
|
||||
|
||||
response, _, err := agent.HookScript.Next(ctx, payload)
|
||||
if err != nil {
|
||||
t.Fatalf("Next hook failed: %v", err)
|
||||
}
|
||||
|
||||
// Default behavior should return nil
|
||||
assert.Nil(t, response, "Default scenario should return nil for standard response")
|
||||
|
||||
t.Log("✓ Default scenario passed")
|
||||
}
|
||||
|
|
@ -1,767 +0,0 @@
|
|||
package hook_test
|
||||
|
||||
import (
|
||||
stdContext "context"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
// ============================================================================
|
||||
// Real World Stress Tests
|
||||
// These tests simulate actual production usage patterns with Stream() flow
|
||||
// ============================================================================
|
||||
|
||||
// TestRealWorldSimpleScenario tests basic Stream() flow with simple Create hook
|
||||
func TestRealWorldSimpleScenario(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping real world test in short mode")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.realworld")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get assistant: %v", err)
|
||||
}
|
||||
|
||||
ctx := newRealWorldContext("test-simple", "tests.realworld")
|
||||
|
||||
// Test Create hook with simple scenario
|
||||
messages := []context.Message{
|
||||
{Role: "user", Content: "simple"},
|
||||
}
|
||||
|
||||
response, _, err := agent.HookScript.Create(ctx, messages)
|
||||
if err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
|
||||
assert.NotNil(t, response)
|
||||
assert.NotEmpty(t, response.Messages)
|
||||
assert.Equal(t, "simple", response.Metadata["scenario"])
|
||||
}
|
||||
|
||||
// TestRealWorldMCPScenarios tests MCP integration scenarios
|
||||
func TestRealWorldMCPScenarios(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping real world test in short mode")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.realworld")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get assistant: %v", err)
|
||||
}
|
||||
|
||||
t.Run("MCP Health", func(t *testing.T) {
|
||||
ctx := newRealWorldContext("test-mcp-health", "tests.realworld")
|
||||
|
||||
messages := []context.Message{
|
||||
{Role: "user", Content: "mcp_health"},
|
||||
}
|
||||
|
||||
response, _, err := agent.HookScript.Create(ctx, messages)
|
||||
if err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
|
||||
// Detailed validation
|
||||
assert.NotNil(t, response)
|
||||
assert.NotEmpty(t, response.Messages)
|
||||
|
||||
// Check if metadata exists
|
||||
if response.Metadata == nil {
|
||||
t.Logf("⚠ Metadata is nil - checking messages content")
|
||||
// Verify messages contain expected content
|
||||
messageContent := ""
|
||||
for _, msg := range response.Messages {
|
||||
if content, ok := msg.Content.(string); ok {
|
||||
messageContent += content + "\n"
|
||||
}
|
||||
}
|
||||
assert.Contains(t, messageContent, "Health", "Message should mention health")
|
||||
assert.Contains(t, messageContent, "Tools", "Message should mention tools")
|
||||
t.Logf("✓ MCP Health executed (verified via message content)")
|
||||
} else {
|
||||
assert.Equal(t, "mcp_health", response.Metadata["scenario"])
|
||||
|
||||
// Verify metadata contains MCP results
|
||||
if toolsCount, ok := response.Metadata["tools_count"]; ok {
|
||||
count := int(toolsCount.(float64))
|
||||
assert.Greater(t, count, 0, "Should have tools from MCP")
|
||||
t.Logf("✓ MCP Health: %d tools, health data: %v",
|
||||
count, response.Metadata["health_data"])
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Release()
|
||||
})
|
||||
|
||||
t.Run("MCP Tools", func(t *testing.T) {
|
||||
ctx := newRealWorldContext("test-mcp-tools", "tests.realworld")
|
||||
|
||||
messages := []context.Message{
|
||||
{Role: "user", Content: "mcp_tools"},
|
||||
}
|
||||
|
||||
response, _, err := agent.HookScript.Create(ctx, messages)
|
||||
if err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
|
||||
// Detailed validation
|
||||
assert.NotNil(t, response)
|
||||
assert.NotEmpty(t, response.Messages)
|
||||
|
||||
// Check if metadata exists
|
||||
if response.Metadata == nil {
|
||||
t.Logf("⚠ Metadata is nil - checking messages content")
|
||||
// Verify messages contain expected content
|
||||
messageContent := ""
|
||||
for _, msg := range response.Messages {
|
||||
if content, ok := msg.Content.(string); ok {
|
||||
messageContent += content + "\n"
|
||||
}
|
||||
}
|
||||
assert.Contains(t, messageContent, "Tools", "Message should mention tools")
|
||||
assert.Contains(t, messageContent, "Ping", "Message should mention ping")
|
||||
t.Logf("✓ MCP Tools executed (verified via message content)")
|
||||
} else {
|
||||
assert.Equal(t, "mcp_tools", response.Metadata["scenario"])
|
||||
|
||||
// Verify tools were called
|
||||
if toolsCount, ok := response.Metadata["tools_count"]; ok {
|
||||
count := int(toolsCount.(float64))
|
||||
assert.Greater(t, count, 0, "Should have tools from MCP")
|
||||
|
||||
// Verify operations list
|
||||
if operations, ok := response.Metadata["operations"].([]interface{}); ok {
|
||||
assert.Len(t, operations, 2, "Should execute 2 operations: ping, status")
|
||||
t.Logf("✓ MCP Tools: %d tools, operations: %v", count, operations)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Release()
|
||||
})
|
||||
|
||||
t.Run("Full Workflow", func(t *testing.T) {
|
||||
ctx := newRealWorldContext("test-full-workflow", "tests.realworld")
|
||||
|
||||
// Initialize stack for trace
|
||||
stack, _, done := context.EnterStack(ctx, "tests.realworld", &context.Options{})
|
||||
defer done()
|
||||
ctx.Stack = stack
|
||||
|
||||
messages := []context.Message{
|
||||
{Role: "user", Content: "full_workflow"},
|
||||
}
|
||||
|
||||
response, _, err := agent.HookScript.Create(ctx, messages)
|
||||
if err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
|
||||
// Detailed validation
|
||||
assert.NotNil(t, response)
|
||||
assert.NotEmpty(t, response.Messages)
|
||||
|
||||
// Check if metadata exists
|
||||
if response.Metadata == nil {
|
||||
t.Logf("⚠ Metadata is nil - checking messages content")
|
||||
// Verify messages contain expected content
|
||||
messageContent := ""
|
||||
for _, msg := range response.Messages {
|
||||
if content, ok := msg.Content.(string); ok {
|
||||
messageContent += content + "\n"
|
||||
}
|
||||
}
|
||||
assert.Contains(t, messageContent, "Workflow", "Message should mention workflow")
|
||||
assert.Contains(t, messageContent, "Tools", "Message should mention tools")
|
||||
assert.Contains(t, messageContent, "Roles", "Message should mention database roles")
|
||||
t.Logf("✓ Full Workflow executed (verified via message content)")
|
||||
} else {
|
||||
assert.Equal(t, "full_workflow", response.Metadata["scenario"])
|
||||
|
||||
// Verify all phases completed
|
||||
if phasesCompleted, ok := response.Metadata["phases_completed"]; ok {
|
||||
phases := int(phasesCompleted.(float64))
|
||||
assert.Equal(t, 4, phases, "Should complete 4 phases")
|
||||
|
||||
// Verify MCP tools
|
||||
if mcpTools, ok := response.Metadata["mcp_tools"]; ok {
|
||||
tools := int(mcpTools.(float64))
|
||||
assert.Greater(t, tools, 0, "Should have MCP tools")
|
||||
|
||||
// Verify DB records
|
||||
if dbRecords, ok := response.Metadata["db_records"]; ok {
|
||||
records := int(dbRecords.(float64))
|
||||
assert.GreaterOrEqual(t, records, 0, "Should have DB query result")
|
||||
|
||||
t.Logf("✓ Full Workflow: %d phases, %d MCP tools, %d DB records",
|
||||
phases, tools, records)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Release()
|
||||
})
|
||||
}
|
||||
|
||||
// TestRealWorldTraceIntensive tests trace-heavy scenarios
|
||||
func TestRealWorldTraceIntensive(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping real world test in short mode")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.realworld")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get assistant: %v", err)
|
||||
}
|
||||
|
||||
ctx := newRealWorldContext("test-trace-intensive", "tests.realworld")
|
||||
stack, _, done := context.EnterStack(ctx, "tests.realworld", &context.Options{})
|
||||
defer done()
|
||||
ctx.Stack = stack
|
||||
|
||||
messages := []context.Message{
|
||||
{Role: "user", Content: "trace_intensive"},
|
||||
}
|
||||
|
||||
response, _, err := agent.HookScript.Create(ctx, messages)
|
||||
if err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
|
||||
assert.NotNil(t, response)
|
||||
assert.Equal(t, "trace_intensive", response.Metadata["scenario"])
|
||||
assert.NotZero(t, response.Metadata["nodes_created"])
|
||||
}
|
||||
|
||||
// TestRealWorldStressSimple tests simple scenario under stress
|
||||
func TestRealWorldStressSimple(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping stress test in short mode")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.realworld")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get assistant: %v", err)
|
||||
}
|
||||
|
||||
iterations := 100
|
||||
startMemory := getMemStats()
|
||||
|
||||
for i := 0; i < iterations; i++ {
|
||||
ctx := newRealWorldContext(fmt.Sprintf("stress-simple-%d", i), "tests.realworld")
|
||||
|
||||
messages := []context.Message{
|
||||
{Role: "user", Content: "simple"},
|
||||
}
|
||||
|
||||
response, _, err := agent.HookScript.Create(ctx, messages)
|
||||
if err != nil {
|
||||
t.Fatalf("Iteration %d failed: %v", i, err)
|
||||
}
|
||||
|
||||
// Validate response
|
||||
assert.NotNil(t, response, "Iteration %d: response should not be nil", i)
|
||||
assert.NotEmpty(t, response.Messages, "Iteration %d: messages should not be empty", i)
|
||||
if response.Metadata != nil {
|
||||
assert.Equal(t, "simple", response.Metadata["scenario"], "Iteration %d: scenario mismatch", i)
|
||||
}
|
||||
|
||||
// Explicit cleanup
|
||||
ctx.Release()
|
||||
|
||||
if i%20 == 0 {
|
||||
runtime.GC()
|
||||
currentMemory := getMemStats()
|
||||
t.Logf("Iteration %d: Memory: %d MB", i, currentMemory/1024/1024)
|
||||
}
|
||||
}
|
||||
|
||||
runtime.GC()
|
||||
endMemory := getMemStats()
|
||||
|
||||
t.Logf("Simple stress: %d iterations", iterations)
|
||||
t.Logf("Start memory: %d MB", startMemory/1024/1024)
|
||||
t.Logf("End memory: %d MB", endMemory/1024/1024)
|
||||
|
||||
if endMemory > startMemory {
|
||||
t.Logf("Memory growth: %d MB", (endMemory-startMemory)/1024/1024)
|
||||
} else {
|
||||
t.Logf("Memory decreased: %d MB", (startMemory-endMemory)/1024/1024)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRealWorldStressMCP tests MCP scenarios under stress
|
||||
func TestRealWorldStressMCP(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping stress test in short mode")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.realworld")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get assistant: %v", err)
|
||||
}
|
||||
|
||||
iterations := 50
|
||||
scenarios := []string{"mcp_health", "mcp_tools"}
|
||||
startMemory := getMemStats()
|
||||
|
||||
for i := 0; i < iterations; i++ {
|
||||
scenario := scenarios[i%len(scenarios)]
|
||||
ctx := newRealWorldContext(fmt.Sprintf("stress-mcp-%d", i), "tests.realworld")
|
||||
|
||||
// Initialize stack for trace
|
||||
stack, _, done := context.EnterStack(ctx, "tests.realworld", &context.Options{})
|
||||
ctx.Stack = stack
|
||||
|
||||
messages := []context.Message{
|
||||
{Role: "user", Content: scenario},
|
||||
}
|
||||
|
||||
response, _, err := agent.HookScript.Create(ctx, messages)
|
||||
if err != nil {
|
||||
t.Fatalf("Iteration %d (%s) failed: %v", i, scenario, err)
|
||||
}
|
||||
|
||||
// Validate response
|
||||
assert.NotNil(t, response, "Iteration %d (%s): response should not be nil", i, scenario)
|
||||
assert.NotEmpty(t, response.Messages, "Iteration %d (%s): messages should not be empty", i, scenario)
|
||||
|
||||
// Validate metadata
|
||||
if response.Metadata != nil {
|
||||
assert.Equal(t, scenario, response.Metadata["scenario"], "Iteration %d: scenario mismatch", i)
|
||||
|
||||
// Verify MCP-specific data
|
||||
if scenario == "mcp_health" {
|
||||
assert.NotNil(t, response.Metadata["tools_count"], "Iteration %d: should have tools_count", i)
|
||||
if toolsCount, ok := response.Metadata["tools_count"].(float64); ok {
|
||||
assert.Greater(t, int(toolsCount), 0, "Iteration %d: should have at least 1 tool", i)
|
||||
assert.Equal(t, 3, int(toolsCount), "Iteration %d: echo should have 3 tools", i)
|
||||
}
|
||||
assert.NotNil(t, response.Metadata["health_data"], "Iteration %d: should have health_data", i)
|
||||
} else if scenario == "mcp_tools" {
|
||||
assert.NotNil(t, response.Metadata["tools_count"], "Iteration %d: should have tools_count", i)
|
||||
if toolsCount, ok := response.Metadata["tools_count"].(float64); ok {
|
||||
assert.Equal(t, 3, int(toolsCount), "Iteration %d: echo should have 3 tools", i)
|
||||
}
|
||||
assert.NotNil(t, response.Metadata["operations"], "Iteration %d: should have operations", i)
|
||||
if operations, ok := response.Metadata["operations"].([]interface{}); ok {
|
||||
assert.Len(t, operations, 2, "Iteration %d: should have 2 operations (ping, status)", i)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
t.Errorf("Iteration %d (%s): metadata is nil", i, scenario)
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
done()
|
||||
ctx.Release()
|
||||
|
||||
if i%10 == 0 {
|
||||
runtime.GC()
|
||||
currentMemory := getMemStats()
|
||||
t.Logf("Iteration %d (%s): Memory: %d MB", i, scenario, currentMemory/1024/1024)
|
||||
}
|
||||
}
|
||||
|
||||
runtime.GC()
|
||||
endMemory := getMemStats()
|
||||
|
||||
t.Logf("MCP stress: %d iterations", iterations)
|
||||
t.Logf("Start memory: %d MB", startMemory/1024/1024)
|
||||
t.Logf("End memory: %d MB", endMemory/1024/1024)
|
||||
|
||||
if endMemory > startMemory {
|
||||
t.Logf("Memory growth: %d MB", (endMemory-startMemory)/1024/1024)
|
||||
} else {
|
||||
t.Logf("Memory decreased: %d MB", (startMemory-endMemory)/1024/1024)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRealWorldStressFullWorkflow tests complete workflow under stress
|
||||
func TestRealWorldStressFullWorkflow(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping stress test in short mode")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.realworld")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get assistant: %v", err)
|
||||
}
|
||||
|
||||
iterations := 30
|
||||
startMemory := getMemStats()
|
||||
startTime := time.Now()
|
||||
|
||||
for i := 0; i < iterations; i++ {
|
||||
ctx := newRealWorldContext(fmt.Sprintf("stress-workflow-%d", i), "tests.realworld")
|
||||
|
||||
// Initialize stack for trace
|
||||
stack, _, done := context.EnterStack(ctx, "tests.realworld", &context.Options{})
|
||||
ctx.Stack = stack
|
||||
|
||||
messages := []context.Message{
|
||||
{Role: "user", Content: "full_workflow"},
|
||||
}
|
||||
|
||||
response, _, err := agent.HookScript.Create(ctx, messages)
|
||||
if err != nil {
|
||||
t.Fatalf("Iteration %d failed: %v", i, err)
|
||||
}
|
||||
|
||||
// Verify response
|
||||
assert.NotNil(t, response, "Iteration %d: response should not be nil", i)
|
||||
assert.NotEmpty(t, response.Messages, "Iteration %d: messages should not be empty", i)
|
||||
if response.Metadata != nil {
|
||||
assert.Equal(t, "full_workflow", response.Metadata["scenario"], "Iteration %d: scenario mismatch", i)
|
||||
// Verify workflow-specific metadata
|
||||
if phasesCompleted, ok := response.Metadata["phases_completed"]; ok {
|
||||
phases := int(phasesCompleted.(float64))
|
||||
assert.Equal(t, 4, phases, "Iteration %d: should complete 4 phases", i)
|
||||
}
|
||||
if mcpTools, ok := response.Metadata["mcp_tools"]; ok {
|
||||
tools := int(mcpTools.(float64))
|
||||
assert.Greater(t, tools, 0, "Iteration %d: should have MCP tools", i)
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
done()
|
||||
ctx.Release()
|
||||
|
||||
if i%10 == 0 {
|
||||
runtime.GC()
|
||||
currentMemory := getMemStats()
|
||||
elapsed := time.Since(startTime)
|
||||
t.Logf("Iteration %d: Memory: %d MB, Elapsed: %v", i, currentMemory/1024/1024, elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
duration := time.Since(startTime)
|
||||
runtime.GC()
|
||||
endMemory := getMemStats()
|
||||
|
||||
avgTime := duration / time.Duration(iterations)
|
||||
t.Logf("Full workflow stress: %d iterations", iterations)
|
||||
t.Logf("Total time: %v", duration)
|
||||
t.Logf("Average time per iteration: %v", avgTime)
|
||||
t.Logf("Start memory: %d MB", startMemory/1024/1024)
|
||||
t.Logf("End memory: %d MB", endMemory/1024/1024)
|
||||
|
||||
if endMemory > startMemory {
|
||||
t.Logf("Memory growth: %d MB", (endMemory-startMemory)/1024/1024)
|
||||
} else {
|
||||
t.Logf("Memory decreased: %d MB", (startMemory-endMemory)/1024/1024)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRealWorldStressConcurrent tests concurrent real-world usage
|
||||
func TestRealWorldStressConcurrent(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping stress test in short mode")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.realworld")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get assistant: %v", err)
|
||||
}
|
||||
|
||||
goroutines := 100
|
||||
iterationsPerGoroutine := 10
|
||||
scenarios := []string{"simple", "mcp_health", "mcp_tools", "full_workflow"}
|
||||
|
||||
startMemory := getMemStats()
|
||||
startTime := time.Now()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errors := make(chan error, goroutines*iterationsPerGoroutine)
|
||||
|
||||
// Track results for validation
|
||||
type Result struct {
|
||||
goroutineID int
|
||||
iteration int
|
||||
scenario string
|
||||
metadata map[string]interface{}
|
||||
}
|
||||
results := make(chan Result, goroutines*iterationsPerGoroutine)
|
||||
|
||||
for g := 0; g < goroutines; g++ {
|
||||
wg.Add(1)
|
||||
go func(goroutineID int) {
|
||||
defer wg.Done()
|
||||
|
||||
for i := 0; i < iterationsPerGoroutine; i++ {
|
||||
scenario := scenarios[(goroutineID+i)%len(scenarios)]
|
||||
ctx := newRealWorldContext(
|
||||
fmt.Sprintf("concurrent-%d-%d", goroutineID, i),
|
||||
"tests.realworld",
|
||||
)
|
||||
|
||||
// Initialize stack for trace
|
||||
stack, _, done := context.EnterStack(ctx, "tests.realworld", &context.Options{})
|
||||
ctx.Stack = stack
|
||||
|
||||
messages := []context.Message{
|
||||
{Role: "user", Content: scenario},
|
||||
}
|
||||
|
||||
response, _, err := agent.HookScript.Create(ctx, messages)
|
||||
if err != nil {
|
||||
errors <- fmt.Errorf("goroutine %d iteration %d (%s): %v", goroutineID, i, scenario, err)
|
||||
done()
|
||||
ctx.Release()
|
||||
return
|
||||
}
|
||||
|
||||
// Validate response
|
||||
if response == nil {
|
||||
errors <- fmt.Errorf("goroutine %d iteration %d (%s): nil response", goroutineID, i, scenario)
|
||||
done()
|
||||
ctx.Release()
|
||||
return
|
||||
}
|
||||
|
||||
if len(response.Messages) == 0 {
|
||||
errors <- fmt.Errorf("goroutine %d iteration %d (%s): empty messages", goroutineID, i, scenario)
|
||||
done()
|
||||
ctx.Release()
|
||||
return
|
||||
}
|
||||
|
||||
// Collect result
|
||||
results <- Result{
|
||||
goroutineID: goroutineID,
|
||||
iteration: i,
|
||||
scenario: scenario,
|
||||
metadata: response.Metadata,
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
done()
|
||||
ctx.Release()
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
close(errors)
|
||||
close(results)
|
||||
|
||||
duration := time.Since(startTime)
|
||||
runtime.GC()
|
||||
endMemory := getMemStats()
|
||||
|
||||
// Check for errors
|
||||
errorCount := 0
|
||||
for err := range errors {
|
||||
t.Error(err)
|
||||
errorCount++
|
||||
}
|
||||
|
||||
assert.Equal(t, 0, errorCount, "No errors should occur in concurrent operations")
|
||||
|
||||
// Validate results
|
||||
scenarioCounts := make(map[string]int)
|
||||
validResults := 0
|
||||
|
||||
for result := range results {
|
||||
validResults++
|
||||
scenarioCounts[result.scenario]++
|
||||
|
||||
// Validate metadata exists and has expected scenario
|
||||
if result.metadata != nil {
|
||||
if scenario, ok := result.metadata["scenario"].(string); ok {
|
||||
if scenario != result.scenario {
|
||||
t.Errorf("Metadata mismatch: expected %s, got %s (goroutine %d, iteration %d)",
|
||||
result.scenario, scenario, result.goroutineID, result.iteration)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
totalOperations := goroutines * iterationsPerGoroutine
|
||||
assert.Equal(t, totalOperations, validResults, "All operations should return valid results")
|
||||
|
||||
avgTime := duration / time.Duration(totalOperations)
|
||||
|
||||
t.Logf("✓ Concurrent stress: %d operations (goroutines: %d, iterations: %d)",
|
||||
totalOperations, goroutines, iterationsPerGoroutine)
|
||||
t.Logf("✓ Valid results: %d/%d (100%%)", validResults, totalOperations)
|
||||
t.Logf("✓ Scenario distribution:")
|
||||
for scenario, count := range scenarioCounts {
|
||||
t.Logf(" - %s: %d operations", scenario, count)
|
||||
}
|
||||
t.Logf("✓ Total time: %v", duration)
|
||||
t.Logf("✓ Average time per operation: %v", avgTime)
|
||||
t.Logf("✓ Start memory: %d MB", startMemory/1024/1024)
|
||||
t.Logf("✓ End memory: %d MB", endMemory/1024/1024)
|
||||
|
||||
if endMemory > startMemory {
|
||||
t.Logf("✓ Memory growth: %d MB", (endMemory-startMemory)/1024/1024)
|
||||
} else {
|
||||
t.Logf("✓ Memory decreased: %d MB", (startMemory-endMemory)/1024/1024)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRealWorldStressResourceHeavy tests resource-intensive scenarios
|
||||
func TestRealWorldStressResourceHeavy(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping stress test in short mode")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
agent, err := assistant.Get("tests.realworld")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get assistant: %v", err)
|
||||
}
|
||||
|
||||
iterations := 20
|
||||
startMemory := getMemStats()
|
||||
startTime := time.Now()
|
||||
|
||||
for i := 0; i < iterations; i++ {
|
||||
ctx := newRealWorldContext(fmt.Sprintf("stress-heavy-%d", i), "tests.realworld")
|
||||
|
||||
// Initialize stack for trace
|
||||
stack, _, done := context.EnterStack(ctx, "tests.realworld", &context.Options{})
|
||||
ctx.Stack = stack
|
||||
|
||||
messages := []context.Message{
|
||||
{Role: "user", Content: "resource_heavy"},
|
||||
}
|
||||
|
||||
response, _, err := agent.HookScript.Create(ctx, messages)
|
||||
if err != nil {
|
||||
t.Fatalf("Iteration %d failed: %v", i, err)
|
||||
}
|
||||
|
||||
// Validate response
|
||||
assert.NotNil(t, response, "Iteration %d: response should not be nil", i)
|
||||
assert.NotEmpty(t, response.Messages, "Iteration %d: messages should not be empty", i)
|
||||
if response.Metadata != nil {
|
||||
assert.Equal(t, "resource_heavy", response.Metadata["scenario"], "Iteration %d: scenario mismatch", i)
|
||||
// Verify resource-heavy metadata
|
||||
if mcpIterations, ok := response.Metadata["mcp_iterations"]; ok {
|
||||
iterations := int(mcpIterations.(float64))
|
||||
assert.Equal(t, 5, iterations, "Iteration %d: should have 5 MCP iterations", i)
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
done()
|
||||
ctx.Release()
|
||||
|
||||
if i%5 == 0 {
|
||||
runtime.GC()
|
||||
currentMemory := getMemStats()
|
||||
elapsed := time.Since(startTime)
|
||||
t.Logf("Iteration %d: Memory: %d MB, Elapsed: %v", i, currentMemory/1024/1024, elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
duration := time.Since(startTime)
|
||||
runtime.GC()
|
||||
endMemory := getMemStats()
|
||||
|
||||
avgTime := duration / time.Duration(iterations)
|
||||
t.Logf("Resource heavy stress: %d iterations", iterations)
|
||||
t.Logf("Total time: %v", duration)
|
||||
t.Logf("Average time per iteration: %v", avgTime)
|
||||
t.Logf("Start memory: %d MB", startMemory/1024/1024)
|
||||
t.Logf("End memory: %d MB", endMemory/1024/1024)
|
||||
|
||||
if endMemory > startMemory {
|
||||
memoryGrowth := int64(endMemory - startMemory)
|
||||
t.Logf("Memory growth: %d MB", memoryGrowth/1024/1024)
|
||||
// Allow up to 100MB growth for resource-heavy operations
|
||||
assert.Less(t, memoryGrowth, int64(100*1024*1024), "Memory growth should be reasonable")
|
||||
} else {
|
||||
t.Logf("Memory decreased: %d MB", (startMemory-endMemory)/1024/1024)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
// newRealWorldContext creates a Context for real-world testing
|
||||
func newRealWorldContext(chatID, assistantID string) *context.Context {
|
||||
authorized := &types.AuthorizedInfo{
|
||||
Subject: "realworld-test-user",
|
||||
ClientID: "realworld-test-client",
|
||||
Scope: "openid profile email",
|
||||
SessionID: "realworld-test-session",
|
||||
UserID: "realworld-user-123",
|
||||
TeamID: "realworld-team-456",
|
||||
TenantID: "realworld-tenant-789",
|
||||
RememberMe: true,
|
||||
Constraints: types.DataConstraints{
|
||||
OwnerOnly: false,
|
||||
CreatorOnly: false,
|
||||
EditorOnly: false,
|
||||
TeamOnly: true,
|
||||
Extra: map[string]interface{}{
|
||||
"department": "engineering",
|
||||
"region": "us-west",
|
||||
"project": "yao-realworld-test",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ctx := context.New(stdContext.Background(), authorized, chatID)
|
||||
ctx.AssistantID = assistantID
|
||||
ctx.Locale = "en-us"
|
||||
ctx.Theme = "light"
|
||||
ctx.Client = context.Client{
|
||||
Type: "web",
|
||||
UserAgent: "RealWorldTest/1.0",
|
||||
IP: "127.0.0.1",
|
||||
}
|
||||
ctx.Referer = context.RefererAPI
|
||||
ctx.Accept = context.AcceptWebCUI
|
||||
ctx.Route = ""
|
||||
ctx.Metadata = make(map[string]interface{})
|
||||
return ctx
|
||||
}
|
||||
|
||||
// getMemStats returns current memory allocation in bytes
|
||||
func getMemStats() uint64 {
|
||||
runtime.GC()
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
return m.Alloc
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
package hook
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
)
|
||||
|
||||
// Execute execute the script
|
||||
func (s *Script) Execute(ctx *context.Context, method string, args ...interface{}) (interface{}, error) {
|
||||
if s == nil || s.Script == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var sid = ""
|
||||
if ctx.Authorized != nil {
|
||||
sid = ctx.Authorized.SessionID
|
||||
}
|
||||
|
||||
scriptCtx, err := s.NewContext(sid, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer scriptCtx.Close()
|
||||
|
||||
// Set authorized information if available
|
||||
if ctx.Authorized != nil {
|
||||
scriptCtx.WithAuthorized(ctx.Authorized.AuthorizedToMap())
|
||||
}
|
||||
|
||||
// The first argument is the context
|
||||
args = append([]interface{}{ctx}, args...)
|
||||
|
||||
// Try to call the method
|
||||
result, err := scriptCtx.CallWith(ctx.Context, method, args...)
|
||||
|
||||
// If method doesn't exist (ReferenceError or similar), return nil without error
|
||||
if err != nil && (strings.Contains(err.Error(), "is not defined") ||
|
||||
strings.Contains(err.Error(), "is not a function") ||
|
||||
strings.Contains(err.Error(), "is not a Function")) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
package hook
|
||||
|
||||
import (
|
||||
v8 "github.com/yaoapp/gou/runtime/v8"
|
||||
)
|
||||
|
||||
// Script the script hook align
|
||||
type Script struct {
|
||||
*v8.Script
|
||||
}
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
package assistant
|
||||
|
||||
import (
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/llm"
|
||||
"github.com/yaoapp/yao/agent/output/message"
|
||||
"github.com/yaoapp/yao/trace/types"
|
||||
)
|
||||
|
||||
// executeLLMStream executes the LLM streaming call with pre-built request
|
||||
// Returns completionResponse and error
|
||||
func (ast *Assistant) executeLLMStream(
|
||||
ctx *context.Context,
|
||||
completionMessages []context.Message,
|
||||
completionOptions *context.CompletionOptions,
|
||||
agentNode types.Node,
|
||||
streamHandler message.StreamFunc,
|
||||
opts *context.Options,
|
||||
) (*context.CompletionResponse, error) {
|
||||
|
||||
// Get connector object (capabilities were already set above, before stream_start)
|
||||
conn, capabilities, err := ast.GetConnector(ctx, opts)
|
||||
if err != nil {
|
||||
ast.traceAgentFail(agentNode, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set capabilities in options if not already set
|
||||
if completionOptions.Capabilities == nil && capabilities != nil {
|
||||
completionOptions.Capabilities = capabilities
|
||||
}
|
||||
|
||||
// Log the capabilities
|
||||
ast.traceConnectorCapabilities(agentNode, capabilities)
|
||||
|
||||
// Build content - convert extended types (file, data, __yao.attachment://) to standard LLM types
|
||||
// This is done here (right before LLM call) to ensure:
|
||||
// 1. autoSearch receives original messages (not converted)
|
||||
// 2. delegate receives original messages (not converted)
|
||||
// 3. Only the actual LLM call sees converted messages
|
||||
llmMessages, err := ast.BuildContent(ctx, completionMessages, completionOptions, opts)
|
||||
if err != nil {
|
||||
ast.traceAgentFail(agentNode, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Trace Add LLM request (use converted messages for trace)
|
||||
ast.traceLLMRequest(ctx, conn.ID(), llmMessages, completionOptions)
|
||||
|
||||
// Log LLM call start
|
||||
ctx.Logger.LLMStart(conn.ID(), "", len(llmMessages))
|
||||
|
||||
// Create LLM instance with connector and options
|
||||
llmInstance, err := llm.New(conn, completionOptions)
|
||||
if err != nil {
|
||||
// Mark LLM Request as failed in trace
|
||||
ast.traceLLMFail(ctx, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Call the LLM Completion Stream (streamHandler was set earlier)
|
||||
// Use llmMessages (converted) instead of completionMessages (original)
|
||||
completionResponse, err := llmInstance.Stream(ctx, llmMessages, completionOptions, streamHandler)
|
||||
|
||||
if err != nil {
|
||||
// Mark LLM Request as failed in trace
|
||||
ast.traceLLMFail(ctx, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Mark LLM Request Complete
|
||||
ast.traceLLMComplete(ctx, completionResponse)
|
||||
|
||||
return completionResponse, nil
|
||||
}
|
||||
|
||||
// executeLLMForToolRetry executes LLM call for tool retry with streaming output
|
||||
// This is used when retrying tool calls - we still want to show LLM's response to users
|
||||
// Returns completionResponse and error
|
||||
func (ast *Assistant) executeLLMForToolRetry(
|
||||
ctx *context.Context,
|
||||
completionMessages []context.Message,
|
||||
completionOptions *context.CompletionOptions,
|
||||
agentNode types.Node,
|
||||
streamHandler message.StreamFunc,
|
||||
opts *context.Options,
|
||||
) (*context.CompletionResponse, error) {
|
||||
|
||||
// Get connector object
|
||||
conn, capabilities, err := ast.GetConnector(ctx, opts)
|
||||
if err != nil {
|
||||
ast.traceAgentFail(agentNode, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set capabilities in options if not already set
|
||||
if completionOptions.Capabilities == nil && capabilities != nil {
|
||||
completionOptions.Capabilities = capabilities
|
||||
}
|
||||
|
||||
// Build content - convert extended types for LLM call
|
||||
llmMessages, err := ast.BuildContent(ctx, completionMessages, completionOptions, opts)
|
||||
if err != nil {
|
||||
ast.traceAgentFail(agentNode, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Trace Add LLM retry request
|
||||
ast.traceLLMRetryRequest(ctx, conn.ID(), llmMessages, completionOptions)
|
||||
|
||||
// Log LLM call start (retry)
|
||||
ctx.Logger.LLMStart(conn.ID(), "", len(llmMessages))
|
||||
|
||||
// Create LLM instance with connector and options
|
||||
llmInstance, err := llm.New(conn, completionOptions)
|
||||
if err != nil {
|
||||
// Mark LLM Retry Request as failed in trace
|
||||
ast.traceLLMFail(ctx, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Call the LLM Completion Stream (still streaming for tool retry)
|
||||
// Use llmMessages (converted) instead of completionMessages (original)
|
||||
completionResponse, err := llmInstance.Stream(ctx, llmMessages, completionOptions, streamHandler)
|
||||
if err != nil {
|
||||
// Mark LLM Retry Request as failed in trace
|
||||
ast.traceLLMFail(ctx, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Mark LLM Request Complete
|
||||
ast.traceLLMComplete(ctx, completionResponse)
|
||||
|
||||
return completionResponse, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,366 +0,0 @@
|
|||
package assistant_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
)
|
||||
|
||||
// TestLoadPathMerge tests loading the merge test assistant
|
||||
// This verifies that global config is properly merged with assistant-specific config
|
||||
func TestLoadPathMerge(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ast, err := assistant.LoadPath("/assistants/tests/merge")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
assert.Equal(t, "tests.merge", ast.ID)
|
||||
assert.Equal(t, "Merge Config Test Assistant", ast.Name)
|
||||
|
||||
// Uses configuration - should merge global with assistant-specific
|
||||
// Global (from agent/agent.yml):
|
||||
// vision: "workers.system.vision"
|
||||
// search: "workers.system.search"
|
||||
// fetch: "workers.system.fetch"
|
||||
// audio: (not set)
|
||||
// querydsl: (not set)
|
||||
// rerank: (not set)
|
||||
// Assistant:
|
||||
// web: "mcp:custom-web"
|
||||
// keyword: "mcp:custom-keyword"
|
||||
// Result: assistant values override, global values inherited
|
||||
assert.NotNil(t, ast.Uses)
|
||||
|
||||
// Assistant overrides
|
||||
assert.Equal(t, "mcp:custom-web", ast.Uses.Web) // overridden by assistant
|
||||
assert.Equal(t, "mcp:custom-keyword", ast.Uses.Keyword) // overridden by assistant
|
||||
|
||||
// Inherited from global (agent/agent.yml)
|
||||
assert.Equal(t, "workers.system.vision", ast.Uses.Vision) // inherited from global
|
||||
assert.Equal(t, "workers.system.search", ast.Uses.Search) // inherited from global
|
||||
assert.Equal(t, "workers.system.fetch", ast.Uses.Fetch) // inherited from global
|
||||
|
||||
// Not set in either global or assistant (should be empty)
|
||||
assert.Empty(t, ast.Uses.Audio) // not set anywhere
|
||||
assert.Empty(t, ast.Uses.QueryDSL) // not set anywhere
|
||||
assert.Empty(t, ast.Uses.Rerank) // not set anywhere
|
||||
|
||||
// Search configuration - should merge global with assistant-specific
|
||||
// Global (from agent/search.yml):
|
||||
// web.provider=tavily, web.max_results=10
|
||||
// kb.threshold=0.7, kb.graph=false
|
||||
// db.max_results=20
|
||||
// keyword.max_keywords=10, keyword.language=auto
|
||||
// rerank.top_n=10
|
||||
// citation.format=#ref:{id}, citation.auto_inject_prompt=true
|
||||
// weights: user=1.0, hook=0.8, auto=0.6
|
||||
// options.skip_threshold=5
|
||||
// Assistant:
|
||||
// web.provider=custom-provider, web.max_results=25
|
||||
// kb.collections=[merge-test-kb], kb.threshold=0.85
|
||||
assert.NotNil(t, ast.Search)
|
||||
|
||||
// Web config - assistant overrides global
|
||||
assert.NotNil(t, ast.Search.Web)
|
||||
assert.Equal(t, "custom-provider", ast.Search.Web.Provider) // overridden
|
||||
assert.Equal(t, 25, ast.Search.Web.MaxResults) // overridden
|
||||
|
||||
// KB config - assistant overrides global
|
||||
assert.NotNil(t, ast.Search.KB)
|
||||
assert.Equal(t, []string{"merge-test-kb"}, ast.Search.KB.Collections) // overridden
|
||||
assert.Equal(t, 0.85, ast.Search.KB.Threshold) // overridden
|
||||
assert.False(t, ast.Search.KB.Graph) // inherited from global
|
||||
|
||||
// DB config - should inherit from global (assistant doesn't define it)
|
||||
assert.NotNil(t, ast.Search.DB)
|
||||
assert.Equal(t, 20, ast.Search.DB.MaxResults) // inherited from global
|
||||
|
||||
// Keyword config - should inherit from global
|
||||
assert.NotNil(t, ast.Search.Keyword)
|
||||
assert.Equal(t, 10, ast.Search.Keyword.MaxKeywords) // inherited from global
|
||||
assert.Equal(t, "auto", ast.Search.Keyword.Language) // inherited from global
|
||||
|
||||
// Rerank config - should inherit from global
|
||||
assert.NotNil(t, ast.Search.Rerank)
|
||||
assert.Equal(t, 10, ast.Search.Rerank.TopN) // inherited from global
|
||||
|
||||
// Citation config - should inherit from global
|
||||
assert.NotNil(t, ast.Search.Citation)
|
||||
assert.Equal(t, "#ref:{id}", ast.Search.Citation.Format) // inherited from global
|
||||
assert.True(t, ast.Search.Citation.AutoInjectPrompt) // inherited from global
|
||||
|
||||
// Weights config - should inherit from global
|
||||
assert.NotNil(t, ast.Search.Weights)
|
||||
assert.Equal(t, 1.0, ast.Search.Weights.User) // inherited from global
|
||||
assert.Equal(t, 0.8, ast.Search.Weights.Hook) // inherited from global
|
||||
assert.Equal(t, 0.6, ast.Search.Weights.Auto) // inherited from global
|
||||
|
||||
// Options config - should inherit from global
|
||||
assert.NotNil(t, ast.Search.Options)
|
||||
assert.Equal(t, 5, ast.Search.Options.SkipThreshold) // inherited from global
|
||||
}
|
||||
|
||||
// TestLoadPathMergeOverride tests loading the merge-override test assistant
|
||||
// This verifies that assistant config completely overrides global config
|
||||
func TestLoadPathMergeOverride(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ast, err := assistant.LoadPath("/assistants/tests/merge-override")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
assert.Equal(t, "tests.merge-override", ast.ID)
|
||||
assert.Equal(t, "Merge Override Test Assistant", ast.Name)
|
||||
|
||||
// Uses configuration - all fields should be overridden by assistant
|
||||
assert.NotNil(t, ast.Uses)
|
||||
assert.Equal(t, "mcp:custom-vision", ast.Uses.Vision)
|
||||
assert.Equal(t, "mcp:custom-audio", ast.Uses.Audio)
|
||||
assert.Equal(t, "mcp:custom-search", ast.Uses.Search)
|
||||
assert.Equal(t, "mcp:custom-fetch", ast.Uses.Fetch)
|
||||
assert.Equal(t, "mcp:custom-web", ast.Uses.Web)
|
||||
assert.Equal(t, "mcp:custom-keyword", ast.Uses.Keyword)
|
||||
assert.Equal(t, "mcp:custom-querydsl", ast.Uses.QueryDSL)
|
||||
assert.Equal(t, "mcp:custom-rerank", ast.Uses.Rerank)
|
||||
|
||||
// Search configuration - all fields should be overridden by assistant
|
||||
assert.NotNil(t, ast.Search)
|
||||
|
||||
// Web config - all overridden
|
||||
assert.NotNil(t, ast.Search.Web)
|
||||
assert.Equal(t, "override-provider", ast.Search.Web.Provider)
|
||||
assert.Equal(t, "$ENV.OVERRIDE_API_KEY", ast.Search.Web.APIKeyEnv)
|
||||
assert.Equal(t, 100, ast.Search.Web.MaxResults)
|
||||
|
||||
// KB config - all overridden
|
||||
assert.NotNil(t, ast.Search.KB)
|
||||
assert.Equal(t, []string{"override-kb"}, ast.Search.KB.Collections)
|
||||
assert.Equal(t, 0.99, ast.Search.KB.Threshold)
|
||||
assert.True(t, ast.Search.KB.Graph)
|
||||
|
||||
// DB config - all overridden
|
||||
assert.NotNil(t, ast.Search.DB)
|
||||
assert.Equal(t, []string{"override-model"}, ast.Search.DB.Models)
|
||||
assert.Equal(t, 200, ast.Search.DB.MaxResults)
|
||||
|
||||
// Keyword config - all overridden
|
||||
assert.NotNil(t, ast.Search.Keyword)
|
||||
assert.Equal(t, 20, ast.Search.Keyword.MaxKeywords)
|
||||
assert.Equal(t, "zh", ast.Search.Keyword.Language)
|
||||
|
||||
// QueryDSL config - overridden
|
||||
assert.NotNil(t, ast.Search.QueryDSL)
|
||||
assert.True(t, ast.Search.QueryDSL.Strict)
|
||||
|
||||
// Rerank config - overridden
|
||||
assert.NotNil(t, ast.Search.Rerank)
|
||||
assert.Equal(t, 20, ast.Search.Rerank.TopN)
|
||||
|
||||
// Citation config - all overridden
|
||||
assert.NotNil(t, ast.Search.Citation)
|
||||
assert.Equal(t, "[override:{id}]", ast.Search.Citation.Format)
|
||||
assert.False(t, ast.Search.Citation.AutoInjectPrompt)
|
||||
assert.Equal(t, "Override citation prompt", ast.Search.Citation.CustomPrompt)
|
||||
|
||||
// Weights config - all overridden
|
||||
assert.NotNil(t, ast.Search.Weights)
|
||||
assert.Equal(t, 2.0, ast.Search.Weights.User)
|
||||
assert.Equal(t, 1.5, ast.Search.Weights.Hook)
|
||||
assert.Equal(t, 1.0, ast.Search.Weights.Auto)
|
||||
|
||||
// Options config - overridden
|
||||
assert.NotNil(t, ast.Search.Options)
|
||||
assert.Equal(t, 10, ast.Search.Options.SkipThreshold)
|
||||
}
|
||||
|
||||
// TestLoadPathMergeEmpty tests loading the merge-empty test assistant
|
||||
// This verifies that assistant with no uses/search config inherits all from global
|
||||
func TestLoadPathMergeEmpty(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ast, err := assistant.LoadPath("/assistants/tests/merge-empty")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
assert.Equal(t, "tests.merge-empty", ast.ID)
|
||||
assert.Equal(t, "Merge Empty Test Assistant", ast.Name)
|
||||
|
||||
// Uses configuration - all inherited from global (agent/agent.yml)
|
||||
assert.NotNil(t, ast.Uses)
|
||||
assert.Equal(t, "workers.system.vision", ast.Uses.Vision) // from global
|
||||
assert.Equal(t, "workers.system.search", ast.Uses.Search) // from global
|
||||
assert.Equal(t, "workers.system.fetch", ast.Uses.Fetch) // from global
|
||||
assert.Empty(t, ast.Uses.Audio) // not set in global
|
||||
assert.Empty(t, ast.Uses.Web) // not set in global
|
||||
assert.Empty(t, ast.Uses.Keyword) // not set in global
|
||||
assert.Empty(t, ast.Uses.QueryDSL) // not set in global
|
||||
assert.Empty(t, ast.Uses.Rerank) // not set in global
|
||||
|
||||
// Search configuration - all inherited from global (agent/search.yml)
|
||||
assert.NotNil(t, ast.Search)
|
||||
|
||||
// Web config - from global
|
||||
assert.NotNil(t, ast.Search.Web)
|
||||
assert.Equal(t, "tavily", ast.Search.Web.Provider)
|
||||
assert.Equal(t, 10, ast.Search.Web.MaxResults)
|
||||
|
||||
// KB config - from global
|
||||
assert.NotNil(t, ast.Search.KB)
|
||||
assert.Equal(t, 0.7, ast.Search.KB.Threshold)
|
||||
assert.False(t, ast.Search.KB.Graph)
|
||||
|
||||
// DB config - from global
|
||||
assert.NotNil(t, ast.Search.DB)
|
||||
assert.Equal(t, 20, ast.Search.DB.MaxResults)
|
||||
|
||||
// Keyword config - from global
|
||||
assert.NotNil(t, ast.Search.Keyword)
|
||||
assert.Equal(t, 10, ast.Search.Keyword.MaxKeywords)
|
||||
assert.Equal(t, "auto", ast.Search.Keyword.Language)
|
||||
|
||||
// Rerank config - from global
|
||||
assert.NotNil(t, ast.Search.Rerank)
|
||||
assert.Equal(t, 10, ast.Search.Rerank.TopN)
|
||||
|
||||
// Citation config - from global
|
||||
assert.NotNil(t, ast.Search.Citation)
|
||||
assert.Equal(t, "#ref:{id}", ast.Search.Citation.Format)
|
||||
assert.True(t, ast.Search.Citation.AutoInjectPrompt)
|
||||
|
||||
// Weights config - from global
|
||||
assert.NotNil(t, ast.Search.Weights)
|
||||
assert.Equal(t, 1.0, ast.Search.Weights.User)
|
||||
assert.Equal(t, 0.8, ast.Search.Weights.Hook)
|
||||
assert.Equal(t, 0.6, ast.Search.Weights.Auto)
|
||||
|
||||
// Options config - from global
|
||||
assert.NotNil(t, ast.Search.Options)
|
||||
assert.Equal(t, 5, ast.Search.Options.SkipThreshold)
|
||||
}
|
||||
|
||||
// TestLoadPathUsesAndSearchMerge tests loading fullfields assistant
|
||||
// This verifies that uses and search configs are properly loaded and merged
|
||||
func TestLoadPathUsesAndSearchMerge(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ast, err := assistant.LoadPath("/assistants/tests/fullfields")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
// Uses configuration - assistant-specific values
|
||||
assert.NotNil(t, ast.Uses)
|
||||
assert.Equal(t, "agent", ast.Uses.Vision)
|
||||
assert.Equal(t, "mcp:audio-server", ast.Uses.Audio)
|
||||
assert.Equal(t, "agent", ast.Uses.Fetch)
|
||||
assert.Equal(t, "builtin", ast.Uses.Web)
|
||||
assert.Equal(t, "builtin", ast.Uses.Keyword)
|
||||
assert.Equal(t, "builtin", ast.Uses.QueryDSL)
|
||||
assert.Equal(t, "builtin", ast.Uses.Rerank)
|
||||
|
||||
// Search configuration - assistant-specific values
|
||||
assert.NotNil(t, ast.Search)
|
||||
|
||||
// Web config - from assistant
|
||||
assert.NotNil(t, ast.Search.Web)
|
||||
assert.Equal(t, "tavily", ast.Search.Web.Provider)
|
||||
assert.Equal(t, 15, ast.Search.Web.MaxResults)
|
||||
|
||||
// KB config - from assistant
|
||||
assert.NotNil(t, ast.Search.KB)
|
||||
assert.Equal(t, []string{"docs", "faq"}, ast.Search.KB.Collections)
|
||||
assert.Equal(t, 0.8, ast.Search.KB.Threshold)
|
||||
assert.True(t, ast.Search.KB.Graph)
|
||||
|
||||
// DB config - from assistant
|
||||
assert.NotNil(t, ast.Search.DB)
|
||||
assert.Equal(t, []string{"user", "product"}, ast.Search.DB.Models)
|
||||
assert.Equal(t, 50, ast.Search.DB.MaxResults)
|
||||
|
||||
// Citation config - from assistant
|
||||
assert.NotNil(t, ast.Search.Citation)
|
||||
assert.Equal(t, "#ref:{id}", ast.Search.Citation.Format)
|
||||
assert.True(t, ast.Search.Citation.AutoInjectPrompt)
|
||||
|
||||
// Weights config - from assistant
|
||||
assert.NotNil(t, ast.Search.Weights)
|
||||
assert.Equal(t, 1.0, ast.Search.Weights.User)
|
||||
assert.Equal(t, 0.9, ast.Search.Weights.Hook)
|
||||
assert.Equal(t, 0.7, ast.Search.Weights.Auto)
|
||||
}
|
||||
|
||||
// TestLoadPathSearchAssistant tests loading the dedicated search test assistant
|
||||
func TestLoadPathSearchAssistant(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ast, err := assistant.LoadPath("/assistants/tests/search")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
assert.Equal(t, "tests.search", ast.ID)
|
||||
assert.Equal(t, "Search Config Test Assistant", ast.Name)
|
||||
|
||||
// Uses configuration
|
||||
assert.NotNil(t, ast.Uses)
|
||||
assert.Equal(t, "builtin", ast.Uses.Web)
|
||||
assert.Equal(t, "builtin", ast.Uses.Keyword)
|
||||
assert.Equal(t, "builtin", ast.Uses.QueryDSL)
|
||||
assert.Equal(t, "builtin", ast.Uses.Rerank)
|
||||
|
||||
// Search configuration
|
||||
assert.NotNil(t, ast.Search)
|
||||
|
||||
// Web config
|
||||
assert.NotNil(t, ast.Search.Web)
|
||||
assert.Equal(t, "serper", ast.Search.Web.Provider)
|
||||
assert.Equal(t, "$ENV.SERPER_API_KEY", ast.Search.Web.APIKeyEnv)
|
||||
assert.Equal(t, 20, ast.Search.Web.MaxResults)
|
||||
|
||||
// KB config
|
||||
assert.NotNil(t, ast.Search.KB)
|
||||
assert.Equal(t, []string{"knowledge-base", "documents"}, ast.Search.KB.Collections)
|
||||
assert.Equal(t, 0.75, ast.Search.KB.Threshold)
|
||||
assert.False(t, ast.Search.KB.Graph)
|
||||
|
||||
// DB config
|
||||
assert.NotNil(t, ast.Search.DB)
|
||||
assert.Equal(t, []string{"article", "comment"}, ast.Search.DB.Models)
|
||||
assert.Equal(t, 30, ast.Search.DB.MaxResults)
|
||||
|
||||
// Keyword config
|
||||
assert.NotNil(t, ast.Search.Keyword)
|
||||
assert.Equal(t, 8, ast.Search.Keyword.MaxKeywords)
|
||||
assert.Equal(t, "auto", ast.Search.Keyword.Language)
|
||||
|
||||
// QueryDSL config
|
||||
assert.NotNil(t, ast.Search.QueryDSL)
|
||||
assert.True(t, ast.Search.QueryDSL.Strict)
|
||||
|
||||
// Rerank config
|
||||
assert.NotNil(t, ast.Search.Rerank)
|
||||
assert.Equal(t, 5, ast.Search.Rerank.TopN)
|
||||
|
||||
// Citation config
|
||||
assert.NotNil(t, ast.Search.Citation)
|
||||
assert.Equal(t, "#cite:{id}", ast.Search.Citation.Format)
|
||||
assert.False(t, ast.Search.Citation.AutoInjectPrompt)
|
||||
assert.Equal(t, "Please cite sources using #cite:{id} format.", ast.Search.Citation.CustomPrompt)
|
||||
|
||||
// Weights config
|
||||
assert.NotNil(t, ast.Search.Weights)
|
||||
assert.Equal(t, 1.0, ast.Search.Weights.User)
|
||||
assert.Equal(t, 0.85, ast.Search.Weights.Hook)
|
||||
assert.Equal(t, 0.65, ast.Search.Weights.Auto)
|
||||
|
||||
// Options config
|
||||
assert.NotNil(t, ast.Search.Options)
|
||||
assert.Equal(t, 3, ast.Search.Options.SkipThreshold)
|
||||
}
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
package assistant_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/gou/process"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
)
|
||||
|
||||
func TestLoadProcessIntegration(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
// After testutils.Prepare, all assistants should be loaded and scripts registered
|
||||
// Test calling mcpload assistant's tools.Hello function
|
||||
|
||||
t.Run("CallHelloAfterLoad", func(t *testing.T) {
|
||||
proc := process.New("agents.tests.mcpload.tools.Hello", map[string]interface{}{
|
||||
"name": "TestUser",
|
||||
})
|
||||
|
||||
err := proc.Execute()
|
||||
assert.NoError(t, err)
|
||||
|
||||
result := proc.Value()
|
||||
assert.NotNil(t, result)
|
||||
|
||||
resultStr, ok := result.(string)
|
||||
assert.True(t, ok, "Result should be a string")
|
||||
assert.Contains(t, resultStr, "Hello, TestUser")
|
||||
assert.Contains(t, resultStr, "mcpload assistant")
|
||||
})
|
||||
|
||||
t.Run("CallPingAfterLoad", func(t *testing.T) {
|
||||
proc := process.New("agents.tests.mcpload.tools.Ping", map[string]interface{}{
|
||||
"message": "integration test",
|
||||
})
|
||||
|
||||
err := proc.Execute()
|
||||
assert.NoError(t, err)
|
||||
|
||||
result := proc.Value()
|
||||
assert.NotNil(t, result)
|
||||
|
||||
resultMap, ok := result.(map[string]interface{})
|
||||
assert.True(t, ok, "Result should be a map")
|
||||
assert.Equal(t, "integration test", resultMap["message"])
|
||||
assert.Contains(t, resultMap["echo"], "Pong")
|
||||
assert.NotEmpty(t, resultMap["timestamp"])
|
||||
})
|
||||
|
||||
t.Run("CallCalculateAfterLoad", func(t *testing.T) {
|
||||
proc := process.New("agents.tests.mcpload.tools.Calculate", map[string]interface{}{
|
||||
"operation": "add",
|
||||
"a": float64(100),
|
||||
"b": float64(50),
|
||||
})
|
||||
|
||||
err := proc.Execute()
|
||||
assert.NoError(t, err)
|
||||
|
||||
result := proc.Value()
|
||||
assert.NotNil(t, result)
|
||||
|
||||
resultMap, ok := result.(map[string]interface{})
|
||||
assert.True(t, ok, "Result should be a map")
|
||||
assert.Equal(t, float64(150), resultMap["result"])
|
||||
assert.Equal(t, "add", resultMap["operation"])
|
||||
assert.Equal(t, float64(100), resultMap["a"])
|
||||
assert.Equal(t, float64(50), resultMap["b"])
|
||||
})
|
||||
|
||||
t.Run("CallNonExistentScript", func(t *testing.T) {
|
||||
proc := process.New("agents.tests.mcpload.nonexistent.Method")
|
||||
|
||||
err := proc.Execute()
|
||||
assert.NotNil(t, err, "Should return error for non-existent script")
|
||||
assert.Contains(t, err.Error(), "Exception|404")
|
||||
})
|
||||
|
||||
t.Run("CallNonExistentMethod", func(t *testing.T) {
|
||||
proc := process.New("agents.tests.mcpload.tools.NonExistentMethod")
|
||||
|
||||
err := proc.Execute()
|
||||
assert.NotNil(t, err, "Should return error for non-existent method")
|
||||
assert.Contains(t, err.Error(), "Exception|500")
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadProcessMultipleAssistants(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
// Test that multiple assistants can have their scripts registered
|
||||
// and process calls work correctly for different assistants
|
||||
|
||||
t.Run("MCPLoadAssistant", func(t *testing.T) {
|
||||
proc := process.New("agents.tests.mcpload.tools.Hello", map[string]interface{}{
|
||||
"name": "User1",
|
||||
})
|
||||
|
||||
err := proc.Execute()
|
||||
assert.NoError(t, err)
|
||||
|
||||
result := proc.Value()
|
||||
resultStr, ok := result.(string)
|
||||
assert.True(t, ok)
|
||||
assert.Contains(t, resultStr, "mcpload assistant")
|
||||
})
|
||||
|
||||
// If there are other test assistants with scripts, they can be tested here
|
||||
// For now, we verify that the handler is properly isolated per assistant
|
||||
t.Run("VerifyIsolation", func(t *testing.T) {
|
||||
// Verify that the mcpload handler is correctly registered
|
||||
handler, exists := process.Handlers["agents.tests.mcpload.tools"]
|
||||
assert.True(t, exists, "Handler should be registered")
|
||||
assert.NotNil(t, handler)
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,353 +0,0 @@
|
|||
package assistant
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/yaoapp/gou/application"
|
||||
"github.com/yaoapp/gou/connector"
|
||||
gouOpenAI "github.com/yaoapp/gou/connector/openai"
|
||||
"github.com/yaoapp/kun/log"
|
||||
"github.com/yaoapp/yao/agent/i18n"
|
||||
store "github.com/yaoapp/yao/agent/store/types"
|
||||
"github.com/yaoapp/yao/data"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// systemAgents defines the system agents loaded from bindata
|
||||
// These are internal agents used by the system (e.g., keyword extraction, querydsl generation)
|
||||
// The directory name is without __yao. prefix, prefix is added during loading
|
||||
// Format: directory name -> bindata path prefix
|
||||
var systemAgents = []string{
|
||||
"keyword",
|
||||
"querydsl",
|
||||
"title",
|
||||
"prompt",
|
||||
"robot_prompt",
|
||||
"needsearch",
|
||||
"entity",
|
||||
"vision",
|
||||
"fetch",
|
||||
"loop_fallback",
|
||||
}
|
||||
|
||||
// SystemConfig holds the system agents connector configuration
|
||||
// This is set from agent.yml system block
|
||||
type SystemConfig struct {
|
||||
// Role-level defaults (consumed by buildSystemRoles → SetDefaults)
|
||||
Default string // Default connector for the "default" role
|
||||
Light string // Default connector for the "light" role
|
||||
Vision string // Default connector for the "vision" role
|
||||
Audio string // Default connector for the "audio" role
|
||||
Heavy string // Default connector for the "heavy" role (complex reasoning)
|
||||
|
||||
// Per-agent overrides (consumed by resolveSystemConnector → ast.Connector)
|
||||
Keyword string // Connector for __yao.keyword agent
|
||||
QueryDSL string // Connector for __yao.querydsl agent
|
||||
Title string // Connector for __yao.title agent
|
||||
Prompt string // Connector for __yao.prompt agent
|
||||
RobotPrompt string // Connector for __yao.robot_prompt agent
|
||||
NeedSearch string // Connector for __yao.needsearch agent
|
||||
Entity string // Connector for __yao.entity agent
|
||||
LoopFallback string // Connector for __yao.loop_fallback agent
|
||||
}
|
||||
|
||||
// systemConfig holds the system agents configuration (global variable like others in load.go)
|
||||
var systemConfig *SystemConfig = nil
|
||||
|
||||
// SetSystemConfig sets the system agents configuration
|
||||
func SetSystemConfig(config *SystemConfig) {
|
||||
systemConfig = config
|
||||
}
|
||||
|
||||
// GetSystemConfig returns the system agents configuration
|
||||
func GetSystemConfig() *SystemConfig {
|
||||
return systemConfig
|
||||
}
|
||||
|
||||
// LoadSystemAgents loads the system agents from bindata
|
||||
// These are internal agents like __yao.keyword and __yao.querydsl
|
||||
// They are loaded before application assistants
|
||||
// Behavior is same as LoadBuiltIn, just reads from bindata instead of filesystem
|
||||
func LoadSystemAgents() error {
|
||||
|
||||
// Get all existing system agents (for cleanup)
|
||||
deletedSystem := map[string]bool{}
|
||||
if storage != nil {
|
||||
// System agents have "system" tag
|
||||
tags := []string{"system"}
|
||||
builtIn := true
|
||||
res, err := storage.GetAssistants(store.AssistantFilter{
|
||||
Tags: tags,
|
||||
BuiltIn: &builtIn,
|
||||
Select: []string{"assistant_id", "id"},
|
||||
})
|
||||
if err != nil {
|
||||
log.Warn("Failed to get existing system agents: %v", err)
|
||||
} else {
|
||||
for _, assistant := range res.Data {
|
||||
deletedSystem[assistant.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort := 1
|
||||
for _, name := range systemAgents {
|
||||
// Build agent ID with __yao. prefix
|
||||
id := "__yao." + name
|
||||
pathPrefix := "yao/assistants/" + name
|
||||
|
||||
assistant, err := loadSystemAgent(id, pathPrefix)
|
||||
if err != nil {
|
||||
log.Warn("Failed to load system agent %s: %v", id, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Set sort order
|
||||
if assistant.Sort == 0 {
|
||||
assistant.Sort = sort
|
||||
}
|
||||
|
||||
// Save to storage
|
||||
if err := assistant.Save(); err != nil {
|
||||
log.Warn("Failed to save system agent %s: %v", id, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Initialize the assistant
|
||||
if err := assistant.initialize(); err != nil {
|
||||
log.Warn("Failed to initialize system agent %s: %v", id, err)
|
||||
continue
|
||||
}
|
||||
|
||||
sort++
|
||||
loaded.Put(assistant)
|
||||
log.Trace("Loaded system agent: %s", id)
|
||||
|
||||
// Remove from deleted list
|
||||
delete(deletedSystem, id)
|
||||
}
|
||||
|
||||
// Remove deleted system agents
|
||||
if len(deletedSystem) > 0 {
|
||||
assistantIDs := []string{}
|
||||
for assistantID := range deletedSystem {
|
||||
assistantIDs = append(assistantIDs, assistantID)
|
||||
}
|
||||
if _, err := storage.DeleteAssistants(store.AssistantFilter{AssistantIDs: assistantIDs}); err != nil {
|
||||
log.Warn("Failed to delete obsolete system agents: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadSystemAgent loads a single system agent from bindata
|
||||
// This follows the same pattern as LoadPath but reads from bindata
|
||||
func loadSystemAgent(id, pathPrefix string) (*Assistant, error) {
|
||||
// Read package.yao from bindata
|
||||
pkgPath := pathPrefix + "/package.yao"
|
||||
pkgContent, err := data.Read(pkgPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read %s: %w", pkgPath, err)
|
||||
}
|
||||
|
||||
// Parse package.yao
|
||||
var pkgData map[string]interface{}
|
||||
if err := application.Parse(pkgPath, pkgContent, &pkgData); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse %s: %w", pkgPath, err)
|
||||
}
|
||||
|
||||
// Set assistant_id (no path - system agents are loaded from storage, not filesystem)
|
||||
pkgData["assistant_id"] = id
|
||||
|
||||
// Set type if not specified
|
||||
if _, has := pkgData["type"]; !has {
|
||||
pkgData["type"] = "assistant"
|
||||
}
|
||||
|
||||
// Override connector only if agent.yml has an explicit per-agent setting
|
||||
if override := resolveSystemConnector(id); override != "" {
|
||||
pkgData["connector"] = override
|
||||
}
|
||||
|
||||
// Read prompts.yml from bindata (default prompts)
|
||||
promptsPath := pathPrefix + "/prompts.yml"
|
||||
promptsContent, err := data.Read(promptsPath)
|
||||
if err == nil {
|
||||
var prompts []store.Prompt
|
||||
if err := yaml.Unmarshal(promptsContent, &prompts); err == nil && len(prompts) > 0 {
|
||||
pkgData["prompts"] = prompts
|
||||
}
|
||||
}
|
||||
|
||||
// Read prompt_presets from prompts directory
|
||||
presets := loadSystemPromptPresets(pathPrefix)
|
||||
if len(presets) > 0 {
|
||||
pkgData["prompt_presets"] = presets
|
||||
}
|
||||
|
||||
// Load scripts from src directory (hook script source and other scripts sources)
|
||||
// These will be compiled by loadMap -> LoadScriptsFromData
|
||||
hookScriptSource, scriptsSource := loadSystemScripts(pathPrefix)
|
||||
if hookScriptSource != "" {
|
||||
pkgData["script"] = hookScriptSource
|
||||
}
|
||||
if len(scriptsSource) > 0 {
|
||||
pkgData["scripts"] = scriptsSource
|
||||
}
|
||||
|
||||
// Read locales
|
||||
locales, err := loadSystemLocales(pathPrefix)
|
||||
if err == nil && len(locales) > 0 {
|
||||
pkgData["locales"] = locales
|
||||
}
|
||||
|
||||
// Mark as system agent
|
||||
pkgData["readonly"] = true
|
||||
pkgData["built_in"] = true
|
||||
pkgData["tags"] = []string{"system"}
|
||||
|
||||
// Load from map (same as LoadPath, includes initialize())
|
||||
return loadMap(pkgData)
|
||||
}
|
||||
|
||||
// resolveSystemConnector returns an explicit per-agent connector override from agent.yml.
|
||||
// Returns empty string if no override exists, so the connector declared in package.yao
|
||||
// (e.g. "use::light") is preserved as-is.
|
||||
func resolveSystemConnector(agentID string) string {
|
||||
if systemConfig == nil {
|
||||
return ""
|
||||
}
|
||||
switch agentID {
|
||||
case "__yao.keyword":
|
||||
return systemConfig.Keyword
|
||||
case "__yao.querydsl":
|
||||
return systemConfig.QueryDSL
|
||||
case "__yao.title":
|
||||
return systemConfig.Title
|
||||
case "__yao.prompt":
|
||||
return systemConfig.Prompt
|
||||
case "__yao.robot_prompt":
|
||||
return systemConfig.RobotPrompt
|
||||
case "__yao.needsearch":
|
||||
return systemConfig.NeedSearch
|
||||
case "__yao.entity":
|
||||
return systemConfig.Entity
|
||||
case "__yao.vision":
|
||||
return systemConfig.Vision
|
||||
case "__yao.audio":
|
||||
return systemConfig.Audio
|
||||
case "__yao.loop_fallback":
|
||||
return systemConfig.LoopFallback
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// findCapableConnector finds the first connector that supports tool calling
|
||||
func findCapableConnector() string {
|
||||
for id, conn := range connector.Connectors {
|
||||
if !conn.Is(connector.OPENAI) {
|
||||
continue
|
||||
}
|
||||
|
||||
if connOpenAI, ok := conn.(*gouOpenAI.Connector); ok {
|
||||
if connOpenAI.Options.Capabilities != nil && connOpenAI.Options.Capabilities.ToolCalls {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// loadSystemPromptPresets loads prompt presets from bindata prompts directory
|
||||
func loadSystemPromptPresets(pathPrefix string) map[string][]store.Prompt {
|
||||
presets := make(map[string][]store.Prompt)
|
||||
promptsDir := pathPrefix + "/prompts"
|
||||
|
||||
// Try common preset files
|
||||
presetFiles := []string{"chat.yml", "task.yml", "code.yml", "analysis.yml"}
|
||||
for _, filename := range presetFiles {
|
||||
presetPath := promptsDir + "/" + filename
|
||||
content, err := data.Read(presetPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var prompts []store.Prompt
|
||||
if err := yaml.Unmarshal(content, &prompts); err == nil && len(prompts) > 0 {
|
||||
presetName := strings.TrimSuffix(filename, ".yml")
|
||||
presets[presetName] = prompts
|
||||
}
|
||||
}
|
||||
|
||||
return presets
|
||||
}
|
||||
|
||||
// loadSystemScripts loads scripts source from bindata src directory
|
||||
// Returns hook script source and other scripts sources (as strings)
|
||||
// These will be compiled by loadMap -> LoadScriptsFromData
|
||||
func loadSystemScripts(pathPrefix string) (string, map[string]string) {
|
||||
srcDir := pathPrefix + "/src"
|
||||
|
||||
// Try to load hook script (index.ts)
|
||||
var hookScriptSource string
|
||||
indexPath := srcDir + "/index.ts"
|
||||
indexContent, err := data.Read(indexPath)
|
||||
if err == nil && len(indexContent) > 0 {
|
||||
hookScriptSource = string(indexContent)
|
||||
}
|
||||
|
||||
// Try to load other scripts
|
||||
scripts := make(map[string]string)
|
||||
scriptFiles := []string{"utils.ts", "helpers.ts", "tools.ts"}
|
||||
for _, filename := range scriptFiles {
|
||||
scriptPath := srcDir + "/" + filename
|
||||
content, err := data.Read(scriptPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
scriptName := strings.TrimSuffix(filename, ".ts")
|
||||
scripts[scriptName] = string(content)
|
||||
}
|
||||
|
||||
if len(scripts) == 0 {
|
||||
scripts = nil
|
||||
}
|
||||
|
||||
return hookScriptSource, scripts
|
||||
}
|
||||
|
||||
// loadSystemLocales loads locales from bindata
|
||||
func loadSystemLocales(pathPrefix string) (i18n.Map, error) {
|
||||
locales := make(i18n.Map)
|
||||
|
||||
// Try to load common locale files
|
||||
localeFiles := []string{"en-us.yml", "zh-cn.yml", "en.yml", "zh.yml"}
|
||||
localesDir := pathPrefix + "/locales"
|
||||
|
||||
for _, filename := range localeFiles {
|
||||
localePath := filepath.Join(localesDir, filename)
|
||||
content, err := data.Read(localePath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse locale file
|
||||
locale := strings.TrimSuffix(filename, ".yml")
|
||||
var messages map[string]any
|
||||
if err := yaml.Unmarshal(content, &messages); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
locales[locale] = i18n.I18n{
|
||||
Locale: locale,
|
||||
Messages: messages,
|
||||
}
|
||||
}
|
||||
|
||||
return locales, nil
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
package assistant
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestResolveSystemConnector_NoConfig(t *testing.T) {
|
||||
saved := systemConfig
|
||||
systemConfig = nil
|
||||
defer func() { systemConfig = saved }()
|
||||
|
||||
assert.Equal(t, "", resolveSystemConnector("__yao.title"))
|
||||
assert.Equal(t, "", resolveSystemConnector("__yao.keyword"))
|
||||
assert.Equal(t, "", resolveSystemConnector("__yao.querydsl"))
|
||||
assert.Equal(t, "", resolveSystemConnector("__yao.vision"))
|
||||
}
|
||||
|
||||
func TestResolveSystemConnector_PerAgentOverride(t *testing.T) {
|
||||
saved := systemConfig
|
||||
systemConfig = &SystemConfig{
|
||||
Title: "openai.gpt-4o",
|
||||
}
|
||||
defer func() { systemConfig = saved }()
|
||||
|
||||
assert.Equal(t, "openai.gpt-4o", resolveSystemConnector("__yao.title"))
|
||||
assert.Equal(t, "", resolveSystemConnector("__yao.keyword"))
|
||||
assert.Equal(t, "", resolveSystemConnector("__yao.querydsl"))
|
||||
assert.Equal(t, "", resolveSystemConnector("__yao.vision"))
|
||||
}
|
||||
|
||||
func TestResolveSystemConnector_RoleLevelOnly(t *testing.T) {
|
||||
saved := systemConfig
|
||||
systemConfig = &SystemConfig{
|
||||
Default: "openai.gpt-4o",
|
||||
Light: "openai.gpt-4o-mini",
|
||||
}
|
||||
defer func() { systemConfig = saved }()
|
||||
|
||||
// Role-level keys don't produce per-agent overrides
|
||||
assert.Equal(t, "", resolveSystemConnector("__yao.title"))
|
||||
assert.Equal(t, "", resolveSystemConnector("__yao.keyword"))
|
||||
assert.Equal(t, "", resolveSystemConnector("__yao.querydsl"))
|
||||
assert.Equal(t, "", resolveSystemConnector("__yao.vision"))
|
||||
}
|
||||
|
||||
func TestResolveSystemConnector_UnknownAgent(t *testing.T) {
|
||||
saved := systemConfig
|
||||
systemConfig = &SystemConfig{
|
||||
Default: "openai.gpt-4o",
|
||||
Title: "openai.gpt-4o",
|
||||
}
|
||||
defer func() { systemConfig = saved }()
|
||||
|
||||
assert.Equal(t, "", resolveSystemConnector("__yao.nonexistent"))
|
||||
assert.Equal(t, "", resolveSystemConnector("custom.agent"))
|
||||
}
|
||||
|
|
@ -1,803 +0,0 @@
|
|||
package assistant_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/agent"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
store "github.com/yaoapp/yao/agent/store/types"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/test"
|
||||
)
|
||||
|
||||
func prepare(t *testing.T) {
|
||||
test.Prepare(t, config.Conf)
|
||||
}
|
||||
|
||||
func prepareAgent(t *testing.T) {
|
||||
test.Prepare(t, config.Conf)
|
||||
err := agent.Load(config.Conf)
|
||||
require.NoError(t, err, "agent.Load should succeed")
|
||||
}
|
||||
|
||||
// TestLoadPath tests loading assistant from path
|
||||
func TestLoadPath(t *testing.T) {
|
||||
prepare(t)
|
||||
defer test.Clean()
|
||||
|
||||
t.Run("LoadFullFieldsAssistant", func(t *testing.T) {
|
||||
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, assistant)
|
||||
|
||||
// Basic fields
|
||||
assert.Equal(t, "tests.fullfields", assistant.ID)
|
||||
assert.Equal(t, "Full Fields Test Assistant", assistant.Name)
|
||||
assert.Equal(t, "assistant", assistant.Type)
|
||||
assert.Equal(t, "/api/__yao/app/icons/app.png", assistant.Avatar)
|
||||
assert.Equal(t, "gpt-4o", assistant.Connector)
|
||||
assert.Equal(t, "/assistants/tests/fullfields", assistant.Path)
|
||||
assert.Equal(t, "Test assistant with all available fields for unit testing", assistant.Description)
|
||||
|
||||
// Boolean fields
|
||||
assert.True(t, assistant.Public)
|
||||
assert.True(t, assistant.Readonly)
|
||||
assert.True(t, assistant.Mentionable)
|
||||
assert.False(t, assistant.Automated)
|
||||
assert.True(t, assistant.DisableGlobalPrompts)
|
||||
|
||||
// Share field
|
||||
assert.Equal(t, "team", assistant.Share)
|
||||
|
||||
// Sort field
|
||||
assert.Equal(t, 100, assistant.Sort)
|
||||
|
||||
// Tags
|
||||
assert.NotNil(t, assistant.Tags)
|
||||
assert.Contains(t, assistant.Tags, "Test")
|
||||
assert.Contains(t, assistant.Tags, "Development")
|
||||
assert.Contains(t, assistant.Tags, "FullFields")
|
||||
|
||||
// Options
|
||||
assert.NotNil(t, assistant.Options)
|
||||
assert.Equal(t, 0.7, assistant.Options["temperature"])
|
||||
assert.Equal(t, float64(2000), assistant.Options["max_tokens"])
|
||||
|
||||
// Prompts (default prompts from prompts.yml)
|
||||
assert.NotNil(t, assistant.Prompts)
|
||||
assert.GreaterOrEqual(t, len(assistant.Prompts), 1)
|
||||
assert.Equal(t, "system", assistant.Prompts[0].Role)
|
||||
|
||||
// Script (from src/index.ts)
|
||||
assert.NotNil(t, assistant.HookScript)
|
||||
})
|
||||
|
||||
t.Run("LoadConnectorOptions", func(t *testing.T) {
|
||||
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, assistant)
|
||||
|
||||
// ConnectorOptions
|
||||
assert.NotNil(t, assistant.ConnectorOptions)
|
||||
assert.NotNil(t, assistant.ConnectorOptions.Optional)
|
||||
assert.True(t, *assistant.ConnectorOptions.Optional)
|
||||
assert.NotNil(t, assistant.ConnectorOptions.Connectors)
|
||||
assert.Contains(t, assistant.ConnectorOptions.Connectors, "gpt-4o")
|
||||
assert.Contains(t, assistant.ConnectorOptions.Connectors, "gpt-4o-mini")
|
||||
assert.Contains(t, assistant.ConnectorOptions.Connectors, "deepseek")
|
||||
assert.NotNil(t, assistant.ConnectorOptions.Filters)
|
||||
assert.Len(t, assistant.ConnectorOptions.Filters, 2)
|
||||
})
|
||||
|
||||
t.Run("LoadPromptPresets", func(t *testing.T) {
|
||||
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, assistant)
|
||||
|
||||
// PromptPresets (from prompts directory)
|
||||
assert.NotNil(t, assistant.PromptPresets)
|
||||
|
||||
// Top-level presets: chat.yml -> "chat", task.yml -> "task"
|
||||
chatPreset, hasChat := assistant.PromptPresets["chat"]
|
||||
assert.True(t, hasChat, "Should have 'chat' preset")
|
||||
assert.NotEmpty(t, chatPreset)
|
||||
|
||||
taskPreset, hasTask := assistant.PromptPresets["task"]
|
||||
assert.True(t, hasTask, "Should have 'task' preset")
|
||||
assert.NotEmpty(t, taskPreset)
|
||||
|
||||
// Nested presets: chat/friendly.yml -> "chat.friendly"
|
||||
friendlyPreset, hasFriendly := assistant.PromptPresets["chat.friendly"]
|
||||
assert.True(t, hasFriendly, "Should have 'chat.friendly' preset")
|
||||
assert.NotEmpty(t, friendlyPreset)
|
||||
|
||||
professionalPreset, hasProfessional := assistant.PromptPresets["chat.professional"]
|
||||
assert.True(t, hasProfessional, "Should have 'chat.professional' preset")
|
||||
assert.NotEmpty(t, professionalPreset)
|
||||
|
||||
// task/analysis.yml -> "task.analysis"
|
||||
analysisPreset, hasAnalysis := assistant.PromptPresets["task.analysis"]
|
||||
assert.True(t, hasAnalysis, "Should have 'task.analysis' preset")
|
||||
assert.NotEmpty(t, analysisPreset)
|
||||
})
|
||||
|
||||
t.Run("LoadKnowledgeBase", func(t *testing.T) {
|
||||
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, assistant)
|
||||
|
||||
// KB
|
||||
assert.NotNil(t, assistant.KB)
|
||||
assert.NotNil(t, assistant.KB.Collections)
|
||||
assert.Contains(t, assistant.KB.Collections, "test-collection")
|
||||
assert.NotNil(t, assistant.KB.Options)
|
||||
assert.Equal(t, float64(5), assistant.KB.Options["top_k"])
|
||||
})
|
||||
|
||||
t.Run("LoadMCPServers", func(t *testing.T) {
|
||||
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, assistant)
|
||||
|
||||
// MCP
|
||||
assert.NotNil(t, assistant.MCP)
|
||||
assert.NotNil(t, assistant.MCP.Servers)
|
||||
assert.Len(t, assistant.MCP.Servers, 1)
|
||||
assert.Equal(t, "echo", assistant.MCP.Servers[0].ServerID)
|
||||
assert.Contains(t, assistant.MCP.Servers[0].Tools, "ping")
|
||||
assert.Contains(t, assistant.MCP.Servers[0].Tools, "echo")
|
||||
})
|
||||
|
||||
t.Run("LoadWorkflow", func(t *testing.T) {
|
||||
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, assistant)
|
||||
|
||||
// Workflow
|
||||
assert.NotNil(t, assistant.Workflow)
|
||||
assert.NotNil(t, assistant.Workflow.Workflows)
|
||||
assert.Contains(t, assistant.Workflow.Workflows, "test-workflow")
|
||||
assert.NotNil(t, assistant.Workflow.Options)
|
||||
assert.Equal(t, float64(10), assistant.Workflow.Options["max_steps"])
|
||||
})
|
||||
|
||||
t.Run("LoadPlaceholder", func(t *testing.T) {
|
||||
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, assistant)
|
||||
|
||||
// Placeholder
|
||||
assert.NotNil(t, assistant.Placeholder)
|
||||
assert.Equal(t, "Full Fields Test", assistant.Placeholder.Title)
|
||||
assert.Equal(t, "Test assistant with complete field coverage", assistant.Placeholder.Description)
|
||||
assert.NotNil(t, assistant.Placeholder.Prompts)
|
||||
assert.Len(t, assistant.Placeholder.Prompts, 3)
|
||||
})
|
||||
|
||||
t.Run("LoadLocales", func(t *testing.T) {
|
||||
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, assistant)
|
||||
|
||||
// Locales
|
||||
assert.NotNil(t, assistant.Locales)
|
||||
|
||||
enLocale, hasEn := assistant.Locales["en-us"]
|
||||
assert.True(t, hasEn, "Should have en-us locale")
|
||||
assert.NotNil(t, enLocale)
|
||||
|
||||
zhLocale, hasZh := assistant.Locales["zh-cn"]
|
||||
assert.True(t, hasZh, "Should have zh-cn locale")
|
||||
assert.NotNil(t, zhLocale)
|
||||
})
|
||||
|
||||
t.Run("LoadDependencies", func(t *testing.T) {
|
||||
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, assistant)
|
||||
|
||||
// Dependencies
|
||||
assert.NotNil(t, assistant.Dependencies)
|
||||
assert.Len(t, assistant.Dependencies, 2)
|
||||
assert.Equal(t, "^1.0.0", assistant.Dependencies["echo"])
|
||||
assert.Equal(t, ">=2.0.0", assistant.Dependencies["customer"])
|
||||
})
|
||||
|
||||
t.Run("LoadNonExistentAssistant", func(t *testing.T) {
|
||||
_, err := assistant.LoadPath("/assistants/non-existent")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestLoadPathMCPTest tests loading the MCP test assistant
|
||||
func TestLoadPathMCPTest(t *testing.T) {
|
||||
prepare(t)
|
||||
defer test.Clean()
|
||||
|
||||
assistant, err := assistant.LoadPath("/assistants/tests/mcptest")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, assistant)
|
||||
|
||||
assert.Equal(t, "tests.mcptest", assistant.ID)
|
||||
assert.Equal(t, "MCP Test Assistant", assistant.Name)
|
||||
assert.Equal(t, "gpt-4o", assistant.Connector)
|
||||
|
||||
// MCP configuration
|
||||
assert.NotNil(t, assistant.MCP)
|
||||
assert.Len(t, assistant.MCP.Servers, 1)
|
||||
assert.Equal(t, "echo", assistant.MCP.Servers[0].ServerID)
|
||||
|
||||
// Locales
|
||||
assert.NotNil(t, assistant.Locales)
|
||||
assert.Contains(t, assistant.Locales, "en-us")
|
||||
assert.Contains(t, assistant.Locales, "zh-cn")
|
||||
}
|
||||
|
||||
// TestLoadPathBuildRequest tests loading the build request test assistant
|
||||
func TestLoadPathBuildRequest(t *testing.T) {
|
||||
prepare(t)
|
||||
defer test.Clean()
|
||||
|
||||
assistant, err := assistant.LoadPath("/assistants/tests/buildrequest")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, assistant)
|
||||
|
||||
assert.Equal(t, "tests.buildrequest", assistant.ID)
|
||||
assert.Equal(t, "Build Request Test", assistant.Name)
|
||||
|
||||
// HookScript should be loaded
|
||||
assert.NotNil(t, assistant.HookScript)
|
||||
|
||||
// Options
|
||||
assert.NotNil(t, assistant.Options)
|
||||
assert.Equal(t, 0.5, assistant.Options["temperature"])
|
||||
}
|
||||
|
||||
// TestCache tests the assistant cache functionality
|
||||
func TestCache(t *testing.T) {
|
||||
// Clear any existing cache
|
||||
assistant.ClearCache()
|
||||
|
||||
// Set small cache for testing
|
||||
assistant.SetCache(3)
|
||||
assert.NotNil(t, assistant.GetCache())
|
||||
|
||||
// Create test assistants
|
||||
ast1 := &assistant.Assistant{AssistantModel: store.AssistantModel{ID: "id1", Name: "Assistant 1"}}
|
||||
ast2 := &assistant.Assistant{AssistantModel: store.AssistantModel{ID: "id2", Name: "Assistant 2"}}
|
||||
ast3 := &assistant.Assistant{AssistantModel: store.AssistantModel{ID: "id3", Name: "Assistant 3"}}
|
||||
ast4 := &assistant.Assistant{AssistantModel: store.AssistantModel{ID: "id4", Name: "Assistant 4"}}
|
||||
|
||||
t.Run("PutAndGet", func(t *testing.T) {
|
||||
assistant.GetCache().Put(ast1)
|
||||
assert.Equal(t, 1, assistant.GetCache().Len())
|
||||
|
||||
cached, exists := assistant.GetCache().Get("id1")
|
||||
assert.True(t, exists)
|
||||
assert.Equal(t, ast1, cached)
|
||||
})
|
||||
|
||||
t.Run("CacheEviction", func(t *testing.T) {
|
||||
assistant.GetCache().Put(ast2)
|
||||
assistant.GetCache().Put(ast3)
|
||||
assert.Equal(t, 3, assistant.GetCache().Len())
|
||||
|
||||
// Access ast1 to make it recently used
|
||||
assistant.GetCache().Get("id1")
|
||||
|
||||
// Add ast4, should evict ast2 (least recently used)
|
||||
assistant.GetCache().Put(ast4)
|
||||
assert.Equal(t, 3, assistant.GetCache().Len())
|
||||
|
||||
_, exists := assistant.GetCache().Get("id2")
|
||||
assert.False(t, exists, "ast2 should be evicted")
|
||||
|
||||
_, exists = assistant.GetCache().Get("id1")
|
||||
assert.True(t, exists, "ast1 should still exist")
|
||||
|
||||
_, exists = assistant.GetCache().Get("id4")
|
||||
assert.True(t, exists, "ast4 should exist")
|
||||
})
|
||||
|
||||
t.Run("ClearCache", func(t *testing.T) {
|
||||
assistant.ClearCache()
|
||||
assert.Nil(t, assistant.GetCache())
|
||||
})
|
||||
|
||||
t.Run("SetCacheAfterClear", func(t *testing.T) {
|
||||
assistant.SetCache(100)
|
||||
assert.NotNil(t, assistant.GetCache())
|
||||
})
|
||||
}
|
||||
|
||||
// TestClone tests the assistant Clone method
|
||||
func TestClone(t *testing.T) {
|
||||
prepare(t)
|
||||
defer test.Clean()
|
||||
|
||||
t.Run("CloneFullFieldsAssistant", func(t *testing.T) {
|
||||
original, err := assistant.LoadPath("/assistants/tests/fullfields")
|
||||
require.NoError(t, err)
|
||||
|
||||
clone := original.Clone()
|
||||
require.NotNil(t, clone)
|
||||
|
||||
// Basic fields should be equal
|
||||
assert.Equal(t, original.ID, clone.ID)
|
||||
assert.Equal(t, original.Name, clone.Name)
|
||||
assert.Equal(t, original.Type, clone.Type)
|
||||
assert.Equal(t, original.Connector, clone.Connector)
|
||||
assert.Equal(t, original.Description, clone.Description)
|
||||
|
||||
// Verify deep copy - modifying original should not affect clone
|
||||
if len(original.Tags) > 0 {
|
||||
originalTag := original.Tags[0]
|
||||
original.Tags[0] = "modified"
|
||||
assert.NotEqual(t, original.Tags[0], clone.Tags[0])
|
||||
original.Tags[0] = originalTag // restore
|
||||
}
|
||||
|
||||
if original.Options != nil {
|
||||
original.Options["test_key"] = "test_value"
|
||||
_, exists := clone.Options["test_key"]
|
||||
assert.False(t, exists, "Clone should not have modified key")
|
||||
delete(original.Options, "test_key") // cleanup
|
||||
}
|
||||
|
||||
if original.Dependencies != nil {
|
||||
original.Dependencies["test_dep"] = "^9.9.9"
|
||||
_, exists := clone.Dependencies["test_dep"]
|
||||
assert.False(t, exists, "Clone dependencies should not have modified key")
|
||||
delete(original.Dependencies, "test_dep") // cleanup
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CloneNil", func(t *testing.T) {
|
||||
var nilAssistant *assistant.Assistant
|
||||
assert.Nil(t, nilAssistant.Clone())
|
||||
})
|
||||
}
|
||||
|
||||
// TestUpdate tests the assistant Update method
|
||||
func TestUpdate(t *testing.T) {
|
||||
prepare(t)
|
||||
defer test.Clean()
|
||||
|
||||
t.Run("UpdateBasicFields", func(t *testing.T) {
|
||||
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
|
||||
require.NoError(t, err)
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"name": "Updated Name",
|
||||
"description": "Updated description",
|
||||
"tags": []string{"updated", "tags"},
|
||||
}
|
||||
|
||||
err = assistant.Update(updates)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "Updated Name", assistant.Name)
|
||||
assert.Equal(t, "Updated description", assistant.Description)
|
||||
assert.Equal(t, []string{"updated", "tags"}, assistant.Tags)
|
||||
})
|
||||
|
||||
t.Run("UpdateConnectorOptions", func(t *testing.T) {
|
||||
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
|
||||
require.NoError(t, err)
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"connector_options": map[string]interface{}{
|
||||
"optional": false,
|
||||
"connectors": []string{"new-connector"},
|
||||
},
|
||||
}
|
||||
|
||||
err = assistant.Update(updates)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotNil(t, assistant.ConnectorOptions)
|
||||
assert.NotNil(t, assistant.ConnectorOptions.Optional)
|
||||
assert.False(t, *assistant.ConnectorOptions.Optional)
|
||||
assert.Contains(t, assistant.ConnectorOptions.Connectors, "new-connector")
|
||||
})
|
||||
|
||||
t.Run("UpdatePromptPresets", func(t *testing.T) {
|
||||
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
|
||||
require.NoError(t, err)
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"prompt_presets": map[string]interface{}{
|
||||
"custom": []map[string]interface{}{
|
||||
{"role": "system", "content": "Custom preset"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err = assistant.Update(updates)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotNil(t, assistant.PromptPresets)
|
||||
customPreset, exists := assistant.PromptPresets["custom"]
|
||||
assert.True(t, exists)
|
||||
assert.Len(t, customPreset, 1)
|
||||
})
|
||||
|
||||
t.Run("UpdateSource", func(t *testing.T) {
|
||||
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
|
||||
require.NoError(t, err)
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"source": "function Create(ctx, messages) { return { messages: messages }; }",
|
||||
}
|
||||
|
||||
err = assistant.Update(updates)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "function Create(ctx, messages) { return { messages: messages }; }", assistant.Source)
|
||||
})
|
||||
|
||||
t.Run("UpdateNilAssistant", func(t *testing.T) {
|
||||
var nilAssistant *assistant.Assistant
|
||||
err := nilAssistant.Update(map[string]interface{}{"name": "test"})
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestMap tests the assistant Map method
|
||||
func TestMap(t *testing.T) {
|
||||
prepare(t)
|
||||
defer test.Clean()
|
||||
|
||||
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
|
||||
require.NoError(t, err)
|
||||
|
||||
m := assistant.Map()
|
||||
require.NotNil(t, m)
|
||||
|
||||
// Check all fields are present
|
||||
assert.Equal(t, assistant.ID, m["assistant_id"])
|
||||
assert.Equal(t, assistant.Name, m["name"])
|
||||
assert.Equal(t, assistant.Type, m["type"])
|
||||
assert.Equal(t, assistant.Connector, m["connector"])
|
||||
assert.Equal(t, assistant.Description, m["description"])
|
||||
assert.Equal(t, assistant.Path, m["path"])
|
||||
assert.Equal(t, assistant.Tags, m["tags"])
|
||||
assert.Equal(t, assistant.Options, m["options"])
|
||||
assert.Equal(t, assistant.Prompts, m["prompts"])
|
||||
assert.Equal(t, assistant.KB, m["kb"])
|
||||
assert.Equal(t, assistant.MCP, m["mcp"])
|
||||
assert.Equal(t, assistant.Workflow, m["workflow"])
|
||||
assert.Equal(t, assistant.Placeholder, m["placeholder"])
|
||||
assert.Equal(t, assistant.Locales, m["locales"])
|
||||
|
||||
// New fields
|
||||
assert.Equal(t, assistant.ConnectorOptions, m["connector_options"])
|
||||
assert.Equal(t, assistant.PromptPresets, m["prompt_presets"])
|
||||
assert.Equal(t, assistant.Source, m["source"])
|
||||
assert.Equal(t, assistant.Dependencies, m["dependencies"])
|
||||
}
|
||||
|
||||
// TestLoadSystemAgents tests loading system agents from bindata
|
||||
func TestLoadSystemAgents(t *testing.T) {
|
||||
prepareAgent(t)
|
||||
defer test.Clean()
|
||||
|
||||
// Clear cache first
|
||||
assistant.ClearCache()
|
||||
assistant.SetCache(200)
|
||||
|
||||
t.Run("LoadSystemAgents", func(t *testing.T) {
|
||||
err := assistant.LoadSystemAgents()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check __yao.keyword
|
||||
keywordAst, keywordExists := assistant.GetCache().Get("__yao.keyword")
|
||||
require.True(t, keywordExists, "__yao.keyword should be loaded")
|
||||
assert.Equal(t, "__yao.keyword", keywordAst.ID)
|
||||
assert.Equal(t, "Keyword Extractor", keywordAst.Name)
|
||||
assert.True(t, keywordAst.Readonly)
|
||||
assert.True(t, keywordAst.BuiltIn)
|
||||
assert.Contains(t, keywordAst.Tags, "system")
|
||||
assert.NotNil(t, keywordAst.Prompts)
|
||||
assert.Greater(t, len(keywordAst.Prompts), 0)
|
||||
|
||||
// Check __yao.querydsl
|
||||
querydslAst, querydslExists := assistant.GetCache().Get("__yao.querydsl")
|
||||
require.True(t, querydslExists, "__yao.querydsl should be loaded")
|
||||
assert.Equal(t, "__yao.querydsl", querydslAst.ID)
|
||||
assert.Equal(t, "Query Builder", querydslAst.Name)
|
||||
assert.True(t, querydslAst.Readonly)
|
||||
assert.True(t, querydslAst.BuiltIn)
|
||||
assert.Contains(t, querydslAst.Tags, "system")
|
||||
assert.NotNil(t, querydslAst.Prompts)
|
||||
assert.Greater(t, len(querydslAst.Prompts), 0)
|
||||
|
||||
// Check __yao.title
|
||||
titleAst, titleExists := assistant.GetCache().Get("__yao.title")
|
||||
require.True(t, titleExists, "__yao.title should be loaded")
|
||||
assert.Equal(t, "__yao.title", titleAst.ID)
|
||||
assert.Equal(t, "Title Generator", titleAst.Name)
|
||||
assert.True(t, titleAst.Readonly)
|
||||
assert.True(t, titleAst.BuiltIn)
|
||||
|
||||
// Check __yao.prompt
|
||||
promptAst, promptExists := assistant.GetCache().Get("__yao.prompt")
|
||||
require.True(t, promptExists, "__yao.prompt should be loaded")
|
||||
assert.Equal(t, "__yao.prompt", promptAst.ID)
|
||||
assert.Equal(t, "Prompt Optimizer", promptAst.Name)
|
||||
assert.True(t, promptAst.Readonly)
|
||||
assert.True(t, promptAst.BuiltIn)
|
||||
|
||||
// Check __yao.needsearch
|
||||
needsearchAst, needsearchExists := assistant.GetCache().Get("__yao.needsearch")
|
||||
require.True(t, needsearchExists, "__yao.needsearch should be loaded")
|
||||
assert.Equal(t, "__yao.needsearch", needsearchAst.ID)
|
||||
assert.Equal(t, "Reference Checker", needsearchAst.Name)
|
||||
assert.True(t, needsearchAst.Readonly)
|
||||
assert.True(t, needsearchAst.BuiltIn)
|
||||
})
|
||||
|
||||
t.Run("SystemAgentsSavedToStorage", func(t *testing.T) {
|
||||
// System agents should be saved to storage
|
||||
require.NotNil(t, assistant.GetStore(), "storage should be initialized")
|
||||
|
||||
// Check __yao.keyword in storage
|
||||
builtIn := true
|
||||
tags := []string{"system"}
|
||||
res, err := assistant.GetStore().GetAssistants(store.AssistantFilter{
|
||||
BuiltIn: &builtIn,
|
||||
Tags: tags,
|
||||
Select: []string{"assistant_id", "name"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, len(res.Data), 0, "System agents should be in storage")
|
||||
|
||||
// Verify at least one system agent exists
|
||||
found := false
|
||||
for _, ast := range res.Data {
|
||||
if ast.ID == "__yao.keyword" || ast.ID == "__yao.querydsl" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "System agents should be found in storage")
|
||||
})
|
||||
|
||||
t.Run("SystemAgentsGetFromStorage", func(t *testing.T) {
|
||||
// Clear cache to force loading from storage
|
||||
assistant.GetCache().Clear()
|
||||
|
||||
// Test Get for each system agent
|
||||
systemAgents := []string{
|
||||
"__yao.keyword",
|
||||
"__yao.querydsl",
|
||||
"__yao.title",
|
||||
"__yao.prompt",
|
||||
"__yao.needsearch",
|
||||
"__yao.entity",
|
||||
}
|
||||
|
||||
for _, agentID := range systemAgents {
|
||||
ast, err := assistant.Get(agentID)
|
||||
require.NoError(t, err, "Get(%s) should succeed", agentID)
|
||||
require.NotNil(t, ast, "Get(%s) should return assistant", agentID)
|
||||
assert.Equal(t, agentID, ast.ID)
|
||||
assert.True(t, ast.BuiltIn, "%s should be built-in", agentID)
|
||||
assert.True(t, ast.Readonly, "%s should be readonly", agentID)
|
||||
assert.Contains(t, ast.Tags, "system", "%s should have system tag", agentID)
|
||||
assert.Equal(t, "worker", ast.Type, "%s should be worker type", agentID)
|
||||
assert.NotNil(t, ast.Prompts, "%s should have prompts", agentID)
|
||||
assert.Greater(t, len(ast.Prompts), 0, "%s should have at least one prompt", agentID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestLoadPathSandboxV2 tests loading assistants with V2 sandbox configuration (standalone sandbox.yao)
|
||||
func TestLoadPathSandboxV2(t *testing.T) {
|
||||
prepare(t)
|
||||
defer test.Clean()
|
||||
|
||||
t.Run("OneshotCLI", func(t *testing.T) {
|
||||
ast, err := assistant.LoadPath("/assistants/tests/sandbox-v2/oneshot-cli")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
assert.Equal(t, "Sandbox V2 Oneshot CLI", ast.Name)
|
||||
assert.Contains(t, ast.Tags, "SandboxV2")
|
||||
|
||||
// V2 sandbox should be loaded from sandbox.yao
|
||||
require.NotNil(t, ast.SandboxV2, "SandboxV2 should be loaded")
|
||||
assert.Equal(t, "2.0", ast.SandboxV2.Version)
|
||||
assert.Equal(t, "yaoapp/tai-sandbox-claude:latest", ast.SandboxV2.Computer.Image)
|
||||
assert.Equal(t, "2GB", ast.SandboxV2.Computer.Memory)
|
||||
assert.Equal(t, float64(2), ast.SandboxV2.Computer.CPUs)
|
||||
assert.Equal(t, "/workspace", ast.SandboxV2.Computer.WorkDir)
|
||||
assert.Equal(t, "claude", ast.SandboxV2.Runner.Name)
|
||||
assert.Equal(t, "cli", ast.SandboxV2.Runner.Mode)
|
||||
assert.Equal(t, "oneshot", ast.SandboxV2.Lifecycle)
|
||||
|
||||
// Runner options
|
||||
assert.NotNil(t, ast.SandboxV2.Runner.Options)
|
||||
assert.Equal(t, float64(5), ast.SandboxV2.Runner.Options["max_turns"])
|
||||
|
||||
// V1 Sandbox should be nil
|
||||
assert.Nil(t, ast.Sandbox, "V1 Sandbox should be nil when V2 is present")
|
||||
|
||||
// ConfigHash should be computed
|
||||
assert.NotEmpty(t, ast.ConfigHash, "ConfigHash should be computed for V2 sandbox")
|
||||
|
||||
// HasSandboxV2 helper
|
||||
assert.True(t, ast.HasSandboxV2())
|
||||
})
|
||||
|
||||
t.Run("SessionCLI", func(t *testing.T) {
|
||||
ast, err := assistant.LoadPath("/assistants/tests/sandbox-v2/session-cli")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
require.NotNil(t, ast.SandboxV2)
|
||||
assert.Equal(t, "session", ast.SandboxV2.Lifecycle)
|
||||
assert.Equal(t, "10m", ast.SandboxV2.IdleTimeout)
|
||||
|
||||
// Prepare steps
|
||||
require.Len(t, ast.SandboxV2.Prepare, 1)
|
||||
assert.Equal(t, "exec", ast.SandboxV2.Prepare[0].Action)
|
||||
assert.True(t, ast.SandboxV2.Prepare[0].Once)
|
||||
})
|
||||
|
||||
t.Run("LongrunningCLI", func(t *testing.T) {
|
||||
ast, err := assistant.LoadPath("/assistants/tests/sandbox-v2/longrunning-cli")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
require.NotNil(t, ast.SandboxV2)
|
||||
assert.Equal(t, "longrunning", ast.SandboxV2.Lifecycle)
|
||||
assert.Equal(t, "15m", ast.SandboxV2.IdleTimeout)
|
||||
assert.Equal(t, "2h", ast.SandboxV2.MaxLifetime)
|
||||
assert.Equal(t, "5s", ast.SandboxV2.StopTimeout)
|
||||
assert.Equal(t, "4GB", ast.SandboxV2.Computer.Memory)
|
||||
assert.Equal(t, "rw", ast.SandboxV2.Computer.MountMode)
|
||||
|
||||
// Environment
|
||||
assert.Equal(t, "test", ast.SandboxV2.Environment["NODE_ENV"])
|
||||
assert.Equal(t, "longrunning", ast.SandboxV2.Environment["V2_TEST_MODE"])
|
||||
|
||||
// Secrets
|
||||
assert.Equal(t, "sandbox-v2-longrunning-secret", ast.SandboxV2.Secrets["TEST_SECRET"])
|
||||
|
||||
// Prepare steps
|
||||
require.Len(t, ast.SandboxV2.Prepare, 3)
|
||||
assert.True(t, ast.SandboxV2.Prepare[2].IgnoreError)
|
||||
|
||||
// MCP (from package.yao)
|
||||
require.NotNil(t, ast.MCP)
|
||||
require.Len(t, ast.MCP.Servers, 1)
|
||||
assert.Equal(t, "echo", ast.MCP.Servers[0].ServerID)
|
||||
|
||||
// ConfigHash should include MCP servers
|
||||
hashWithMCP := ast.ConfigHash
|
||||
assert.NotEmpty(t, hashWithMCP)
|
||||
})
|
||||
|
||||
t.Run("HooksOnly_YaoRunner", func(t *testing.T) {
|
||||
ast, err := assistant.LoadPath("/assistants/tests/sandbox-v2/hooks-only")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
require.NotNil(t, ast.SandboxV2)
|
||||
assert.Equal(t, "yao", ast.SandboxV2.Runner.Name)
|
||||
assert.Equal(t, "oneshot", ast.SandboxV2.Lifecycle)
|
||||
assert.Equal(t, float64(1), ast.SandboxV2.Computer.CPUs)
|
||||
|
||||
// Runner mode should be empty (yao runner ignores mode)
|
||||
assert.Empty(t, ast.SandboxV2.Runner.Mode)
|
||||
})
|
||||
|
||||
t.Run("FullPrepare", func(t *testing.T) {
|
||||
ast, err := assistant.LoadPath("/assistants/tests/sandbox-v2/full-prepare")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
require.NotNil(t, ast.SandboxV2)
|
||||
assert.Equal(t, "session", ast.SandboxV2.Lifecycle)
|
||||
assert.Equal(t, "15m", ast.SandboxV2.IdleTimeout)
|
||||
|
||||
// Prepare: 5 steps with mixed actions
|
||||
require.Len(t, ast.SandboxV2.Prepare, 5)
|
||||
assert.Equal(t, "copy", ast.SandboxV2.Prepare[0].Action)
|
||||
assert.Equal(t, "skills", ast.SandboxV2.Prepare[0].Src)
|
||||
assert.Equal(t, "~/.claude/skills", ast.SandboxV2.Prepare[0].Dst)
|
||||
assert.Equal(t, "exec", ast.SandboxV2.Prepare[1].Action)
|
||||
assert.True(t, ast.SandboxV2.Prepare[1].Once)
|
||||
assert.True(t, ast.SandboxV2.Prepare[3].IgnoreError)
|
||||
|
||||
// Environment + Secrets
|
||||
assert.Equal(t, "full", ast.SandboxV2.Environment["V2_PREPARE_TEST"])
|
||||
assert.Equal(t, "v2-full-prepare-key", ast.SandboxV2.Secrets["TEST_API_KEY"])
|
||||
|
||||
// Runner options
|
||||
assert.Equal(t, "acceptEdits", ast.SandboxV2.Runner.Options["permission_mode"])
|
||||
})
|
||||
|
||||
t.Run("HostMode", func(t *testing.T) {
|
||||
ast, err := assistant.LoadPath("/assistants/tests/sandbox-v2/host-mode")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
require.NotNil(t, ast.SandboxV2)
|
||||
// Host mode: no image
|
||||
assert.Empty(t, ast.SandboxV2.Computer.Image)
|
||||
assert.Equal(t, "/tmp/yao-sandbox-v2-host-test", ast.SandboxV2.Computer.WorkDir)
|
||||
assert.Equal(t, "session", ast.SandboxV2.Lifecycle)
|
||||
})
|
||||
|
||||
t.Run("ConfigHashDeterministic", func(t *testing.T) {
|
||||
ast1, err := assistant.LoadPath("/assistants/tests/sandbox-v2/oneshot-cli")
|
||||
require.NoError(t, err)
|
||||
ast2, err := assistant.LoadPath("/assistants/tests/sandbox-v2/oneshot-cli")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ast1.ConfigHash, ast2.ConfigHash, "same config should produce same hash")
|
||||
})
|
||||
|
||||
t.Run("ConfigHashDiffers", func(t *testing.T) {
|
||||
ast1, err := assistant.LoadPath("/assistants/tests/sandbox-v2/oneshot-cli")
|
||||
require.NoError(t, err)
|
||||
ast2, err := assistant.LoadPath("/assistants/tests/sandbox-v2/longrunning-cli")
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, ast1.ConfigHash, ast2.ConfigHash, "different configs should produce different hashes")
|
||||
})
|
||||
}
|
||||
|
||||
// TestValidate tests the assistant Validate method
|
||||
func TestValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ast *assistant.Assistant
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "ValidAssistant",
|
||||
ast: &assistant.Assistant{
|
||||
AssistantModel: store.AssistantModel{
|
||||
ID: "test-id",
|
||||
Name: "Test Assistant",
|
||||
Connector: "gpt-4o",
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "MissingID",
|
||||
ast: &assistant.Assistant{
|
||||
AssistantModel: store.AssistantModel{
|
||||
Name: "Test Assistant",
|
||||
Connector: "gpt-4o",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "MissingName",
|
||||
ast: &assistant.Assistant{
|
||||
AssistantModel: store.AssistantModel{
|
||||
ID: "test-id",
|
||||
Connector: "gpt-4o",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.ast.Validate()
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,295 +0,0 @@
|
|||
package assistant
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/output/message"
|
||||
"github.com/yaoapp/yao/trace/types"
|
||||
)
|
||||
|
||||
// ToolLoopParams holds all parameters needed by executeToolLoop.
|
||||
type ToolLoopParams struct {
|
||||
CompletionMessages []context.Message
|
||||
CompletionOptions *context.CompletionOptions
|
||||
CompletionResponse *context.CompletionResponse
|
||||
ToolCallResponses []context.ToolCallResponse
|
||||
FullMessages []context.Message
|
||||
AgentNode types.Node
|
||||
StreamHandler message.StreamFunc
|
||||
CreateResponse *context.HookCreateResponse
|
||||
Opts *context.Options
|
||||
}
|
||||
|
||||
// executeToolLoop feeds tool results back to the LLM in a loop until
|
||||
// the LLM produces a final text response (no more tool_calls) or
|
||||
// the maximum number of turns is reached.
|
||||
//
|
||||
// Returns the final Response, the last CompletionResponse (for tracing),
|
||||
// accumulated ToolCallResponses, and any error.
|
||||
func (ast *Assistant) executeToolLoop(
|
||||
ctx *context.Context,
|
||||
params *ToolLoopParams,
|
||||
) (*context.Response, *context.CompletionResponse, []context.ToolCallResponse, error) {
|
||||
|
||||
maxTurns := ast.getMaxToolLoopTurns()
|
||||
currentMessages := params.CompletionMessages
|
||||
currentCompletion := params.CompletionResponse
|
||||
allToolResponses := make([]context.ToolCallResponse, 0, len(params.ToolCallResponses))
|
||||
allToolResponses = append(allToolResponses, params.ToolCallResponses...)
|
||||
|
||||
for turn := 0; turn < maxTurns; turn++ {
|
||||
ctx.Logger.Debug("Tool loop turn %d/%d", turn+1, maxTurns)
|
||||
|
||||
// Build messages: previous messages + assistant(tool_calls) + tool results
|
||||
loopMessages := buildToolLoopMessages(currentMessages, currentCompletion, allToolResponses[len(allToolResponses)-len(params.ToolCallResponses):])
|
||||
|
||||
// Step tracking: LLM call
|
||||
ast.BeginStep(ctx, context.StepTypeLLM, map[string]interface{}{
|
||||
"messages": loopMessages,
|
||||
"loop_turn": turn + 1,
|
||||
})
|
||||
|
||||
// Call LLM with tool results included
|
||||
newCompletion, err := ast.executeLLMStream(ctx, loopMessages, params.CompletionOptions, params.AgentNode, params.StreamHandler, params.Opts)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("tool loop LLM call failed (turn %d): %w", turn+1, err)
|
||||
}
|
||||
|
||||
ast.CompleteStep(ctx, map[string]interface{}{
|
||||
"content": newCompletion.Content,
|
||||
"tool_calls": newCompletion.ToolCalls,
|
||||
})
|
||||
|
||||
// No tool_calls → LLM gave final text response
|
||||
if newCompletion.ToolCalls == nil || len(newCompletion.ToolCalls) == 0 {
|
||||
finalResponse := ast.buildStandardResponse(&NextProcessContext{
|
||||
Context: ctx,
|
||||
CompletionResponse: newCompletion,
|
||||
FullMessages: params.FullMessages,
|
||||
ToolCallResponses: allToolResponses,
|
||||
StreamHandler: params.StreamHandler,
|
||||
CreateResponse: params.CreateResponse,
|
||||
})
|
||||
return finalResponse, newCompletion, allToolResponses, nil
|
||||
}
|
||||
|
||||
// Has tool_calls → execute them
|
||||
ast.BeginStep(ctx, context.StepTypeTool, map[string]interface{}{
|
||||
"tool_calls": newCompletion.ToolCalls,
|
||||
"loop_turn": turn + 1,
|
||||
})
|
||||
|
||||
toolResults, _ := ast.executeToolCalls(ctx, newCompletion.ToolCalls, 0)
|
||||
|
||||
// Convert ToolCallResult → ToolCallResponse
|
||||
toolCallArgsMap := make(map[string]interface{})
|
||||
for _, tc := range newCompletion.ToolCalls {
|
||||
toolCallArgsMap[tc.ID] = tc.Function.Arguments
|
||||
}
|
||||
|
||||
turnResponses := make([]context.ToolCallResponse, len(toolResults))
|
||||
for i, result := range toolResults {
|
||||
parsedContent, _ := result.ParsedContent()
|
||||
turnResponses[i] = context.ToolCallResponse{
|
||||
ToolCallID: result.ToolCallID,
|
||||
Server: result.Server(),
|
||||
Tool: result.Tool(),
|
||||
Arguments: toolCallArgsMap[result.ToolCallID],
|
||||
Result: parsedContent,
|
||||
Error: "",
|
||||
}
|
||||
if result.Error != nil {
|
||||
turnResponses[i].Error = result.Error.Error()
|
||||
}
|
||||
}
|
||||
|
||||
ast.CompleteStep(ctx, map[string]interface{}{
|
||||
"results": turnResponses,
|
||||
"loop_turn": turn + 1,
|
||||
})
|
||||
|
||||
// Accumulate and prepare next iteration
|
||||
allToolResponses = append(allToolResponses, turnResponses...)
|
||||
currentMessages = loopMessages
|
||||
currentCompletion = newCompletion
|
||||
params.ToolCallResponses = turnResponses
|
||||
}
|
||||
|
||||
return nil, nil, allToolResponses, fmt.Errorf("tool loop reached max turns (%d)", maxTurns)
|
||||
}
|
||||
|
||||
// buildToolLoopMessages constructs the message sequence for the next LLM call:
|
||||
// previous messages + assistant message (with tool_calls) + tool result messages.
|
||||
// Unlike buildToolRetryMessages, this does NOT append a retry system prompt.
|
||||
func buildToolLoopMessages(
|
||||
previousMessages []context.Message,
|
||||
completion *context.CompletionResponse,
|
||||
toolResponses []context.ToolCallResponse,
|
||||
) []context.Message {
|
||||
messages := make([]context.Message, 0, len(previousMessages)+len(toolResponses)+2)
|
||||
messages = append(messages, previousMessages...)
|
||||
|
||||
// Assistant message with tool_calls
|
||||
messages = append(messages, context.Message{
|
||||
Role: context.RoleAssistant,
|
||||
Content: completion.Content,
|
||||
ReasoningContent: completion.ReasoningContent,
|
||||
ToolCalls: completion.ToolCalls,
|
||||
})
|
||||
|
||||
// One tool-role message per tool call result
|
||||
for _, tr := range toolResponses {
|
||||
var content string
|
||||
if tr.Error != "" {
|
||||
content = fmt.Sprintf("Error: %s", tr.Error)
|
||||
} else if tr.Result != nil {
|
||||
raw, _ := jsoniter.MarshalToString(tr.Result)
|
||||
content = raw
|
||||
}
|
||||
toolCallID := tr.ToolCallID
|
||||
messages = append(messages, context.Message{
|
||||
Role: context.RoleTool,
|
||||
Content: content,
|
||||
ToolCallID: &toolCallID,
|
||||
})
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
// isToolLoopDisabled checks mcp.options.tool_loop.
|
||||
// Default is enabled (returns false). Only disabled when explicitly set to false.
|
||||
func (ast *Assistant) isToolLoopDisabled() bool {
|
||||
if ast.MCP == nil || ast.MCP.Options == nil {
|
||||
return false
|
||||
}
|
||||
if v, ok := ast.MCP.Options["tool_loop"]; ok {
|
||||
if enabled, ok := v.(bool); ok {
|
||||
return !enabled
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getMaxToolLoopTurns reads mcp.options.max_turn. Default is 5.
|
||||
func (ast *Assistant) getMaxToolLoopTurns() int {
|
||||
const defaultMaxTurns = 5
|
||||
if ast.MCP == nil || ast.MCP.Options == nil {
|
||||
return defaultMaxTurns
|
||||
}
|
||||
if v, ok := ast.MCP.Options["max_turn"]; ok {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
if n > 0 {
|
||||
return int(n)
|
||||
}
|
||||
case int:
|
||||
if n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return defaultMaxTurns
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fallback: __yao.loop_fallback delegation (used when tool loop fails/maxes out)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// buildLoopFallbackDelegate constructs a DelegateConfig for __yao.loop_fallback.
|
||||
// It packages conversation context and tool results into a Markdown user message.
|
||||
func (ast *Assistant) buildLoopFallbackDelegate(
|
||||
ctx *context.Context,
|
||||
fullMessages []context.Message,
|
||||
completion *context.CompletionResponse,
|
||||
toolResults []context.ToolCallResponse,
|
||||
) *context.DelegateConfig {
|
||||
|
||||
content := buildLoopFallbackMarkdown(fullMessages, toolResults)
|
||||
return &context.DelegateConfig{
|
||||
AgentID: "__yao.loop_fallback",
|
||||
Messages: []context.Message{
|
||||
{Role: context.RoleUser, Content: content},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// buildLoopFallbackMarkdown formats context into a Markdown string for the fallback agent.
|
||||
func buildLoopFallbackMarkdown(
|
||||
fullMessages []context.Message,
|
||||
toolResults []context.ToolCallResponse,
|
||||
) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("## Assistant Context\n\n")
|
||||
for _, msg := range fullMessages {
|
||||
if msg.Role == context.RoleSystem {
|
||||
if text := messageText(msg); text != "" {
|
||||
sb.WriteString(text)
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("## Conversation\n\n")
|
||||
for _, msg := range fullMessages {
|
||||
text := messageText(msg)
|
||||
switch msg.Role {
|
||||
case context.RoleUser:
|
||||
if text != "" {
|
||||
sb.WriteString(fmt.Sprintf("**User**: %s\n\n", text))
|
||||
}
|
||||
case context.RoleAssistant:
|
||||
if text != "" {
|
||||
sb.WriteString(fmt.Sprintf("**Assistant**: %s\n\n", text))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("## Tool Results\n\n")
|
||||
for _, tr := range toolResults {
|
||||
toolName := tr.Tool
|
||||
if tr.Server != "" {
|
||||
toolName = tr.Server + "." + tr.Tool
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("### %s\n", toolName))
|
||||
if tr.Error != "" {
|
||||
sb.WriteString(fmt.Sprintf("Error: %s\n\n", tr.Error))
|
||||
} else {
|
||||
raw, _ := jsoniter.MarshalToString(tr.Result)
|
||||
sb.WriteString(fmt.Sprintf("```json\n%s\n```\n\n", raw))
|
||||
}
|
||||
}
|
||||
|
||||
sb.WriteString("---\nPlease answer the user's question based on the above context and tool results.\n")
|
||||
sb.WriteString("Respond in the same language as the user.\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// messageText extracts text content from a message's Content field.
|
||||
// Content can be a string or an array of content parts (multimodal).
|
||||
func messageText(msg context.Message) string {
|
||||
if msg.Content == nil {
|
||||
return ""
|
||||
}
|
||||
if str, ok := msg.Content.(string); ok {
|
||||
return str
|
||||
}
|
||||
if parts, ok := msg.Content.([]interface{}); ok {
|
||||
var texts []string
|
||||
for _, part := range parts {
|
||||
if partMap, ok := part.(map[string]interface{}); ok {
|
||||
if partMap["type"] == "text" {
|
||||
if text, ok := partMap["text"].(string); ok {
|
||||
texts = append(texts, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(texts, "\n")
|
||||
}
|
||||
return fmt.Sprintf("%v", msg.Content)
|
||||
}
|
||||
|
|
@ -1,856 +0,0 @@
|
|||
package assistant
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
gouJson "github.com/yaoapp/gou/json"
|
||||
"github.com/yaoapp/gou/mcp"
|
||||
mcpTypes "github.com/yaoapp/gou/mcp/types"
|
||||
agentContext "github.com/yaoapp/yao/agent/context"
|
||||
storeTypes "github.com/yaoapp/yao/agent/store/types"
|
||||
"github.com/yaoapp/yao/trace/types"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxMCPTools maximum number of MCP tools to include (to avoid overwhelming the LLM)
|
||||
MaxMCPTools = 20
|
||||
)
|
||||
|
||||
// MCPToolName formats a tool name with MCP server prefix
|
||||
// Format: server_id__tool_name (double underscore separator)
|
||||
// Dots in server_id are replaced with single underscores
|
||||
// Examples:
|
||||
// - ("echo", "ping") → "echo__ping"
|
||||
// - ("github.enterprise", "search") → "github_enterprise__search"
|
||||
//
|
||||
// Naming constraint: MCP server_id MUST NOT contain underscores (_)
|
||||
// Only dots (.), letters, numbers, and hyphens (-) are allowed in server_id
|
||||
func MCPToolName(serverID, toolName string) string {
|
||||
if serverID == "" || toolName == "" {
|
||||
return ""
|
||||
}
|
||||
// Replace dots with single underscores in server_id
|
||||
cleanServerID := strings.ReplaceAll(serverID, ".", "_")
|
||||
// Use double underscore as separator
|
||||
return fmt.Sprintf("%s__%s", cleanServerID, toolName)
|
||||
}
|
||||
|
||||
// ParseMCPToolName parses a formatted MCP tool name into server ID and tool name
|
||||
// Splits by double underscore (__), then restores dots in server_id
|
||||
// Examples:
|
||||
// - "echo__ping" → ("echo", "ping")
|
||||
// - "github_enterprise__search" → ("github.enterprise", "search")
|
||||
//
|
||||
// Returns (serverID, toolName, true) if valid format, ("", "", false) otherwise
|
||||
func ParseMCPToolName(formattedName string) (string, string, bool) {
|
||||
if formattedName == "" {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// Split by double underscore
|
||||
parts := strings.Split(formattedName, "__")
|
||||
if len(parts) != 2 {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
cleanServerID := parts[0]
|
||||
toolName := parts[1]
|
||||
|
||||
// Validate that both parts are non-empty
|
||||
if cleanServerID == "" || toolName == "" {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// Restore dots in server_id (replace single underscores back to dots)
|
||||
serverID := strings.ReplaceAll(cleanServerID, "_", ".")
|
||||
|
||||
return serverID, toolName, true
|
||||
}
|
||||
|
||||
// buildMCPTools builds tool definitions and samples system prompt from MCP servers
|
||||
// Returns (tools, samplesPrompt, error)
|
||||
func (ast *Assistant) buildMCPTools(ctx *agentContext.Context, createResponse *agentContext.HookCreateResponse) ([]MCPTool, string, error) {
|
||||
// Determine which MCP servers to use: hook's or assistant's (hook takes precedence)
|
||||
var servers []storeTypes.MCPServerConfig
|
||||
|
||||
// If hook provides MCP servers, use those (override)
|
||||
if createResponse != nil && len(createResponse.MCPServers) > 0 {
|
||||
servers = make([]storeTypes.MCPServerConfig, len(createResponse.MCPServers))
|
||||
for i, hookServer := range createResponse.MCPServers {
|
||||
// Convert context.MCPServerConfig to storeTypes.MCPServerConfig
|
||||
servers[i] = storeTypes.MCPServerConfig{
|
||||
ServerID: hookServer.ServerID,
|
||||
Tools: hookServer.Tools,
|
||||
Resources: hookServer.Resources,
|
||||
}
|
||||
}
|
||||
} else if ast.MCP != nil && len(ast.MCP.Servers) > 0 {
|
||||
// Otherwise, use assistant's configured servers
|
||||
servers = ast.MCP.Servers
|
||||
} else {
|
||||
// No servers configured
|
||||
return nil, "", nil
|
||||
}
|
||||
|
||||
// Use the agent context for cancellation and timeout control
|
||||
mcpCtx := ctx.Context
|
||||
if mcpCtx == nil {
|
||||
mcpCtx = context.Background()
|
||||
}
|
||||
|
||||
allTools := make([]MCPTool, 0)
|
||||
samplesBuilder := strings.Builder{}
|
||||
hasSamples := false
|
||||
|
||||
// Process each MCP server in order
|
||||
for _, serverConfig := range servers {
|
||||
if len(allTools) >= MaxMCPTools {
|
||||
ctx.Logger.Warn("Reached maximum tool limit (%d), skipping remaining servers", MaxMCPTools)
|
||||
break
|
||||
}
|
||||
|
||||
// Get MCP client
|
||||
client, err := mcp.Select(serverConfig.ServerID)
|
||||
if err != nil {
|
||||
ctx.Logger.Warn("Failed to select MCP client '%s': %v", serverConfig.ServerID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Get tools list (filter by serverConfig.Tools if specified)
|
||||
toolsResponse, err := client.ListTools(mcpCtx, "")
|
||||
if err != nil {
|
||||
ctx.Logger.Warn("Failed to list tools for '%s': %v", serverConfig.ServerID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Build tool filter map if specified
|
||||
toolFilter := make(map[string]bool)
|
||||
if len(serverConfig.Tools) > 0 {
|
||||
for _, toolName := range serverConfig.Tools {
|
||||
toolFilter[toolName] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Process each tool
|
||||
for _, tool := range toolsResponse.Tools {
|
||||
// Check tool limit
|
||||
if len(allTools) >= MaxMCPTools {
|
||||
break
|
||||
}
|
||||
|
||||
// Apply tool filter if specified
|
||||
if len(toolFilter) > 0 && !toolFilter[tool.Name] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Format tool name with server prefix
|
||||
formattedName := MCPToolName(serverConfig.ServerID, tool.Name)
|
||||
|
||||
// Convert MCP tool to MCPTool format
|
||||
mcpTool := MCPTool{
|
||||
Name: formattedName,
|
||||
Description: tool.Description,
|
||||
Parameters: tool.InputSchema,
|
||||
}
|
||||
|
||||
allTools = append(allTools, mcpTool)
|
||||
|
||||
// Try to get samples for this tool
|
||||
samples, err := client.ListSamples(mcpCtx, mcpTypes.SampleTool, tool.Name)
|
||||
if err == nil && len(samples.Samples) > 0 {
|
||||
if !hasSamples {
|
||||
samplesBuilder.WriteString("\n\n## MCP Tool Usage Examples\n\n")
|
||||
samplesBuilder.WriteString("The following examples demonstrate how to use MCP tools correctly:\n\n")
|
||||
hasSamples = true
|
||||
}
|
||||
|
||||
samplesBuilder.WriteString(fmt.Sprintf("### %s\n\n", formattedName))
|
||||
if tool.Description != "" {
|
||||
samplesBuilder.WriteString(fmt.Sprintf("**Description**: %s\n\n", tool.Description))
|
||||
}
|
||||
|
||||
for i, sample := range samples.Samples {
|
||||
if i >= 3 { // Limit to 3 examples per tool
|
||||
break
|
||||
}
|
||||
|
||||
samplesBuilder.WriteString(fmt.Sprintf("**Example %d", i+1))
|
||||
if sample.Name != "" {
|
||||
samplesBuilder.WriteString(fmt.Sprintf(" - %s", sample.Name))
|
||||
}
|
||||
samplesBuilder.WriteString("**:\n")
|
||||
|
||||
// Check metadata for description
|
||||
if sample.Metadata != nil {
|
||||
if desc, ok := sample.Metadata["description"].(string); ok && desc != "" {
|
||||
samplesBuilder.WriteString(fmt.Sprintf("- Description: %s\n", desc))
|
||||
}
|
||||
}
|
||||
|
||||
if sample.Input != nil {
|
||||
samplesBuilder.WriteString(fmt.Sprintf("- Input: `%v`\n", sample.Input))
|
||||
}
|
||||
|
||||
if sample.Output != nil {
|
||||
samplesBuilder.WriteString(fmt.Sprintf("- Output: `%v`\n", sample.Output))
|
||||
}
|
||||
|
||||
samplesBuilder.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Logger.Debug("Loaded %d tools from server '%s'", len(toolsResponse.Tools), serverConfig.ServerID)
|
||||
}
|
||||
|
||||
samplesPrompt := ""
|
||||
if hasSamples {
|
||||
samplesPrompt = samplesBuilder.String()
|
||||
}
|
||||
|
||||
ctx.Logger.Debug("Total MCP tools loaded: %d", len(allTools))
|
||||
return allTools, samplesPrompt, nil
|
||||
}
|
||||
|
||||
// ToolCallResult represents the result of a tool call execution
|
||||
// executeToolCalls executes tool calls with intelligent strategy and trace logging:
|
||||
// - Single tool: use CallTool, single trace node
|
||||
// - Multiple tools: use CallToolsParallel with parallel trace nodes, fallback to sequential on certain errors
|
||||
// Returns (results, hasErrors)
|
||||
func (ast *Assistant) executeToolCalls(ctx *agentContext.Context, toolCalls []agentContext.ToolCall, attempt int) ([]ToolCallResult, bool) {
|
||||
if len(toolCalls) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
ctx.Logger.Debug("Executing %d tool calls (attempt %d)", len(toolCalls), attempt)
|
||||
|
||||
// Single tool call
|
||||
if len(toolCalls) == 1 {
|
||||
return ast.executeSingleToolCall(ctx, toolCalls[0])
|
||||
}
|
||||
|
||||
// Multiple tool calls - try parallel first
|
||||
return ast.executeMultipleToolCallsParallel(ctx, toolCalls)
|
||||
}
|
||||
|
||||
// executeSingleToolCall executes a single tool call with trace logging
|
||||
func (ast *Assistant) executeSingleToolCall(ctx *agentContext.Context, toolCall agentContext.ToolCall) ([]ToolCallResult, bool) {
|
||||
ctx.Logger.ToolStart(toolCall.Function.Name)
|
||||
|
||||
trace, _ := ctx.Trace()
|
||||
|
||||
// Use the agent context for cancellation and timeout control
|
||||
mcpCtx := ctx.Context
|
||||
if mcpCtx == nil {
|
||||
mcpCtx = context.Background()
|
||||
}
|
||||
|
||||
result := ToolCallResult{
|
||||
ToolCallID: toolCall.ID,
|
||||
Name: toolCall.Function.Name,
|
||||
}
|
||||
|
||||
// Parse tool name
|
||||
serverID, toolName, ok := ParseMCPToolName(toolCall.Function.Name)
|
||||
if !ok {
|
||||
result.Error = fmt.Errorf("invalid MCP tool name format: %s", toolCall.Function.Name)
|
||||
result.Content = result.Error.Error()
|
||||
ctx.Logger.Error("Invalid MCP tool name format: %s", toolCall.Function.Name)
|
||||
ctx.Logger.ToolComplete(toolCall.Function.Name, false)
|
||||
return []ToolCallResult{result}, true
|
||||
}
|
||||
|
||||
// Get MCP client
|
||||
client, err := mcp.Select(serverID)
|
||||
if err != nil {
|
||||
result.Error = fmt.Errorf("failed to select MCP client '%s': %w", serverID, err)
|
||||
result.Content = result.Error.Error()
|
||||
result.IsRetryableError = false // MCP client selection error is not retryable
|
||||
ctx.Logger.Error("Failed to select MCP client '%s': %v", serverID, err)
|
||||
ctx.Logger.ToolComplete(toolCall.Function.Name, false)
|
||||
return []ToolCallResult{result}, true
|
||||
}
|
||||
|
||||
// Get tool info for description and schema
|
||||
toolsResponse, err := client.ListTools(mcpCtx, "")
|
||||
var toolDescription string
|
||||
var toolSchema interface{}
|
||||
if err == nil {
|
||||
for _, t := range toolsResponse.Tools {
|
||||
if t.Name == toolName {
|
||||
toolDescription = t.Description
|
||||
toolSchema = t.InputSchema
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if toolDescription == "" {
|
||||
toolDescription = fmt.Sprintf("MCP tool '%s'", toolName)
|
||||
}
|
||||
|
||||
// Add trace node for this tool call
|
||||
var toolNode types.Node
|
||||
if trace != nil {
|
||||
toolNode, _ = trace.Add(
|
||||
map[string]any{
|
||||
"tool_call_id": toolCall.ID,
|
||||
"server": serverID,
|
||||
"tool": toolName,
|
||||
"arguments": toolCall.Function.Arguments,
|
||||
},
|
||||
types.TraceNodeOption{
|
||||
Label: toolDescription,
|
||||
Type: "mcp_tool",
|
||||
Icon: "build",
|
||||
Description: fmt.Sprintf("Calling '%s' on server '%s'", toolName, serverID),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Parse arguments with repair support for better tolerance
|
||||
var args map[string]interface{}
|
||||
if toolCall.Function.Arguments != "" {
|
||||
parsed, err := gouJson.Parse(toolCall.Function.Arguments)
|
||||
if err != nil {
|
||||
result.Error = fmt.Errorf("failed to parse arguments: %w", err)
|
||||
result.Content = result.Error.Error()
|
||||
result.IsRetryableError = true // Argument parsing error is retryable by LLM
|
||||
ctx.Logger.Error("Failed to parse arguments: %v", err)
|
||||
ctx.Logger.ToolComplete(toolCall.Function.Name, false)
|
||||
if toolNode != nil {
|
||||
toolNode.Fail(result.Error)
|
||||
}
|
||||
return []ToolCallResult{result}, true
|
||||
}
|
||||
|
||||
// Convert to map
|
||||
if argsMap, ok := parsed.(map[string]interface{}); ok {
|
||||
args = argsMap
|
||||
} else {
|
||||
result.Error = fmt.Errorf("arguments must be an object, got %T", parsed)
|
||||
result.Content = result.Error.Error()
|
||||
result.IsRetryableError = true // Type error is retryable by LLM
|
||||
ctx.Logger.Error("Arguments must be an object, got %T", parsed)
|
||||
ctx.Logger.ToolComplete(toolCall.Function.Name, false)
|
||||
if toolNode != nil {
|
||||
toolNode.Fail(result.Error)
|
||||
}
|
||||
return []ToolCallResult{result}, true
|
||||
}
|
||||
|
||||
// Validate arguments against tool schema if available
|
||||
if toolSchema != nil {
|
||||
if err := gouJson.Validate(args, toolSchema); err != nil {
|
||||
result.Error = fmt.Errorf("argument validation failed: %w", err)
|
||||
result.Content = result.Error.Error()
|
||||
result.IsRetryableError = true // Validation error is retryable by LLM
|
||||
ctx.Logger.Error("Argument validation failed: %v", err)
|
||||
ctx.Logger.ToolComplete(toolCall.Function.Name, false)
|
||||
if toolNode != nil {
|
||||
toolNode.Fail(result.Error)
|
||||
}
|
||||
return []ToolCallResult{result}, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Call the tool with agent context as extra argument
|
||||
ctx.Logger.Debug("Calling tool: %s (server: %s)", toolName, serverID)
|
||||
|
||||
// Pass agent context as extra argument (only used for Process transport)
|
||||
callResult, err := client.CallTool(mcpCtx, toolName, args, ctx)
|
||||
if err != nil {
|
||||
result.Error = fmt.Errorf("tool call failed: %w", err)
|
||||
result.Content = result.Error.Error()
|
||||
// Check if error is retryable (parameter/validation errors)
|
||||
result.IsRetryableError = isRetryableToolError(err)
|
||||
ctx.Logger.Error("Tool call failed: %v (retryable: %v)", err, result.IsRetryableError)
|
||||
ctx.Logger.ToolComplete(toolCall.Function.Name, false)
|
||||
if toolNode != nil {
|
||||
toolNode.Fail(result.Error)
|
||||
}
|
||||
return []ToolCallResult{result}, true
|
||||
}
|
||||
|
||||
// Serialize the Content field only ([]ToolContent)
|
||||
contentBytes, err := jsoniter.Marshal(callResult.Content)
|
||||
if err != nil {
|
||||
result.Error = fmt.Errorf("failed to serialize result: %w", err)
|
||||
result.Content = result.Error.Error()
|
||||
result.IsRetryableError = false
|
||||
ctx.Logger.Error("Failed to serialize result: %v", err)
|
||||
ctx.Logger.ToolComplete(toolCall.Function.Name, false)
|
||||
if toolNode != nil {
|
||||
toolNode.Fail(result.Error)
|
||||
}
|
||||
return []ToolCallResult{result}, true
|
||||
}
|
||||
|
||||
result.Content = string(contentBytes)
|
||||
|
||||
// Check if result is an error — include actual content so LLM can see the details
|
||||
if callResult.IsError {
|
||||
result.Error = fmt.Errorf("tool call error: %s", result.Content)
|
||||
result.IsRetryableError = isRetryableToolError(result.Error)
|
||||
ctx.Logger.Error("Tool call failed: %s - %s (retryable: %v)", toolCall.Function.Name, result.Content, result.IsRetryableError)
|
||||
ctx.Logger.ToolComplete(toolCall.Function.Name, false)
|
||||
if toolNode != nil {
|
||||
toolNode.Fail(result.Error)
|
||||
}
|
||||
return []ToolCallResult{result}, true
|
||||
}
|
||||
|
||||
ctx.Logger.ToolComplete(toolCall.Function.Name, true)
|
||||
|
||||
if toolNode != nil {
|
||||
toolNode.Complete(map[string]any{
|
||||
"result": callResult,
|
||||
})
|
||||
}
|
||||
|
||||
return []ToolCallResult{result}, false
|
||||
}
|
||||
|
||||
// executeMultipleToolCallsParallel executes multiple tool calls in parallel with trace logging
|
||||
func (ast *Assistant) executeMultipleToolCallsParallel(ctx *agentContext.Context, toolCalls []agentContext.ToolCall) ([]ToolCallResult, bool) {
|
||||
trace, _ := ctx.Trace()
|
||||
|
||||
// Use the agent context for cancellation and timeout control
|
||||
mcpCtx := ctx.Context
|
||||
if mcpCtx == nil {
|
||||
mcpCtx = context.Background()
|
||||
}
|
||||
|
||||
// Group tool calls by server
|
||||
serverGroups := make(map[string][]agentContext.ToolCall)
|
||||
for _, tc := range toolCalls {
|
||||
serverID, _, ok := ParseMCPToolName(tc.Function.Name)
|
||||
if !ok {
|
||||
ctx.Logger.Warn("Invalid tool name format: %s", tc.Function.Name)
|
||||
continue
|
||||
}
|
||||
serverGroups[serverID] = append(serverGroups[serverID], tc)
|
||||
}
|
||||
|
||||
results := make([]ToolCallResult, 0, len(toolCalls))
|
||||
hasErrors := false
|
||||
|
||||
// Process each server's tools
|
||||
for serverID, calls := range serverGroups {
|
||||
client, err := mcp.Select(serverID)
|
||||
if err != nil {
|
||||
ctx.Logger.Error("Failed to select MCP client '%s': %v", serverID, err)
|
||||
// Add error results for all calls to this server
|
||||
for _, tc := range calls {
|
||||
results = append(results, ToolCallResult{
|
||||
ToolCallID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Content: fmt.Sprintf("Failed to select MCP client: %v", err),
|
||||
Error: err,
|
||||
})
|
||||
}
|
||||
hasErrors = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Try parallel execution
|
||||
serverResults, serverHasErrors := ast.executeServerToolsParallelWithTrace(
|
||||
mcpCtx, ctx, trace, client, serverID, calls,
|
||||
)
|
||||
|
||||
// If parallel execution failed with retryable error, try sequential
|
||||
if serverHasErrors && ast.shouldRetrySequential(serverResults) {
|
||||
ctx.Logger.Warn("Parallel execution had parameter errors for server '%s', retrying sequentially", serverID)
|
||||
serverResults, serverHasErrors = ast.executeServerToolsSequentialWithTrace(
|
||||
mcpCtx, ctx, trace, client, serverID, calls,
|
||||
)
|
||||
}
|
||||
|
||||
results = append(results, serverResults...)
|
||||
if serverHasErrors {
|
||||
hasErrors = true
|
||||
}
|
||||
}
|
||||
|
||||
return results, hasErrors
|
||||
}
|
||||
|
||||
// isRetryableToolError checks if an error is retryable by LLM (parameter/validation errors)
|
||||
// Returns true for errors that LLM can potentially fix by adjusting parameters
|
||||
// Returns false for MCP internal errors (network, auth, service unavailable, etc.)
|
||||
func isRetryableToolError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
errMsg := strings.ToLower(err.Error())
|
||||
|
||||
// These are NOT retryable (MCP internal issues)
|
||||
nonRetryablePatterns := []string{
|
||||
"network",
|
||||
"timeout",
|
||||
"connection",
|
||||
"unauthorized",
|
||||
"forbidden",
|
||||
"unavailable",
|
||||
"failed to select",
|
||||
"context canceled",
|
||||
"context deadline",
|
||||
"server error",
|
||||
"internal error",
|
||||
}
|
||||
|
||||
for _, pattern := range nonRetryablePatterns {
|
||||
if strings.Contains(errMsg, pattern) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// These ARE retryable (parameter/validation issues LLM can fix)
|
||||
retryablePatterns := []string{
|
||||
"invalid",
|
||||
"required",
|
||||
"missing",
|
||||
"validation",
|
||||
"schema",
|
||||
"type",
|
||||
"format",
|
||||
"parse",
|
||||
"argument",
|
||||
"parameter",
|
||||
}
|
||||
|
||||
for _, pattern := range retryablePatterns {
|
||||
if strings.Contains(errMsg, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Default: assume it's retryable unless proven otherwise
|
||||
// This allows LLM to attempt fixes for unknown error types
|
||||
return true
|
||||
}
|
||||
|
||||
// shouldRetrySequential checks if errors are retryable (parameter issues, not network/service issues)
|
||||
func (ast *Assistant) shouldRetrySequential(results []ToolCallResult) bool {
|
||||
// Check if any result has a retryable error
|
||||
hasRetryable := false
|
||||
for _, result := range results {
|
||||
if result.Error != nil && result.IsRetryableError {
|
||||
hasRetryable = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return hasRetryable
|
||||
}
|
||||
|
||||
// executeServerToolsParallelWithTrace executes tools for a single server in parallel with trace
|
||||
func (ast *Assistant) executeServerToolsParallelWithTrace(mcpCtx context.Context, ctx *agentContext.Context, trace types.Manager, client mcp.Client, serverID string, toolCalls []agentContext.ToolCall) ([]ToolCallResult, bool) {
|
||||
// Prepare parallel trace inputs
|
||||
var parallelInputs []types.TraceParallelInput
|
||||
mcpCalls := make([]mcpTypes.ToolCall, 0, len(toolCalls))
|
||||
orderedCalls := make([]agentContext.ToolCall, 0, len(toolCalls))
|
||||
|
||||
for _, tc := range toolCalls {
|
||||
_, toolName, ok := ParseMCPToolName(tc.Function.Name)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
var args map[string]interface{}
|
||||
if tc.Function.Arguments != "" {
|
||||
if err := jsoniter.UnmarshalFromString(tc.Function.Arguments, &args); err != nil {
|
||||
ctx.Logger.Error("Failed to parse arguments for %s: %v", toolName, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
mcpCalls = append(mcpCalls, mcpTypes.ToolCall{
|
||||
Name: toolName,
|
||||
Arguments: args,
|
||||
})
|
||||
orderedCalls = append(orderedCalls, tc)
|
||||
ctx.Logger.ToolStart(tc.Function.Name)
|
||||
|
||||
// Add trace input for this tool
|
||||
parallelInputs = append(parallelInputs, types.TraceParallelInput{
|
||||
Input: map[string]any{
|
||||
"tool_call_id": tc.ID,
|
||||
"server": serverID,
|
||||
"tool": toolName,
|
||||
"arguments": tc.Function.Arguments,
|
||||
},
|
||||
Option: types.TraceNodeOption{
|
||||
Label: fmt.Sprintf("Tool: %s", toolName),
|
||||
Type: "mcp_tool",
|
||||
Icon: "build",
|
||||
Description: fmt.Sprintf("Calling MCP tool '%s' on server '%s'", toolName, serverID),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Create parallel trace nodes
|
||||
var toolNodes []types.Node
|
||||
if trace != nil && len(parallelInputs) > 0 {
|
||||
var err error
|
||||
toolNodes, err = trace.Parallel(parallelInputs)
|
||||
if err != nil {
|
||||
ctx.Logger.Debug("trace.Parallel() failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Call tools in parallel with agent context as extra argument
|
||||
ctx.Logger.Debug("Calling %d tools in parallel on server '%s'", len(mcpCalls), serverID)
|
||||
|
||||
// Pass agent context as extra argument (only used for Process transport)
|
||||
mcpResponse, err := client.CallToolsParallel(mcpCtx, mcpCalls, ctx)
|
||||
if err != nil {
|
||||
ctx.Logger.Error("Parallel call failed: %v", err)
|
||||
for i, node := range toolNodes {
|
||||
if node != nil {
|
||||
node.Fail(err)
|
||||
}
|
||||
if i < len(orderedCalls) {
|
||||
ctx.Logger.ToolComplete(orderedCalls[i].Function.Name, false)
|
||||
}
|
||||
}
|
||||
return nil, true
|
||||
}
|
||||
|
||||
// Process results
|
||||
results := make([]ToolCallResult, 0, len(mcpResponse.Results))
|
||||
hasErrors := false
|
||||
|
||||
for i, mcpResult := range mcpResponse.Results {
|
||||
toolName := mcpCalls[i].Name
|
||||
originalCall := orderedCalls[i]
|
||||
var toolNode types.Node
|
||||
if i < len(toolNodes) {
|
||||
toolNode = toolNodes[i]
|
||||
}
|
||||
|
||||
result := ToolCallResult{
|
||||
ToolCallID: originalCall.ID,
|
||||
Name: originalCall.Function.Name,
|
||||
}
|
||||
|
||||
// Serialize content
|
||||
contentBytes, err := jsoniter.Marshal(mcpResult.Content)
|
||||
if err != nil {
|
||||
result.Error = fmt.Errorf("failed to serialize result: %w", err)
|
||||
result.Content = result.Error.Error()
|
||||
result.IsRetryableError = false // Serialization error is not retryable
|
||||
hasErrors = true
|
||||
ctx.Logger.ToolComplete(originalCall.Function.Name, false)
|
||||
if toolNode != nil {
|
||||
toolNode.Fail(result.Error)
|
||||
}
|
||||
} else {
|
||||
result.Content = string(contentBytes)
|
||||
|
||||
// Check if it's an error result
|
||||
if mcpResult.IsError {
|
||||
result.Error = fmt.Errorf("tool call error: %s", result.Content)
|
||||
result.IsRetryableError = isRetryableToolError(result.Error)
|
||||
hasErrors = true
|
||||
ctx.Logger.Error("Tool call failed: %s - %s (retryable: %v)", toolName, result.Content, result.IsRetryableError)
|
||||
ctx.Logger.ToolComplete(originalCall.Function.Name, false)
|
||||
if toolNode != nil {
|
||||
toolNode.Fail(result.Error)
|
||||
}
|
||||
} else {
|
||||
ctx.Logger.ToolComplete(originalCall.Function.Name, true)
|
||||
if toolNode != nil {
|
||||
toolNode.Complete(map[string]any{
|
||||
"result": mcpResult.Content,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results, hasErrors
|
||||
}
|
||||
|
||||
// executeServerToolsSequentialWithTrace executes tools for a single server sequentially with trace
|
||||
func (ast *Assistant) executeServerToolsSequentialWithTrace(mcpCtx context.Context, ctx *agentContext.Context, trace types.Manager, client mcp.Client, serverID string, toolCalls []agentContext.ToolCall) ([]ToolCallResult, bool) {
|
||||
results := make([]ToolCallResult, 0, len(toolCalls))
|
||||
hasErrors := false
|
||||
|
||||
ctx.Logger.Debug("Calling %d tools sequentially on server '%s'", len(toolCalls), serverID)
|
||||
|
||||
for _, tc := range toolCalls {
|
||||
ctx.Logger.ToolStart(tc.Function.Name)
|
||||
|
||||
_, toolName, ok := ParseMCPToolName(tc.Function.Name)
|
||||
if !ok {
|
||||
results = append(results, ToolCallResult{
|
||||
ToolCallID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Content: fmt.Sprintf("Invalid tool name format: %s", tc.Function.Name),
|
||||
Error: fmt.Errorf("invalid tool name format"),
|
||||
})
|
||||
ctx.Logger.ToolComplete(tc.Function.Name, false)
|
||||
hasErrors = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Get tool schema for validation
|
||||
toolsResponse, err := client.ListTools(mcpCtx, "")
|
||||
var toolSchema interface{}
|
||||
if err == nil {
|
||||
for _, t := range toolsResponse.Tools {
|
||||
if t.Name == toolName {
|
||||
toolSchema = t.InputSchema
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add trace node for this tool call
|
||||
var toolNode types.Node
|
||||
if trace != nil {
|
||||
toolNode, _ = trace.Add(
|
||||
map[string]any{
|
||||
"tool_call_id": tc.ID,
|
||||
"server": serverID,
|
||||
"tool": toolName,
|
||||
"arguments": tc.Function.Arguments,
|
||||
},
|
||||
types.TraceNodeOption{
|
||||
Label: fmt.Sprintf("Tool: %s (sequential retry)", toolName),
|
||||
Type: "mcp_tool",
|
||||
Icon: "build",
|
||||
Description: fmt.Sprintf("Retrying MCP tool '%s' on server '%s' sequentially", toolName, serverID),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Parse arguments with repair support
|
||||
var args map[string]interface{}
|
||||
if tc.Function.Arguments != "" {
|
||||
parsed, err := gouJson.Parse(tc.Function.Arguments)
|
||||
if err != nil {
|
||||
result := ToolCallResult{
|
||||
ToolCallID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Content: fmt.Sprintf("Failed to parse arguments: %v", err),
|
||||
Error: err,
|
||||
IsRetryableError: true, // Parsing error is retryable
|
||||
}
|
||||
results = append(results, result)
|
||||
hasErrors = true
|
||||
ctx.Logger.ToolComplete(tc.Function.Name, false)
|
||||
if toolNode != nil {
|
||||
toolNode.Fail(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Convert to map
|
||||
if argsMap, ok := parsed.(map[string]interface{}); ok {
|
||||
args = argsMap
|
||||
} else {
|
||||
err := fmt.Errorf("arguments must be an object, got %T", parsed)
|
||||
result := ToolCallResult{
|
||||
ToolCallID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Content: err.Error(),
|
||||
Error: err,
|
||||
IsRetryableError: true, // Type error is retryable
|
||||
}
|
||||
results = append(results, result)
|
||||
hasErrors = true
|
||||
ctx.Logger.ToolComplete(tc.Function.Name, false)
|
||||
if toolNode != nil {
|
||||
toolNode.Fail(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Validate arguments against tool schema if available
|
||||
if toolSchema != nil {
|
||||
if err := gouJson.Validate(args, toolSchema); err != nil {
|
||||
result := ToolCallResult{
|
||||
ToolCallID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Content: fmt.Sprintf("Argument validation failed: %v", err),
|
||||
Error: err,
|
||||
IsRetryableError: true, // Validation error is retryable
|
||||
}
|
||||
results = append(results, result)
|
||||
hasErrors = true
|
||||
ctx.Logger.ToolComplete(tc.Function.Name, false)
|
||||
if toolNode != nil {
|
||||
toolNode.Fail(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Call single tool with agent context as extra argument
|
||||
ctx.Logger.Debug("Calling tool: %s", toolName)
|
||||
mcpResult, err := client.CallTool(mcpCtx, toolName, args, ctx)
|
||||
|
||||
result := ToolCallResult{
|
||||
ToolCallID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
result.Error = err
|
||||
result.Content = fmt.Sprintf("Tool call failed: %v", err)
|
||||
result.IsRetryableError = isRetryableToolError(err)
|
||||
hasErrors = true
|
||||
ctx.Logger.Error("Tool call failed: %s - %v (retryable: %v)", toolName, err, result.IsRetryableError)
|
||||
ctx.Logger.ToolComplete(tc.Function.Name, false)
|
||||
if toolNode != nil {
|
||||
toolNode.Fail(err)
|
||||
}
|
||||
} else {
|
||||
// Serialize the Content field only ([]ToolContent)
|
||||
contentBytes, err := jsoniter.Marshal(mcpResult.Content)
|
||||
if err != nil {
|
||||
result.Error = err
|
||||
result.Content = fmt.Sprintf("Failed to serialize result: %v", err)
|
||||
result.IsRetryableError = false
|
||||
hasErrors = true
|
||||
ctx.Logger.ToolComplete(tc.Function.Name, false)
|
||||
if toolNode != nil {
|
||||
toolNode.Fail(err)
|
||||
}
|
||||
} else {
|
||||
result.Content = string(contentBytes)
|
||||
|
||||
// Check if result is an error — include actual content so LLM can see the details
|
||||
if mcpResult.IsError {
|
||||
result.Error = fmt.Errorf("tool call error: %s", result.Content)
|
||||
result.IsRetryableError = isRetryableToolError(result.Error)
|
||||
hasErrors = true
|
||||
ctx.Logger.Error("Tool call failed: %s - %s (retryable: %v)", toolName, result.Content, result.IsRetryableError)
|
||||
ctx.Logger.ToolComplete(tc.Function.Name, false)
|
||||
if toolNode != nil {
|
||||
toolNode.Fail(result.Error)
|
||||
}
|
||||
} else {
|
||||
ctx.Logger.ToolComplete(tc.Function.Name, true)
|
||||
if toolNode != nil {
|
||||
toolNode.Complete(map[string]any{
|
||||
"result": mcpResult.Content,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results, hasErrors
|
||||
}
|
||||
|
|
@ -1,401 +0,0 @@
|
|||
package assistant_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/gou/mcp"
|
||||
mcpTypes "github.com/yaoapp/gou/mcp/types"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
agentContext "github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
func TestMCPToolName(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
serverID string
|
||||
toolName string
|
||||
wantResult string
|
||||
}{
|
||||
{
|
||||
name: "Simple tool name",
|
||||
serverID: "github",
|
||||
toolName: "search_repos",
|
||||
wantResult: "github__search_repos",
|
||||
},
|
||||
{
|
||||
name: "Server with dots",
|
||||
serverID: "github.enterprise",
|
||||
toolName: "search_repos",
|
||||
wantResult: "github_enterprise__search_repos",
|
||||
},
|
||||
{
|
||||
name: "Tool with underscores",
|
||||
serverID: "customer-db",
|
||||
toolName: "create_customer",
|
||||
wantResult: "customer-db__create_customer",
|
||||
},
|
||||
{
|
||||
name: "Complex server with multiple dots",
|
||||
serverID: "com.example.mcp",
|
||||
toolName: "tool_name",
|
||||
wantResult: "com_example_mcp__tool_name",
|
||||
},
|
||||
{
|
||||
name: "Empty server ID",
|
||||
serverID: "",
|
||||
toolName: "tool",
|
||||
wantResult: "",
|
||||
},
|
||||
{
|
||||
name: "Empty tool name",
|
||||
serverID: "server",
|
||||
toolName: "",
|
||||
wantResult: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := assistant.MCPToolName(tt.serverID, tt.toolName)
|
||||
if result != tt.wantResult {
|
||||
t.Errorf("MCPToolName() = %v, want %v", result, tt.wantResult)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMCPToolName(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
formattedName string
|
||||
wantServerID string
|
||||
wantToolName string
|
||||
wantOK bool
|
||||
}{
|
||||
{
|
||||
name: "Valid simple format",
|
||||
formattedName: "github__search_repos",
|
||||
wantServerID: "github",
|
||||
wantToolName: "search_repos",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "Server with dots restored",
|
||||
formattedName: "github_enterprise__search_repos",
|
||||
wantServerID: "github.enterprise",
|
||||
wantToolName: "search_repos",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "Complex server ID with multiple dots",
|
||||
formattedName: "com_example_mcp_server__tool_name",
|
||||
wantServerID: "com.example.mcp.server",
|
||||
wantToolName: "tool_name",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "Tool name with underscores",
|
||||
formattedName: "server__create_new_user",
|
||||
wantServerID: "server",
|
||||
wantToolName: "create_new_user",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "Server with hyphens",
|
||||
formattedName: "mcp-server__tool",
|
||||
wantServerID: "mcp-server",
|
||||
wantToolName: "tool",
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid format - no double underscore",
|
||||
formattedName: "invalid",
|
||||
wantServerID: "",
|
||||
wantToolName: "",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid format - empty string",
|
||||
formattedName: "",
|
||||
wantServerID: "",
|
||||
wantToolName: "",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid format - only double underscore",
|
||||
formattedName: "__",
|
||||
wantServerID: "",
|
||||
wantToolName: "",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid format - ends with double underscore",
|
||||
formattedName: "server__",
|
||||
wantServerID: "",
|
||||
wantToolName: "",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid format - starts with double underscore",
|
||||
formattedName: "__tool",
|
||||
wantServerID: "",
|
||||
wantToolName: "",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid format - multiple double underscores",
|
||||
formattedName: "server__middle__tool",
|
||||
wantServerID: "",
|
||||
wantToolName: "",
|
||||
wantOK: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
serverID, toolName, ok := assistant.ParseMCPToolName(tt.formattedName)
|
||||
if serverID != tt.wantServerID {
|
||||
t.Errorf("ParseMCPToolName() serverID = %v, want %v", serverID, tt.wantServerID)
|
||||
}
|
||||
if toolName != tt.wantToolName {
|
||||
t.Errorf("ParseMCPToolName() toolName = %v, want %v", toolName, tt.wantToolName)
|
||||
}
|
||||
if ok != tt.wantOK {
|
||||
t.Errorf("ParseMCPToolName() ok = %v, want %v", ok, tt.wantOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPToolName_RoundTrip(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
serverID string
|
||||
toolName string
|
||||
}{
|
||||
{
|
||||
name: "Simple IDs",
|
||||
serverID: "github",
|
||||
toolName: "search_repos",
|
||||
},
|
||||
{
|
||||
name: "Server with dots",
|
||||
serverID: "github.enterprise",
|
||||
toolName: "search",
|
||||
},
|
||||
{
|
||||
name: "Complex server ID",
|
||||
serverID: "com.example.mcp.server",
|
||||
toolName: "tool_name",
|
||||
},
|
||||
{
|
||||
name: "Server with dashes",
|
||||
serverID: "mcp-server-123",
|
||||
toolName: "tool_with_underscores",
|
||||
},
|
||||
{
|
||||
name: "Mixed dots and dashes",
|
||||
serverID: "github.enterprise-prod",
|
||||
toolName: "api_call",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Format
|
||||
formatted := assistant.MCPToolName(tt.serverID, tt.toolName)
|
||||
if formatted == "" {
|
||||
t.Fatal("MCPToolName() returned empty string")
|
||||
}
|
||||
|
||||
// Parse
|
||||
serverID, toolName, ok := assistant.ParseMCPToolName(formatted)
|
||||
|
||||
// Verify round-trip
|
||||
if !ok {
|
||||
t.Fatal("ParseMCPToolName() failed")
|
||||
}
|
||||
if serverID != tt.serverID {
|
||||
t.Errorf("Round-trip failed: serverID = %v, want %v", serverID, tt.serverID)
|
||||
}
|
||||
if toolName != tt.toolName {
|
||||
t.Errorf("Round-trip failed: toolName = %v, want %v", toolName, tt.toolName)
|
||||
}
|
||||
|
||||
t.Logf("✓ Round-trip successful: (%s, %s) → %s → (%s, %s)",
|
||||
tt.serverID, tt.toolName, formatted, serverID, toolName)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMCPToolContextPassing tests that agent context is correctly passed to MCP tools
|
||||
func TestMCPToolContextPassing(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
// Get the echo MCP client
|
||||
client, err := mcp.Select("echo")
|
||||
assert.NoError(t, err, "Failed to select echo MCP client")
|
||||
assert.NotNil(t, client, "MCP client should not be nil")
|
||||
|
||||
// Create a test agent context
|
||||
authorized := &types.AuthorizedInfo{
|
||||
UserID: "test-user-123",
|
||||
TenantID: "test-tenant-456",
|
||||
}
|
||||
ctx := agentContext.New(context.Background(), authorized, "test-chat-789")
|
||||
ctx.AssistantID = "test-assistant-mcptest"
|
||||
ctx.Locale = "en"
|
||||
ctx.Theme = "dark"
|
||||
|
||||
// Call the echo tool with context
|
||||
args := map[string]interface{}{
|
||||
"message": "test message from context test",
|
||||
}
|
||||
|
||||
// Call the tool - the agent context will be passed as extra parameter
|
||||
result, err := client.CallTool(ctx.Context, "echo", args, ctx)
|
||||
assert.NoError(t, err, "CallTool should not return error")
|
||||
assert.NotNil(t, result, "Result should not be nil")
|
||||
assert.False(t, result.IsError, "Result should not be an error")
|
||||
assert.Greater(t, len(result.Content), 0, "Result should have content")
|
||||
|
||||
// Parse the result content
|
||||
var echoResult map[string]interface{}
|
||||
err = jsoniter.Unmarshal([]byte(result.Content[0].Text), &echoResult)
|
||||
assert.NoError(t, err, "Failed to parse result content")
|
||||
|
||||
t.Logf("Echo result: %+v", echoResult)
|
||||
|
||||
// Verify the context was received
|
||||
contextData, ok := echoResult["context"].(map[string]interface{})
|
||||
assert.True(t, ok, "Result should contain context field")
|
||||
assert.NotNil(t, contextData, "Context data should not be nil")
|
||||
|
||||
// Verify context has_context flag
|
||||
hasContext, ok := contextData["has_context"].(bool)
|
||||
assert.True(t, ok, "Context should have has_context field")
|
||||
assert.True(t, hasContext, "Context should indicate it has context")
|
||||
|
||||
// Verify chat_id and assistant_id have values (main verification)
|
||||
chatID, ok := contextData["chat_id"].(string)
|
||||
assert.True(t, ok, "Context should have chat_id field")
|
||||
assert.NotEmpty(t, chatID, "chat_id should have a value")
|
||||
assert.Equal(t, "test-chat-789", chatID, "chat_id should match")
|
||||
|
||||
assistantID, ok := contextData["assistant_id"].(string)
|
||||
assert.True(t, ok, "Context should have assistant_id field")
|
||||
assert.NotEmpty(t, assistantID, "assistant_id should have a value")
|
||||
assert.Equal(t, "test-assistant-mcptest", assistantID, "assistant_id should match")
|
||||
|
||||
// Verify authorized information
|
||||
authorizedData, ok := contextData["authorized"].(map[string]interface{})
|
||||
assert.True(t, ok, "Context should have authorized field")
|
||||
assert.NotNil(t, authorizedData, "Authorized data should not be nil")
|
||||
|
||||
userID, ok := authorizedData["user_id"].(string)
|
||||
assert.True(t, ok, "Authorized should have user_id field")
|
||||
assert.Equal(t, "test-user-123", userID, "User ID should match")
|
||||
|
||||
tenantID, ok := authorizedData["tenant_id"].(string)
|
||||
assert.True(t, ok, "Authorized should have tenant_id field")
|
||||
assert.Equal(t, "test-tenant-456", tenantID, "Tenant ID should match")
|
||||
|
||||
t.Logf("✓ Context successfully passed to MCP tool")
|
||||
t.Logf(" - ChatID: %s", chatID)
|
||||
t.Logf(" - AssistantID: %s", assistantID)
|
||||
t.Logf(" - UserID: %s", userID)
|
||||
t.Logf(" - TenantID: %s", tenantID)
|
||||
}
|
||||
|
||||
// TestMCPToolContextPassingParallel tests that agent context is correctly passed in parallel calls
|
||||
func TestMCPToolContextPassingParallel(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
// Get the echo MCP client
|
||||
client, err := mcp.Select("echo")
|
||||
assert.NoError(t, err, "Failed to select echo MCP client")
|
||||
assert.NotNil(t, client, "MCP client should not be nil")
|
||||
|
||||
// Create a test agent context
|
||||
authorized := &types.AuthorizedInfo{
|
||||
UserID: "parallel-user-123",
|
||||
TenantID: "parallel-tenant-456",
|
||||
}
|
||||
ctx := agentContext.New(context.Background(), authorized, "parallel-chat-789")
|
||||
ctx.AssistantID = "test-assistant-parallel"
|
||||
ctx.Locale = "zh-CN"
|
||||
|
||||
// Call multiple echo tools in parallel
|
||||
toolCalls := []mcpTypes.ToolCall{
|
||||
{
|
||||
Name: "echo",
|
||||
Arguments: map[string]interface{}{
|
||||
"message": "parallel message 1",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "echo",
|
||||
Arguments: map[string]interface{}{
|
||||
"message": "parallel message 2",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Call tools in parallel - the agent context will be passed as extra parameter
|
||||
results, err := client.CallToolsParallel(ctx.Context, toolCalls, ctx)
|
||||
assert.NoError(t, err, "CallToolsParallel should not return error")
|
||||
assert.NotNil(t, results, "Results should not be nil")
|
||||
assert.Equal(t, 2, len(results.Results), "Should have 2 results")
|
||||
|
||||
// Verify both results received the context
|
||||
for i, result := range results.Results {
|
||||
assert.False(t, result.IsError, "Result %d should not be an error", i)
|
||||
assert.Greater(t, len(result.Content), 0, "Result %d should have content", i)
|
||||
|
||||
// Parse the result content
|
||||
var echoResult map[string]interface{}
|
||||
err = jsoniter.Unmarshal([]byte(result.Content[0].Text), &echoResult)
|
||||
assert.NoError(t, err, "Failed to parse result %d content", i)
|
||||
|
||||
// Verify the context was received
|
||||
contextData, ok := echoResult["context"].(map[string]interface{})
|
||||
assert.True(t, ok, "Result %d should contain context field", i)
|
||||
assert.NotNil(t, contextData, "Context data %d should not be nil", i)
|
||||
|
||||
hasContext, ok := contextData["has_context"].(bool)
|
||||
assert.True(t, ok, "Context %d should have has_context field", i)
|
||||
assert.True(t, hasContext, "Context %d should indicate it has context", i)
|
||||
|
||||
// Verify chat_id in parallel call
|
||||
chatID, ok := contextData["chat_id"].(string)
|
||||
assert.True(t, ok, "Context %d should have chat_id field", i)
|
||||
assert.Equal(t, "parallel-chat-789", chatID, "Chat ID in result %d should match", i)
|
||||
|
||||
// Verify authorized information in parallel call
|
||||
authorizedData, ok := contextData["authorized"].(map[string]interface{})
|
||||
assert.True(t, ok, "Context %d should have authorized field", i)
|
||||
if userID, ok := authorizedData["user_id"].(string); ok {
|
||||
assert.Equal(t, "parallel-user-123", userID, "User ID in result %d should match", i)
|
||||
}
|
||||
|
||||
t.Logf("✓ Result %d successfully received context", i)
|
||||
}
|
||||
|
||||
t.Log("✓ Context successfully passed to all parallel MCP tool calls")
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
package assistant
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
agentContext "github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/output/message"
|
||||
)
|
||||
|
||||
// processNextResponse processes the Next hook's response and handles agent delegation or custom data
|
||||
func (ast *Assistant) processNextResponse(npc *NextProcessContext) (*agentContext.Response, error) {
|
||||
// If no Next hook response, return standard response
|
||||
if npc.NextResponse == nil {
|
||||
return ast.buildStandardResponse(npc), nil
|
||||
}
|
||||
|
||||
// Handle Delegate: call another agent
|
||||
// Note: User input is already buffered by root agent, delegated agent will skip buffering
|
||||
if npc.NextResponse.Delegate != nil {
|
||||
return ast.handleDelegation(npc.Context, npc.NextResponse.Delegate, npc.StreamHandler)
|
||||
}
|
||||
|
||||
// Handle custom Data: return as-is wrapped in standard Response
|
||||
if npc.NextResponse.Data != nil {
|
||||
return &agentContext.Response{
|
||||
ContextID: npc.Context.ID,
|
||||
RequestID: npc.Context.RequestID(),
|
||||
TraceID: npc.Context.TraceID(),
|
||||
ChatID: npc.Context.ChatID,
|
||||
AssistantID: ast.ID,
|
||||
Create: npc.CreateResponse,
|
||||
Next: npc.NextResponse.Data, // Put custom data in Next field
|
||||
Completion: npc.CompletionResponse,
|
||||
Tools: npc.ToolCallResponses,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// No delegate or data, return standard response
|
||||
return ast.buildStandardResponse(npc), nil
|
||||
}
|
||||
|
||||
// handleDelegation handles calling another agent based on DelegateConfig
|
||||
func (ast *Assistant) handleDelegation(
|
||||
ctx *agentContext.Context,
|
||||
delegate *agentContext.DelegateConfig,
|
||||
streamHandler func(message.StreamChunkType, []byte) int,
|
||||
) (*agentContext.Response, error) {
|
||||
// Load the target assistant
|
||||
targetAssistant, err := Get(delegate.AgentID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load delegated assistant '%s': %w", delegate.AgentID, err)
|
||||
}
|
||||
|
||||
// Mark this as an agent-to-agent call for proper source tracking
|
||||
ctx.Referer = agentContext.RefererAgent
|
||||
|
||||
// Call the delegated assistant with the same context
|
||||
// The delegated assistant's Stream method will:
|
||||
// 1. Call EnterStack() to push itself onto the Stack (creating parent-child relationship)
|
||||
// 2. Execute with the same Context (preserving ID, Space, Writer, etc.)
|
||||
// 3. Call done() to pop from Stack when finished
|
||||
// This ensures proper Stack tracing: parent assistant -> delegated assistant
|
||||
|
||||
// Convert options map from delegate config to Options struct
|
||||
delegateOpts := agentContext.OptionsFromMap(delegate.Options)
|
||||
return targetAssistant.Stream(ctx, delegate.Messages, delegateOpts)
|
||||
}
|
||||
|
||||
// buildStandardResponse builds the standard agent response when no custom Next hook processing is needed
|
||||
func (ast *Assistant) buildStandardResponse(npc *NextProcessContext) *agentContext.Response {
|
||||
|
||||
var next interface{} = nil
|
||||
if npc.NextResponse != nil {
|
||||
next = npc.NextResponse
|
||||
}
|
||||
|
||||
return &agentContext.Response{
|
||||
ContextID: npc.Context.ID,
|
||||
RequestID: npc.Context.RequestID(),
|
||||
TraceID: npc.Context.TraceID(),
|
||||
ChatID: npc.Context.ChatID,
|
||||
AssistantID: ast.ID,
|
||||
Create: npc.CreateResponse,
|
||||
Next: next,
|
||||
Completion: npc.CompletionResponse,
|
||||
Tools: npc.ToolCallResponses,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
package assistant
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
)
|
||||
|
||||
func (ast *Assistant) checkPermissions(ctx *context.Context) error {
|
||||
if ctx.Authorized == nil {
|
||||
return fmt.Errorf("authorized information not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,467 +0,0 @@
|
|||
package assistant
|
||||
|
||||
import (
|
||||
stdContext "context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/gou/connector"
|
||||
goullm "github.com/yaoapp/gou/llm"
|
||||
gouMCP "github.com/yaoapp/gou/mcp"
|
||||
mcpProcess "github.com/yaoapp/gou/mcp/process"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/i18n"
|
||||
"github.com/yaoapp/yao/agent/output/message"
|
||||
agentsandbox "github.com/yaoapp/yao/agent/sandbox"
|
||||
"github.com/yaoapp/yao/config"
|
||||
infraSandbox "github.com/yaoapp/yao/sandbox"
|
||||
"github.com/yaoapp/yao/sandbox/ipc"
|
||||
traceTypes "github.com/yaoapp/yao/trace/types"
|
||||
)
|
||||
|
||||
var (
|
||||
sandboxManager *infraSandbox.Manager
|
||||
sandboxManagerOnce sync.Once
|
||||
sandboxManagerErr error
|
||||
)
|
||||
|
||||
// GetSandboxManager returns the sandbox manager singleton
|
||||
// Returns nil and error if sandbox is not configured or Docker is unavailable
|
||||
func GetSandboxManager() (*infraSandbox.Manager, error) {
|
||||
sandboxManagerOnce.Do(func() {
|
||||
// Create sandbox config from Yao config
|
||||
cfg := &infraSandbox.Config{}
|
||||
|
||||
// Use YAO_DATA_ROOT for workspace and IPC paths
|
||||
dataRoot := config.Conf.DataRoot
|
||||
if dataRoot != "" {
|
||||
cfg.Init(dataRoot)
|
||||
}
|
||||
|
||||
// Create manager (will fail if Docker is not available)
|
||||
sandboxManager, sandboxManagerErr = infraSandbox.NewManager(cfg)
|
||||
})
|
||||
|
||||
return sandboxManager, sandboxManagerErr
|
||||
}
|
||||
|
||||
// HasSandbox returns true if the assistant has sandbox configuration
|
||||
func (ast *Assistant) HasSandbox() bool {
|
||||
return ast.Sandbox != nil && ast.Sandbox.Command != ""
|
||||
}
|
||||
|
||||
// initSandbox initializes the sandbox executor
|
||||
// Returns the full Executor (for LLM calls), cleanup function, and any error
|
||||
// This is called BEFORE hooks so that hooks can access ctx.sandbox
|
||||
// The executor implements both agentsandbox.Executor and context.SandboxExecutor interfaces
|
||||
func (ast *Assistant) initSandbox(ctx *context.Context, opts *context.Options) (agentsandbox.Executor, func(), string, error) {
|
||||
// Get sandbox manager (singleton)
|
||||
manager, err := GetSandboxManager()
|
||||
if err != nil {
|
||||
ctx.Logger.Error("Sandbox manager initialization failed: %v", err)
|
||||
return nil, nil, "", fmt.Errorf("sandbox manager not available: %w", err)
|
||||
}
|
||||
if manager == nil {
|
||||
return nil, nil, "", fmt.Errorf("sandbox manager not initialized")
|
||||
}
|
||||
|
||||
// Build executor options from assistant config
|
||||
execOpts, err := ast.buildSandboxOptions(ctx, opts)
|
||||
if err != nil {
|
||||
ctx.Logger.Error("Failed to build sandbox options: %v", err)
|
||||
return nil, nil, "", fmt.Errorf("failed to build sandbox options: %w", err)
|
||||
}
|
||||
|
||||
// Log sandbox creation
|
||||
ctx.Logger.Info("Creating sandbox container for command: %s", ast.Sandbox.Command)
|
||||
|
||||
// Add trace for sandbox creation
|
||||
trace, traceErr := ctx.Trace()
|
||||
if traceErr == nil && trace != nil {
|
||||
trace.Info("Creating sandbox container...")
|
||||
}
|
||||
|
||||
// Send loading message to user
|
||||
loadingMsg := &message.Message{
|
||||
Type: message.TypeLoading,
|
||||
Props: map[string]interface{}{
|
||||
"message": i18n.T(ctx.Locale, "sandbox.preparing"),
|
||||
},
|
||||
}
|
||||
loadingMsgID, _ := ctx.SendStream(loadingMsg)
|
||||
|
||||
// Create executor (container starts here)
|
||||
executor, err := agentsandbox.New(manager, execOpts)
|
||||
if err != nil {
|
||||
ctx.Logger.Error("Sandbox creation failed: %v", err)
|
||||
if traceErr == nil && trace != nil {
|
||||
trace.Error("Sandbox creation failed: %v", err)
|
||||
}
|
||||
// End loading message with done:true
|
||||
if loadingMsgID != "" {
|
||||
doneMsg := &message.Message{
|
||||
MessageID: loadingMsgID,
|
||||
Delta: true,
|
||||
DeltaAction: message.DeltaReplace,
|
||||
Type: message.TypeLoading,
|
||||
Props: map[string]interface{}{
|
||||
"message": i18n.T(ctx.Locale, "sandbox.failed"),
|
||||
"done": true,
|
||||
},
|
||||
}
|
||||
ctx.Send(doneMsg)
|
||||
}
|
||||
return nil, nil, "", fmt.Errorf("failed to create sandbox executor: %w", err)
|
||||
}
|
||||
|
||||
// Log sandbox ready
|
||||
ctx.Logger.Info("Sandbox container ready")
|
||||
if traceErr == nil && trace != nil {
|
||||
trace.Info("Sandbox container ready")
|
||||
}
|
||||
|
||||
// Return cleanup function
|
||||
cleanup := func() {
|
||||
if err := executor.Close(); err != nil {
|
||||
ctx.Logger.Error("Failed to close sandbox executor: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Keep loadingMsgID open - it will be closed when first output is received
|
||||
// This provides better UX: user sees "Preparing..." until actual content appears
|
||||
return executor, cleanup, loadingMsgID, nil
|
||||
}
|
||||
|
||||
// executeSandboxStream executes the request using sandbox (Claude CLI, etc.)
|
||||
// This is called when ast.Sandbox is configured
|
||||
// NOTE: The executor is passed directly from initSandbox, no type assertion needed
|
||||
func (ast *Assistant) executeSandboxStream(
|
||||
ctx *context.Context,
|
||||
completionMessages []context.Message,
|
||||
agentNode traceTypes.Node,
|
||||
streamHandler message.StreamFunc,
|
||||
executor agentsandbox.Executor,
|
||||
loadingMsgID string,
|
||||
) (*context.CompletionResponse, error) {
|
||||
|
||||
// Mark the agentNode as used to avoid unused variable error
|
||||
_ = agentNode
|
||||
|
||||
if executor == nil {
|
||||
return nil, fmt.Errorf("sandbox executor not initialized (call initSandbox first)")
|
||||
}
|
||||
|
||||
// Log sandbox execution
|
||||
ctx.Logger.Info("Executing via sandbox (command: %s)", ast.Sandbox.Command)
|
||||
|
||||
// Pass the "preparing sandbox" loading message ID to executor
|
||||
// It will be closed when first output (text or tool) is received
|
||||
if loadingMsgID != "" {
|
||||
executor.SetLoadingMsgID(loadingMsgID)
|
||||
}
|
||||
|
||||
// Execute LLM call via sandbox
|
||||
// The loadingMsgID will be closed when first output is received
|
||||
// Tool calls will create their own loading messages below the text
|
||||
resp, err := executor.Stream(ctx, completionMessages, streamHandler)
|
||||
|
||||
if err != nil {
|
||||
// Close loading message on error
|
||||
if loadingMsgID != "" {
|
||||
doneMsg := &message.Message{
|
||||
MessageID: loadingMsgID,
|
||||
Delta: true,
|
||||
DeltaAction: message.DeltaReplace,
|
||||
Type: message.TypeLoading,
|
||||
Props: map[string]interface{}{
|
||||
"message": i18n.T(ctx.Locale, "sandbox.failed"),
|
||||
"done": true,
|
||||
},
|
||||
}
|
||||
ctx.Send(doneMsg)
|
||||
}
|
||||
|
||||
// Send error message to client
|
||||
errMsg := &message.Message{
|
||||
Type: message.TypeError,
|
||||
Props: map[string]interface{}{
|
||||
"message": err.Error(),
|
||||
},
|
||||
}
|
||||
ctx.Send(errMsg)
|
||||
return nil, fmt.Errorf("sandbox execution failed: %w", err)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// buildSandboxOptions builds executor options from assistant config
|
||||
func (ast *Assistant) buildSandboxOptions(ctx *context.Context, opts *context.Options) (*agentsandbox.Options, error) {
|
||||
if ast.Sandbox == nil {
|
||||
return nil, fmt.Errorf("sandbox configuration is required")
|
||||
}
|
||||
|
||||
execOpts := &agentsandbox.Options{
|
||||
Command: ast.Sandbox.Command,
|
||||
Image: ast.Sandbox.Image,
|
||||
MaxMemory: ast.Sandbox.MaxMemory,
|
||||
MaxCPU: ast.Sandbox.MaxCPU,
|
||||
Arguments: ast.Sandbox.Arguments,
|
||||
}
|
||||
|
||||
// Parse timeout string (e.g., "10m") to duration
|
||||
if ast.Sandbox.Timeout != "" {
|
||||
timeout, err := time.ParseDuration(ast.Sandbox.Timeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid timeout format: %w", err)
|
||||
}
|
||||
execOpts.Timeout = timeout
|
||||
}
|
||||
|
||||
// Set user and chat IDs for workspace isolation
|
||||
if ctx.Authorized != nil && ctx.Authorized.UserID != "" {
|
||||
execOpts.UserID = ctx.Authorized.UserID
|
||||
} else {
|
||||
execOpts.UserID = "anonymous"
|
||||
}
|
||||
execOpts.ChatID = ctx.ChatID
|
||||
|
||||
// Set skills directory (auto-resolved from assistant path)
|
||||
// Only set if the directory actually exists
|
||||
if ast.Path != "" {
|
||||
appRoot := config.Conf.AppSource
|
||||
skillsDir := filepath.Join(appRoot, ast.Path, "skills")
|
||||
if info, err := os.Stat(skillsDir); err == nil && info.IsDir() {
|
||||
execOpts.SkillsDir = skillsDir
|
||||
ctx.Logger.Debug("Skills directory found: %s", skillsDir)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if assistant has prompts (from prompts.yml)
|
||||
// If prompts are configured, we need to call Claude CLI
|
||||
if len(ast.Prompts) > 0 {
|
||||
// Extract system prompt from prompts
|
||||
for _, prompt := range ast.Prompts {
|
||||
if prompt.Role == "system" && prompt.Content != "" {
|
||||
execOpts.SystemPrompt = prompt.Content
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve connector settings
|
||||
conn, _, err := ast.GetConnector(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get connector: %w", err)
|
||||
}
|
||||
|
||||
// Determine connector type for sandbox proxy behavior
|
||||
// Anthropic connectors bypass the proxy (Claude CLI connects directly)
|
||||
if conn.Is(connector.ANTHROPIC) {
|
||||
execOpts.ConnectorType = "anthropic"
|
||||
} else {
|
||||
execOpts.ConnectorType = "openai"
|
||||
}
|
||||
|
||||
// Extract standard fields via LLMConnector when available, fallback to Setting()
|
||||
setting := conn.Setting()
|
||||
if lc, ok := conn.(goullm.LLMConnector); ok {
|
||||
execOpts.ConnectorHost = lc.GetURL()
|
||||
execOpts.ConnectorKey = lc.GetKey()
|
||||
execOpts.Model = lc.GetModel()
|
||||
} else {
|
||||
if host, ok := setting["host"].(string); ok {
|
||||
execOpts.ConnectorHost = host
|
||||
}
|
||||
if key, ok := setting["key"].(string); ok {
|
||||
execOpts.ConnectorKey = key
|
||||
}
|
||||
if model, ok := setting["model"].(string); ok {
|
||||
execOpts.Model = model
|
||||
}
|
||||
}
|
||||
|
||||
// Whitelist-filter remaining settings for sandbox proxy options
|
||||
connectorOptions := connector.FilterRequestBodyParams(setting, conn)
|
||||
if len(connectorOptions) > 0 {
|
||||
execOpts.ConnectorOptions = connectorOptions
|
||||
ctx.Logger.Debug("Connector options extracted: %v", connectorOptions)
|
||||
}
|
||||
|
||||
// Extract secrets from sandbox config (e.g., GITHUB_TOKEN: "$ENV.GITHUB_TOKEN")
|
||||
if ast.Sandbox != nil && len(ast.Sandbox.Secrets) > 0 {
|
||||
secrets := make(map[string]string)
|
||||
for k, v := range ast.Sandbox.Secrets {
|
||||
// Resolve $ENV.XXX references
|
||||
resolved := resolveEnvValue(v)
|
||||
if resolved != "" {
|
||||
secrets[k] = resolved
|
||||
}
|
||||
}
|
||||
if len(secrets) > 0 {
|
||||
execOpts.Secrets = secrets
|
||||
ctx.Logger.Debug("Secrets extracted: %d items", len(secrets))
|
||||
}
|
||||
}
|
||||
|
||||
// Build MCP config and load tools if the assistant has MCP servers configured
|
||||
if ast.MCP != nil && len(ast.MCP.Servers) > 0 {
|
||||
// Build MCP config for Claude CLI
|
||||
mcpConfig, err := ast.BuildMCPConfigForSandbox(ctx)
|
||||
if err != nil {
|
||||
ctx.Logger.Warn("Failed to build MCP config for sandbox: %v", err)
|
||||
// Non-fatal: sandbox can work without MCP
|
||||
} else {
|
||||
execOpts.MCPConfig = mcpConfig
|
||||
ctx.Logger.Debug("MCP config built for sandbox (%d bytes)", len(mcpConfig))
|
||||
}
|
||||
|
||||
// Load MCP tools for IPC session
|
||||
mcpTools, err := ast.loadMCPToolsForIPC(ctx)
|
||||
if err != nil {
|
||||
ctx.Logger.Warn("Failed to load MCP tools for IPC: %v", err)
|
||||
// Non-fatal: IPC will have no tools
|
||||
} else if len(mcpTools) > 0 {
|
||||
execOpts.MCPTools = mcpTools
|
||||
ctx.Logger.Debug("Loaded %d MCP tools for IPC", len(mcpTools))
|
||||
}
|
||||
}
|
||||
|
||||
return execOpts, nil
|
||||
}
|
||||
|
||||
// loadMCPToolsForIPC loads MCP tools from configured servers and converts them to IPC format
|
||||
func (ast *Assistant) loadMCPToolsForIPC(ctx *context.Context) (map[string]*ipc.MCPTool, error) {
|
||||
if ast.MCP == nil || len(ast.MCP.Servers) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
tools := make(map[string]*ipc.MCPTool)
|
||||
stdCtx := ctx.Context
|
||||
if stdCtx == nil {
|
||||
stdCtx = stdContext.Background()
|
||||
}
|
||||
|
||||
for _, serverConfig := range ast.MCP.Servers {
|
||||
if serverConfig.ServerID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get MCP client
|
||||
client, err := gouMCP.Select(serverConfig.ServerID)
|
||||
if err != nil {
|
||||
ctx.Logger.Warn("MCP server '%s' not found: %v", serverConfig.ServerID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// List tools from the MCP client
|
||||
toolsResp, err := client.ListTools(stdCtx, "")
|
||||
if err != nil {
|
||||
ctx.Logger.Warn("Failed to list tools from MCP server '%s': %v", serverConfig.ServerID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Get tool mapping for process names
|
||||
mapping, ok := mcpProcess.GetMapping(serverConfig.ServerID)
|
||||
if !ok {
|
||||
ctx.Logger.Warn("No mapping found for MCP server '%s'", serverConfig.ServerID)
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter tools if specified in config
|
||||
toolFilter := make(map[string]bool)
|
||||
if len(serverConfig.Tools) > 0 {
|
||||
for _, t := range serverConfig.Tools {
|
||||
toolFilter[t] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Convert tools to IPC format
|
||||
// Tool names are prefixed with server ID to avoid conflicts
|
||||
// e.g., "echo" server's "ping" tool becomes "echo__ping"
|
||||
for _, tool := range toolsResp.Tools {
|
||||
// Apply tool filter if specified
|
||||
if len(toolFilter) > 0 && !toolFilter[tool.Name] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Find the process name from mapping
|
||||
processName := ""
|
||||
if toolSchema, ok := mapping.Tools[tool.Name]; ok {
|
||||
processName = toolSchema.Process
|
||||
}
|
||||
if processName == "" {
|
||||
ctx.Logger.Warn("No process mapping for tool '%s' in server '%s'", tool.Name, serverConfig.ServerID)
|
||||
continue
|
||||
}
|
||||
|
||||
// Prefixed tool name: serverID__toolName
|
||||
// This matches Claude's MCP naming: mcp__yao__serverID__toolName
|
||||
prefixedName := serverConfig.ServerID + "__" + tool.Name
|
||||
|
||||
// Create IPC tool entry with prefixed name
|
||||
ipcTool := &ipc.MCPTool{
|
||||
Name: prefixedName,
|
||||
Description: tool.Description,
|
||||
Process: processName,
|
||||
InputSchema: tool.InputSchema,
|
||||
}
|
||||
|
||||
tools[prefixedName] = ipcTool
|
||||
}
|
||||
}
|
||||
|
||||
return tools, nil
|
||||
}
|
||||
|
||||
// BuildMCPConfigForSandbox builds the MCP configuration JSON for sandbox
|
||||
// This creates a .mcp.json format that Claude CLI can understand
|
||||
// Exported for testing
|
||||
func (ast *Assistant) BuildMCPConfigForSandbox(ctx *context.Context) ([]byte, error) {
|
||||
if ast.MCP == nil || len(ast.MCP.Servers) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Build MCP config in Claude CLI format
|
||||
// Claude CLI expects: { "mcpServers": { "server_id": { "command": "...", "args": [...] } } }
|
||||
//
|
||||
// For Yao's MCP servers, we use yao-bridge to connect to the IPC socket.
|
||||
// yao-bridge bridges stdio to Unix socket, allowing Claude CLI to communicate
|
||||
// with Yao's IPC server running on the host.
|
||||
//
|
||||
// Architecture:
|
||||
// Claude CLI → yao-bridge → Unix Socket → IPC Session → Yao Process
|
||||
config := map[string]interface{}{
|
||||
"mcpServers": map[string]interface{}{
|
||||
// Single "yao" server that handles all MCP tools via IPC
|
||||
"yao": map[string]interface{}{
|
||||
"command": "yao-bridge",
|
||||
"args": []string{"/tmp/yao.sock"}, // ContainerIPCSocket from sandbox config
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return json.Marshal(config)
|
||||
}
|
||||
|
||||
// resolveEnvValue resolves environment variable references in a string
|
||||
// Supports format: $ENV.VAR_NAME or plain value
|
||||
// Returns empty string if the variable is not set
|
||||
func resolveEnvValue(value string) string {
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Check for $ENV.XXX format
|
||||
if len(value) > 5 && value[:5] == "$ENV." {
|
||||
envName := value[5:]
|
||||
return os.Getenv(envName)
|
||||
}
|
||||
|
||||
// Return as-is if not an env reference
|
||||
return value
|
||||
}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
package assistant_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
)
|
||||
|
||||
// TestSandboxDebugHasSandbox tests the HasSandbox method directly
|
||||
func TestSandboxDebugHasSandbox(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
assistantID string
|
||||
expectTrue bool
|
||||
}{
|
||||
{"BasicSandbox", "tests.sandbox.basic", true},
|
||||
{"HooksSandbox", "tests.sandbox.hooks", true},
|
||||
{"FullSandbox", "tests.sandbox.full", true},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ast, err := assistant.Get(tc.assistantID)
|
||||
require.NoError(t, err, "Failed to get assistant %s", tc.assistantID)
|
||||
|
||||
// Check Sandbox struct
|
||||
t.Logf("Assistant ID: %s", ast.ID)
|
||||
t.Logf("Sandbox: %+v", ast.Sandbox)
|
||||
|
||||
if ast.Sandbox != nil {
|
||||
t.Logf("Sandbox.Command: %q", ast.Sandbox.Command)
|
||||
t.Logf("Sandbox.Timeout: %s", ast.Sandbox.Timeout)
|
||||
t.Logf("Sandbox.Image: %s", ast.Sandbox.Image)
|
||||
t.Logf("Sandbox.Arguments: %v", ast.Sandbox.Arguments)
|
||||
}
|
||||
|
||||
// Check HasSandbox
|
||||
hasSandbox := ast.HasSandbox()
|
||||
t.Logf("HasSandbox() = %v", hasSandbox)
|
||||
|
||||
if tc.expectTrue {
|
||||
assert.True(t, hasSandbox, "Expected HasSandbox() to be true for %s", tc.assistantID)
|
||||
} else {
|
||||
assert.False(t, hasSandbox, "Expected HasSandbox() to be false for %s", tc.assistantID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSandboxDebugPrompts tests if Prompts is set (affects execution path)
|
||||
func TestSandboxDebugPrompts(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ast, err := assistant.Get("tests.sandbox.basic")
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Logf("Assistant ID: %s", ast.ID)
|
||||
t.Logf("Prompts: %v", ast.Prompts)
|
||||
t.Logf("MCP: %v", ast.MCP)
|
||||
t.Logf("HasSandbox: %v", ast.HasSandbox())
|
||||
|
||||
// The condition in agent.go is:
|
||||
// if ast.Prompts != nil || ast.MCP != nil {
|
||||
// // ... execute LLM
|
||||
// if ast.HasSandbox() {
|
||||
// // sandbox path
|
||||
// } else {
|
||||
// // direct LLM path
|
||||
// }
|
||||
// }
|
||||
// So we need Prompts or MCP to be non-nil
|
||||
if ast.Prompts == nil && ast.MCP == nil {
|
||||
t.Log("WARNING: Neither Prompts nor MCP is set, LLM phase will be skipped entirely!")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,481 +0,0 @@
|
|||
package assistant_test
|
||||
|
||||
import (
|
||||
stdContext "context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
// newSandboxE2EContext creates a Context for sandbox E2E testing
|
||||
// Uses unique chatID to avoid container name conflicts
|
||||
func newSandboxE2EContext(chatIDPrefix, assistantID string) *context.Context {
|
||||
// Generate unique chatID using timestamp to avoid container conflicts
|
||||
chatID := fmt.Sprintf("%s-%d", chatIDPrefix, time.Now().UnixNano())
|
||||
|
||||
authorized := &types.AuthorizedInfo{
|
||||
Subject: "sandbox-e2e-test-user",
|
||||
ClientID: "sandbox-e2e-test-client",
|
||||
Scope: "openid profile",
|
||||
SessionID: "sandbox-e2e-test-session",
|
||||
UserID: "sandbox-user-123",
|
||||
TeamID: "sandbox-team-456",
|
||||
TenantID: "sandbox-tenant-789",
|
||||
}
|
||||
|
||||
ctx := context.New(stdContext.Background(), authorized, chatID)
|
||||
ctx.AssistantID = assistantID
|
||||
ctx.Locale = "en-us"
|
||||
ctx.Theme = "light"
|
||||
ctx.Client = context.Client{
|
||||
Type: "web",
|
||||
UserAgent: "SandboxE2ETest/1.0",
|
||||
IP: "127.0.0.1",
|
||||
}
|
||||
ctx.Referer = context.RefererAPI
|
||||
ctx.Accept = context.AcceptWebCUI
|
||||
ctx.Route = ""
|
||||
ctx.Metadata = make(map[string]interface{})
|
||||
return ctx
|
||||
}
|
||||
|
||||
// TestSandboxBasicE2E tests the basic sandbox assistant end-to-end
|
||||
// This test verifies that:
|
||||
// 1. Sandbox is correctly initialized
|
||||
// 2. Claude CLI command is built correctly
|
||||
// 3. Docker container is created and managed
|
||||
func TestSandboxBasicE2E(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping sandbox E2E test in short mode")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
// Load the basic sandbox assistant
|
||||
ast, err := assistant.Get("tests.sandbox.basic")
|
||||
if err != nil {
|
||||
t.Skipf("Skipping test: sandbox assistant not available: %v", err)
|
||||
}
|
||||
|
||||
// Verify sandbox is configured
|
||||
require.NotNil(t, ast.Sandbox, "Sandbox should be configured")
|
||||
assert.Equal(t, "claude", ast.Sandbox.Command)
|
||||
t.Logf("✓ Sandbox configured with command: %s", ast.Sandbox.Command)
|
||||
|
||||
// Create context
|
||||
ctx := newSandboxE2EContext("sandbox-basic-e2e", "tests.sandbox.basic")
|
||||
|
||||
// Test messages
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "echo hello sandbox"},
|
||||
}
|
||||
|
||||
// Execute stream
|
||||
// Note: This will fail if Docker/Claude image is not available, which is expected in CI
|
||||
response, err := ast.Stream(ctx, messages)
|
||||
if err != nil {
|
||||
// Check if it's a Docker/sandbox availability issue
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "Docker") ||
|
||||
strings.Contains(errStr, "sandbox") ||
|
||||
strings.Contains(errStr, "container") ||
|
||||
strings.Contains(errStr, "image") {
|
||||
t.Skipf("Skipping test: Docker/sandbox not available: %v", err)
|
||||
}
|
||||
t.Fatalf("Stream failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify response
|
||||
require.NotNil(t, response, "Response should not be nil")
|
||||
|
||||
// Verify response completion (Claude CLI should return some response)
|
||||
if response.Completion != nil && response.Completion.Content != nil {
|
||||
if contentStr, ok := response.Completion.Content.(string); ok && contentStr != "" {
|
||||
t.Logf("✓ Response content: %s", truncateString(contentStr, 200))
|
||||
} else {
|
||||
t.Logf("⚠ Response content type: %T", response.Completion.Content)
|
||||
}
|
||||
} else {
|
||||
t.Log("⚠ Response content is empty (might be expected for some commands)")
|
||||
}
|
||||
|
||||
t.Log("✓ Basic sandbox E2E test passed")
|
||||
}
|
||||
|
||||
// truncateString truncates a string to maxLen and adds "..." if truncated
|
||||
func truncateString(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
|
||||
// TestSandboxHooksE2E tests the sandbox assistant with hooks
|
||||
func TestSandboxHooksE2E(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping sandbox E2E test in short mode")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
// Load the hooks sandbox assistant
|
||||
ast, err := assistant.Get("tests.sandbox.hooks")
|
||||
if err != nil {
|
||||
t.Skipf("Skipping test: sandbox hooks assistant not available: %v", err)
|
||||
}
|
||||
|
||||
// Verify sandbox and hooks are configured
|
||||
require.NotNil(t, ast.Sandbox, "Sandbox should be configured")
|
||||
require.NotNil(t, ast.HookScript, "HookScript should be loaded")
|
||||
t.Logf("✓ Sandbox and hooks configured")
|
||||
|
||||
// Create context
|
||||
ctx := newSandboxE2EContext("sandbox-hooks-e2e", "tests.sandbox.hooks")
|
||||
|
||||
// Test messages
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "test hooks integration"},
|
||||
}
|
||||
|
||||
// Execute stream
|
||||
response, err := ast.Stream(ctx, messages)
|
||||
if err != nil {
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "Docker") ||
|
||||
strings.Contains(errStr, "sandbox") ||
|
||||
strings.Contains(errStr, "container") ||
|
||||
strings.Contains(errStr, "image") {
|
||||
t.Skipf("Skipping test: Docker/sandbox not available: %v", err)
|
||||
}
|
||||
t.Fatalf("Stream failed: %v", err)
|
||||
}
|
||||
|
||||
require.NotNil(t, response, "Response should not be nil")
|
||||
t.Log("✓ Sandbox hooks E2E test passed")
|
||||
}
|
||||
|
||||
// TestSandboxFullE2E tests the full sandbox assistant with MCPs and Skills
|
||||
func TestSandboxFullE2E(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping sandbox E2E test in short mode")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
// Load the full sandbox assistant
|
||||
ast, err := assistant.Get("tests.sandbox.full")
|
||||
if err != nil {
|
||||
t.Skipf("Skipping test: full sandbox assistant not available: %v", err)
|
||||
}
|
||||
|
||||
// Verify all components are configured
|
||||
require.NotNil(t, ast.Sandbox, "Sandbox should be configured")
|
||||
require.NotNil(t, ast.MCP, "MCP should be configured")
|
||||
require.NotNil(t, ast.HookScript, "HookScript should be loaded")
|
||||
t.Logf("✓ Full sandbox configured: command=%s, MCP servers=%d",
|
||||
ast.Sandbox.Command, len(ast.MCP.Servers))
|
||||
|
||||
// Verify MCP configuration
|
||||
assert.Len(t, ast.MCP.Servers, 1)
|
||||
assert.Equal(t, "echo", ast.MCP.Servers[0].ServerID)
|
||||
t.Logf("✓ MCP server: %s with tools %v", ast.MCP.Servers[0].ServerID, ast.MCP.Servers[0].Tools)
|
||||
|
||||
// Create context
|
||||
ctx := newSandboxE2EContext("sandbox-full-e2e", "tests.sandbox.full")
|
||||
|
||||
// Test messages
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "test full sandbox with MCP and skills"},
|
||||
}
|
||||
|
||||
// Execute stream
|
||||
response, err := ast.Stream(ctx, messages)
|
||||
if err != nil {
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "Docker") ||
|
||||
strings.Contains(errStr, "sandbox") ||
|
||||
strings.Contains(errStr, "container") ||
|
||||
strings.Contains(errStr, "image") {
|
||||
t.Skipf("Skipping test: Docker/sandbox not available: %v", err)
|
||||
}
|
||||
t.Fatalf("Stream failed: %v", err)
|
||||
}
|
||||
|
||||
require.NotNil(t, response, "Response should not be nil")
|
||||
t.Log("✓ Full sandbox E2E test passed")
|
||||
}
|
||||
|
||||
// TestSandboxContextAccess tests that sandbox is accessible in hooks via ctx.sandbox
|
||||
func TestSandboxContextAccess(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping sandbox context access test in short mode")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
// Load the hooks sandbox assistant
|
||||
ast, err := assistant.Get("tests.sandbox.hooks")
|
||||
if err != nil {
|
||||
t.Skipf("Skipping test: sandbox hooks assistant not available: %v", err)
|
||||
}
|
||||
|
||||
require.NotNil(t, ast.HookScript, "HookScript should be loaded")
|
||||
|
||||
// Create context
|
||||
ctx := newSandboxE2EContext("sandbox-ctx-access", "tests.sandbox.hooks")
|
||||
|
||||
// Test Create Hook - it should have access to ctx.sandbox
|
||||
messages := []context.Message{
|
||||
{Role: context.RoleUser, Content: "test sandbox context access"},
|
||||
}
|
||||
|
||||
// Execute Create hook directly
|
||||
// This tests that the hook runs without error (sandbox operations tested within)
|
||||
opts := &context.Options{}
|
||||
response, _, err := ast.HookScript.Create(ctx, messages, opts)
|
||||
|
||||
// The hook might fail if sandbox isn't initialized yet (that's done in Stream)
|
||||
// But we can at least verify the hook exists and can be called
|
||||
if err != nil {
|
||||
// If the error is about sandbox not being available, that's expected
|
||||
// because we haven't initialized the sandbox yet
|
||||
if strings.Contains(err.Error(), "sandbox") {
|
||||
t.Logf("Expected error: sandbox not available in direct hook call: %v", err)
|
||||
} else {
|
||||
t.Fatalf("Unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Response might be nil, that's okay
|
||||
t.Logf("Create hook response: %v", response)
|
||||
t.Log("✓ Sandbox context access test passed")
|
||||
}
|
||||
|
||||
// TestSandboxMCPToolCall tests that Claude actually calls MCP tools via IPC
|
||||
// This test specifically asks Claude to use the echo tool and verifies the result
|
||||
func TestSandboxMCPToolCall(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping sandbox MCP tool call test in short mode")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
// Load the full sandbox assistant (has MCP echo tool)
|
||||
ast, err := assistant.Get("tests.sandbox.full")
|
||||
if err != nil {
|
||||
t.Skipf("Skipping test: full sandbox assistant not available: %v", err)
|
||||
}
|
||||
|
||||
// Verify MCP is configured with echo tools
|
||||
require.NotNil(t, ast.MCP, "MCP should be configured")
|
||||
require.NotEmpty(t, ast.MCP.Servers, "MCP servers should be configured")
|
||||
t.Logf("✓ MCP configured with server: %s, tools: %v",
|
||||
ast.MCP.Servers[0].ServerID, ast.MCP.Servers[0].Tools)
|
||||
|
||||
// Create context
|
||||
ctx := newSandboxE2EContext("sandbox-mcp-tool", "tests.sandbox.full")
|
||||
|
||||
// Explicit prompt to use echo tool
|
||||
// This tells Claude to use the MCP tool specifically
|
||||
messages := []context.Message{
|
||||
{
|
||||
Role: context.RoleUser,
|
||||
Content: `Please use the 'ping' MCP tool to send a ping with message "MCP_TEST_SUCCESS".
|
||||
Just call the tool and show me the result. Do not explain, just use the tool.`,
|
||||
},
|
||||
}
|
||||
|
||||
// Collect all response content
|
||||
var responseContent strings.Builder
|
||||
|
||||
// Execute stream
|
||||
response, err := ast.Stream(ctx, messages)
|
||||
if err != nil {
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "Docker") ||
|
||||
strings.Contains(errStr, "sandbox") ||
|
||||
strings.Contains(errStr, "container") ||
|
||||
strings.Contains(errStr, "image") {
|
||||
t.Skipf("Skipping test: Docker/sandbox not available: %v", err)
|
||||
}
|
||||
t.Fatalf("Stream failed: %v", err)
|
||||
}
|
||||
|
||||
require.NotNil(t, response, "Response should not be nil")
|
||||
|
||||
// Get the response content
|
||||
fullResponse := ""
|
||||
if response.Completion != nil && response.Completion.Content != nil {
|
||||
if contentStr, ok := response.Completion.Content.(string); ok {
|
||||
fullResponse = contentStr
|
||||
responseContent.WriteString(contentStr)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Claude response: %s", fullResponse)
|
||||
|
||||
// Check if Claude acknowledged using the tool or returned tool results
|
||||
// The response should contain either:
|
||||
// 1. Evidence of tool call (tool_use block in response)
|
||||
// 2. The ping result "pong" or "MCP_TEST_SUCCESS"
|
||||
// 3. Some indication that it attempted to use the MCP tool
|
||||
|
||||
hasToolEvidence := strings.Contains(fullResponse, "pong") ||
|
||||
strings.Contains(fullResponse, "MCP_TEST_SUCCESS") ||
|
||||
strings.Contains(fullResponse, "ping") ||
|
||||
strings.Contains(fullResponse, "tool")
|
||||
|
||||
if hasToolEvidence {
|
||||
t.Log("✓ Claude appears to have used the MCP tool")
|
||||
} else {
|
||||
t.Logf("⚠ Claude response does not clearly show MCP tool usage")
|
||||
t.Logf("Response: %s", fullResponse)
|
||||
}
|
||||
|
||||
// At minimum, verify we got a response
|
||||
if fullResponse == "" {
|
||||
t.Log("⚠ Response content is empty")
|
||||
}
|
||||
t.Log("✓ Sandbox MCP tool call test completed")
|
||||
}
|
||||
|
||||
// TestSandboxMCPEchoTool tests the echo MCP tool specifically
|
||||
// This test uses a more explicit prompt to force tool usage
|
||||
func TestSandboxMCPEchoTool(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping sandbox MCP echo test in short mode")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
// Load the full sandbox assistant
|
||||
ast, err := assistant.Get("tests.sandbox.full")
|
||||
if err != nil {
|
||||
t.Skipf("Skipping test: full sandbox assistant not available: %v", err)
|
||||
}
|
||||
|
||||
// Create context
|
||||
ctx := newSandboxE2EContext("sandbox-mcp-echo", "tests.sandbox.full")
|
||||
|
||||
// Very explicit prompt for echo tool
|
||||
messages := []context.Message{
|
||||
{
|
||||
Role: context.RoleUser,
|
||||
Content: `Call the 'echo' MCP tool with message "ECHO_VERIFICATION_12345" and uppercase=true.
|
||||
Show me the exact response from the tool.`,
|
||||
},
|
||||
}
|
||||
|
||||
response, err := ast.Stream(ctx, messages)
|
||||
if err != nil {
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "Docker") ||
|
||||
strings.Contains(errStr, "sandbox") ||
|
||||
strings.Contains(errStr, "container") ||
|
||||
strings.Contains(errStr, "image") {
|
||||
t.Skipf("Skipping test: Docker/sandbox not available: %v", err)
|
||||
}
|
||||
t.Fatalf("Stream failed: %v", err)
|
||||
}
|
||||
|
||||
require.NotNil(t, response)
|
||||
|
||||
fullResponse := ""
|
||||
if response.Completion != nil && response.Completion.Content != nil {
|
||||
if contentStr, ok := response.Completion.Content.(string); ok {
|
||||
fullResponse = contentStr
|
||||
}
|
||||
}
|
||||
t.Logf("Claude response for echo tool: %s", fullResponse)
|
||||
|
||||
// The echo tool with uppercase=true should return "ECHO_VERIFICATION_12345"
|
||||
// Check if this appears in the response
|
||||
if strings.Contains(fullResponse, "ECHO_VERIFICATION_12345") {
|
||||
t.Log("✓ MCP echo tool executed successfully - found verification string in response")
|
||||
} else if strings.Contains(fullResponse, "echo") || strings.Contains(fullResponse, "ECHO") {
|
||||
t.Log("✓ MCP echo tool appears to have been used (found 'echo' in response)")
|
||||
} else {
|
||||
t.Logf("⚠ Could not verify echo tool execution. Response: %s", fullResponse)
|
||||
}
|
||||
|
||||
t.Log("✓ Sandbox MCP echo tool test completed")
|
||||
}
|
||||
|
||||
// TestSandboxLoadConfiguration verifies that sandbox assistants load correctly
|
||||
func TestSandboxLoadConfiguration(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
assistantID string
|
||||
expectSandbox bool
|
||||
expectMCP bool
|
||||
expectHooks bool
|
||||
}{
|
||||
{
|
||||
name: "BasicSandbox",
|
||||
assistantID: "tests.sandbox.basic",
|
||||
expectSandbox: true,
|
||||
expectMCP: false,
|
||||
expectHooks: false,
|
||||
},
|
||||
{
|
||||
name: "HooksSandbox",
|
||||
assistantID: "tests.sandbox.hooks",
|
||||
expectSandbox: true,
|
||||
expectMCP: false,
|
||||
expectHooks: true,
|
||||
},
|
||||
{
|
||||
name: "FullSandbox",
|
||||
assistantID: "tests.sandbox.full",
|
||||
expectSandbox: true,
|
||||
expectMCP: true,
|
||||
expectHooks: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ast, err := assistant.Get(tc.assistantID)
|
||||
if err != nil {
|
||||
t.Skipf("Skipping: assistant %s not available: %v", tc.assistantID, err)
|
||||
}
|
||||
|
||||
// Check sandbox
|
||||
if tc.expectSandbox {
|
||||
require.NotNil(t, ast.Sandbox, "Expected sandbox to be configured")
|
||||
assert.Equal(t, "claude", ast.Sandbox.Command)
|
||||
t.Logf("✓ %s: Sandbox configured with command=%s", tc.name, ast.Sandbox.Command)
|
||||
}
|
||||
|
||||
// Check MCP
|
||||
if tc.expectMCP {
|
||||
require.NotNil(t, ast.MCP, "Expected MCP to be configured")
|
||||
assert.True(t, len(ast.MCP.Servers) > 0, "Expected at least one MCP server")
|
||||
t.Logf("✓ %s: MCP configured with %d servers", tc.name, len(ast.MCP.Servers))
|
||||
}
|
||||
|
||||
// Check hooks
|
||||
if tc.expectHooks {
|
||||
require.NotNil(t, ast.HookScript, "Expected hooks to be loaded")
|
||||
t.Logf("✓ %s: Hooks loaded", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
package assistant_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/agent"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
agentContext "github.com/yaoapp/yao/agent/context"
|
||||
agentsandbox "github.com/yaoapp/yao/agent/sandbox"
|
||||
"github.com/yaoapp/yao/agent/sandbox/claude"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/test"
|
||||
)
|
||||
|
||||
// TestSandboxOptionsBuilding tests that sandbox options are correctly built from assistant config
|
||||
func TestSandboxOptionsBuilding(t *testing.T) {
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
// Load agent to ensure connectors are available
|
||||
err := agent.Load(config.Conf)
|
||||
require.NoError(t, err, "agent.Load should succeed")
|
||||
|
||||
// Load the full test assistant
|
||||
ast, err := assistant.LoadPath("/assistants/tests/sandbox/full")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
// Verify sandbox is configured
|
||||
require.NotNil(t, ast.Sandbox)
|
||||
assert.Equal(t, "claude", ast.Sandbox.Command)
|
||||
assert.Equal(t, "5m", ast.Sandbox.Timeout)
|
||||
|
||||
// Verify arguments are set
|
||||
require.NotNil(t, ast.Sandbox.Arguments)
|
||||
assert.Equal(t, float64(10), ast.Sandbox.Arguments["max_turns"])
|
||||
assert.Equal(t, "acceptEdits", ast.Sandbox.Arguments["permission_mode"])
|
||||
|
||||
// Verify MCP configuration
|
||||
require.NotNil(t, ast.MCP)
|
||||
assert.Len(t, ast.MCP.Servers, 1)
|
||||
assert.Equal(t, "echo", ast.MCP.Servers[0].ServerID)
|
||||
|
||||
t.Logf("Sandbox config: command=%s, timeout=%s", ast.Sandbox.Command, ast.Sandbox.Timeout)
|
||||
t.Logf("Sandbox arguments: %v", ast.Sandbox.Arguments)
|
||||
t.Logf("MCP servers: %v", ast.MCP.Servers)
|
||||
}
|
||||
|
||||
// TestClaudeCommandBuilding tests that Claude CLI commands are correctly built
|
||||
func TestClaudeCommandBuilding(t *testing.T) {
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
// Create test messages
|
||||
messages := []agentContext.Message{
|
||||
{Role: "system", Content: "You are a helpful coding assistant."},
|
||||
{Role: "user", Content: "Hello, how are you?"},
|
||||
}
|
||||
|
||||
// Create options similar to what buildSandboxOptions would produce
|
||||
opts := &claude.Options{
|
||||
Command: "claude",
|
||||
UserID: "test-user",
|
||||
ChatID: "test-chat",
|
||||
ConnectorHost: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
ConnectorKey: "test-api-key",
|
||||
Model: "ep-xxxxx",
|
||||
Arguments: map[string]interface{}{
|
||||
"max_turns": 10,
|
||||
"permission_mode": "acceptEdits",
|
||||
},
|
||||
}
|
||||
|
||||
// Build the command
|
||||
cmd, env, err := claude.BuildCommand(messages, opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify command structure
|
||||
// Command is now: ["bash", "-c", "cat << 'INPUTEOF' | claude -p ... INPUTEOF"]
|
||||
assert.NotEmpty(t, cmd)
|
||||
assert.Equal(t, "bash", cmd[0], "Command should start with bash")
|
||||
assert.Equal(t, "-c", cmd[1], "Second arg should be -c")
|
||||
assert.Contains(t, cmd[2], "claude -p", "Bash command should contain claude -p")
|
||||
assert.Contains(t, cmd[2], "--permission-mode", "Should include permission mode")
|
||||
assert.Contains(t, cmd[2], "--input-format", "Should include input-format flag")
|
||||
assert.Contains(t, cmd[2], "--output-format", "Should include output-format flag")
|
||||
assert.Contains(t, cmd[2], "--verbose", "Should include verbose flag")
|
||||
assert.Contains(t, cmd[2], "stream-json", "Should use stream-json format")
|
||||
assert.Contains(t, cmd[2], "INPUTEOF", "Should use heredoc for input")
|
||||
t.Logf("Built command: %v", cmd)
|
||||
|
||||
// Verify environment variables (claude-proxy mode)
|
||||
assert.NotEmpty(t, env)
|
||||
assert.Equal(t, "http://127.0.0.1:3456", env["ANTHROPIC_BASE_URL"], "Should set proxy base URL")
|
||||
assert.Equal(t, "dummy", env["ANTHROPIC_API_KEY"], "Should set dummy API key for proxy")
|
||||
|
||||
// max_turns is passed via CLI flag
|
||||
// system prompt is written to file via heredoc, then referenced via --append-system-prompt-file
|
||||
assert.Contains(t, cmd[2], "--max-turns", "Should include max-turns flag")
|
||||
assert.Contains(t, cmd[2], "cat << 'PROMPTEOF' > /tmp/.system-prompt.txt", "Should use heredoc for system prompt")
|
||||
assert.Contains(t, cmd[2], "--append-system-prompt-file", "Should include append-system-prompt-file flag")
|
||||
assert.Contains(t, cmd[2], "You are a helpful coding assistant", "Command should contain system prompt")
|
||||
t.Logf("Built environment: %v", env)
|
||||
}
|
||||
|
||||
// TestClaudeProxyConfigBuilding tests that claude-proxy config is correctly built
|
||||
func TestClaudeProxyConfigBuilding(t *testing.T) {
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
opts := &claude.Options{
|
||||
ConnectorHost: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
ConnectorKey: "test-api-key",
|
||||
Model: "ep-xxxxx",
|
||||
}
|
||||
|
||||
configJSON, err := claude.BuildProxyConfig(opts)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, configJSON)
|
||||
|
||||
t.Logf("Proxy config: %s", string(configJSON))
|
||||
|
||||
// Verify the JSON contains expected fields for claude-proxy
|
||||
assert.Contains(t, string(configJSON), "backend")
|
||||
assert.Contains(t, string(configJSON), "api_key")
|
||||
assert.Contains(t, string(configJSON), "model")
|
||||
assert.Contains(t, string(configJSON), "test-api-key")
|
||||
assert.Contains(t, string(configJSON), "ep-xxxxx")
|
||||
// Backend URL should end with /chat/completions
|
||||
assert.Contains(t, string(configJSON), "/chat/completions")
|
||||
}
|
||||
|
||||
// TestDefaultImageSelection tests that default images are correctly selected
|
||||
func TestDefaultImageSelection(t *testing.T) {
|
||||
tests := []struct {
|
||||
command string
|
||||
expectedImage string
|
||||
}{
|
||||
{"claude", "yaoapp/sandbox-claude:latest"},
|
||||
{"cursor", "yaoapp/sandbox-cursor:latest"},
|
||||
{"unknown", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.command, func(t *testing.T) {
|
||||
image := agentsandbox.DefaultImage(tt.command)
|
||||
assert.Equal(t, tt.expectedImage, image)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSandboxCommandValidation tests that command validation works correctly
|
||||
func TestSandboxCommandValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
command string
|
||||
valid bool
|
||||
}{
|
||||
{"claude", true},
|
||||
{"cursor", true},
|
||||
{"invalid", false},
|
||||
{"", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.command, func(t *testing.T) {
|
||||
result := agentsandbox.IsValidCommand(tt.command)
|
||||
assert.Equal(t, tt.valid, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHasSandboxMethod tests the HasSandbox method on Assistant
|
||||
func TestHasSandboxMethod(t *testing.T) {
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
// Test assistant with sandbox
|
||||
astWithSandbox, err := assistant.LoadPath("/assistants/tests/sandbox/basic")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, astWithSandbox.HasSandbox(), "Assistant with sandbox config should return true")
|
||||
|
||||
// Test assistant without sandbox
|
||||
astWithoutSandbox, err := assistant.LoadPath("/assistants/tests/simple-greeting")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, astWithoutSandbox.HasSandbox(), "Assistant without sandbox config should return false")
|
||||
}
|
||||
|
|
@ -1,319 +0,0 @@
|
|||
package assistant_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/agent"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
agentContext "github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/test"
|
||||
)
|
||||
|
||||
// TestLoadSandboxBasicAssistant tests loading the basic sandbox test assistant
|
||||
func TestLoadSandboxBasicAssistant(t *testing.T) {
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
ast, err := assistant.LoadPath("/assistants/tests/sandbox/basic")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
// Verify basic fields
|
||||
assert.Equal(t, "tests.sandbox.basic", ast.ID)
|
||||
assert.Equal(t, "Sandbox Basic Test", ast.Name)
|
||||
assert.Equal(t, "deepseek.v3", ast.Connector)
|
||||
|
||||
// Verify sandbox configuration
|
||||
require.NotNil(t, ast.Sandbox, "Sandbox should be configured")
|
||||
assert.Equal(t, "claude", ast.Sandbox.Command)
|
||||
assert.Equal(t, "5m", ast.Sandbox.Timeout)
|
||||
|
||||
// Verify HasSandbox returns true
|
||||
assert.True(t, ast.HasSandbox(), "HasSandbox should return true")
|
||||
}
|
||||
|
||||
// TestLoadSandboxHooksAssistant tests loading the hooks sandbox test assistant
|
||||
func TestLoadSandboxHooksAssistant(t *testing.T) {
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
ast, err := assistant.LoadPath("/assistants/tests/sandbox/hooks")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
// Verify basic fields
|
||||
assert.Equal(t, "tests.sandbox.hooks", ast.ID)
|
||||
assert.Equal(t, "Sandbox Hooks Test", ast.Name)
|
||||
assert.Equal(t, "deepseek.v3", ast.Connector)
|
||||
|
||||
// Verify sandbox configuration
|
||||
require.NotNil(t, ast.Sandbox, "Sandbox should be configured")
|
||||
assert.Equal(t, "claude", ast.Sandbox.Command)
|
||||
|
||||
// Verify hooks are loaded
|
||||
assert.NotNil(t, ast.HookScript, "HookScript should be loaded")
|
||||
}
|
||||
|
||||
// TestLoadSandboxFullAssistant tests loading the full sandbox test assistant with MCPs and Skills
|
||||
func TestLoadSandboxFullAssistant(t *testing.T) {
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
// Load agent to ensure MCPs are available
|
||||
err := agent.Load(config.Conf)
|
||||
require.NoError(t, err, "agent.Load should succeed")
|
||||
|
||||
ast, err := assistant.LoadPath("/assistants/tests/sandbox/full")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
// Verify basic fields
|
||||
assert.Equal(t, "tests.sandbox.full", ast.ID)
|
||||
assert.Equal(t, "Sandbox Full Test", ast.Name)
|
||||
assert.Equal(t, "deepseek.v3", ast.Connector)
|
||||
|
||||
// Verify sandbox configuration
|
||||
require.NotNil(t, ast.Sandbox, "Sandbox should be configured")
|
||||
assert.Equal(t, "claude", ast.Sandbox.Command)
|
||||
assert.Equal(t, "5m", ast.Sandbox.Timeout)
|
||||
|
||||
// Verify sandbox arguments (command-specific options)
|
||||
require.NotNil(t, ast.Sandbox.Arguments, "Sandbox arguments should be configured")
|
||||
assert.Equal(t, float64(10), ast.Sandbox.Arguments["max_turns"])
|
||||
assert.Equal(t, "acceptEdits", ast.Sandbox.Arguments["permission_mode"])
|
||||
|
||||
// Verify MCP configuration
|
||||
require.NotNil(t, ast.MCP, "MCP should be configured")
|
||||
require.NotNil(t, ast.MCP.Servers, "MCP.Servers should be configured")
|
||||
assert.Len(t, ast.MCP.Servers, 1, "Should have 1 MCP server configured")
|
||||
assert.Equal(t, "echo", ast.MCP.Servers[0].ServerID, "MCP server ID should be 'echo'")
|
||||
assert.Contains(t, ast.MCP.Servers[0].Tools, "ping", "MCP tools should contain 'ping'")
|
||||
assert.Contains(t, ast.MCP.Servers[0].Tools, "echo", "MCP tools should contain 'echo'")
|
||||
|
||||
// Verify hooks are loaded
|
||||
assert.NotNil(t, ast.HookScript, "HookScript should be loaded")
|
||||
}
|
||||
|
||||
// TestSandboxConfigValidation tests sandbox configuration validation
|
||||
func TestSandboxConfigValidation(t *testing.T) {
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
hasError bool
|
||||
}{
|
||||
{
|
||||
name: "Basic sandbox config",
|
||||
path: "/assistants/tests/sandbox/basic",
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "Hooks sandbox config",
|
||||
path: "/assistants/tests/sandbox/hooks",
|
||||
hasError: false,
|
||||
},
|
||||
{
|
||||
name: "Full sandbox config with MCPs",
|
||||
path: "/assistants/tests/sandbox/full",
|
||||
hasError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ast, err := assistant.LoadPath(tt.path)
|
||||
if tt.hasError {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
require.NotNil(t, ast.Sandbox)
|
||||
assert.NotEmpty(t, ast.Sandbox.Command)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSkillsDirectoryResolution tests that skills directory exists and has correct structure
|
||||
// Note: Skills are auto-discovered from skills/ directory, not stored in AssistantModel
|
||||
func TestSkillsDirectoryResolution(t *testing.T) {
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
ast, err := assistant.LoadPath("/assistants/tests/sandbox/full")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
// Get app root from environment
|
||||
appRoot := os.Getenv("YAO_ROOT")
|
||||
require.NotEmpty(t, appRoot, "YAO_ROOT should be set")
|
||||
|
||||
// Verify assistant path is set
|
||||
assert.NotEmpty(t, ast.Path, "Assistant path should be set")
|
||||
|
||||
// Build expected skills directory path
|
||||
// ast.Path is like "/assistants/tests/sandbox/full"
|
||||
expectedSkillsDir := filepath.Join(appRoot, ast.Path, "skills")
|
||||
|
||||
// Verify skills directory exists
|
||||
info, err := os.Stat(expectedSkillsDir)
|
||||
require.NoError(t, err, "Skills directory should exist: %s", expectedSkillsDir)
|
||||
assert.True(t, info.IsDir(), "Skills path should be a directory")
|
||||
|
||||
// Verify skills directory structure
|
||||
entries, err := os.ReadDir(expectedSkillsDir)
|
||||
require.NoError(t, err, "Should be able to read skills directory")
|
||||
|
||||
// Find echo-test skill
|
||||
var foundEchoTest bool
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() && entry.Name() == "echo-test" {
|
||||
foundEchoTest = true
|
||||
|
||||
// Verify SKILL.md exists (required)
|
||||
skillMdPath := filepath.Join(expectedSkillsDir, "echo-test", "SKILL.md")
|
||||
_, err := os.Stat(skillMdPath)
|
||||
assert.NoError(t, err, "SKILL.md should exist")
|
||||
|
||||
// Verify scripts directory exists (optional but we created it)
|
||||
scriptsDir := filepath.Join(expectedSkillsDir, "echo-test", "scripts")
|
||||
_, err = os.Stat(scriptsDir)
|
||||
assert.NoError(t, err, "scripts directory should exist")
|
||||
|
||||
// Verify echo.sh exists
|
||||
echoShPath := filepath.Join(scriptsDir, "echo.sh")
|
||||
_, err = os.Stat(echoShPath)
|
||||
assert.NoError(t, err, "echo.sh should exist")
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, foundEchoTest, "echo-test skill should exist in skills directory")
|
||||
}
|
||||
|
||||
// TestMCPConfiguration tests that MCP is correctly loaded for sandbox assistant
|
||||
func TestMCPConfiguration(t *testing.T) {
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
// Load agent to ensure MCPs are available
|
||||
err := agent.Load(config.Conf)
|
||||
require.NoError(t, err, "agent.Load should succeed")
|
||||
|
||||
ast, err := assistant.LoadPath("/assistants/tests/sandbox/full")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
// Verify MCP configuration structure
|
||||
require.NotNil(t, ast.MCP, "MCP should not be nil")
|
||||
require.NotNil(t, ast.MCP.Servers, "MCP.Servers should not be nil")
|
||||
assert.Len(t, ast.MCP.Servers, 1, "Should have 1 MCP server configured")
|
||||
|
||||
// Verify echo server configuration
|
||||
echoServer := ast.MCP.Servers[0]
|
||||
assert.Equal(t, "echo", echoServer.ServerID, "Server ID should be 'echo'")
|
||||
assert.Len(t, echoServer.Tools, 3, "Should have 3 tools configured")
|
||||
assert.Contains(t, echoServer.Tools, "ping")
|
||||
assert.Contains(t, echoServer.Tools, "echo")
|
||||
assert.Contains(t, echoServer.Tools, "status")
|
||||
}
|
||||
|
||||
// TestBuildMCPConfigForSandbox tests that MCP configuration is correctly built for sandbox
|
||||
func TestBuildMCPConfigForSandbox(t *testing.T) {
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
// Load agent to ensure MCPs are available
|
||||
err := agent.Load(config.Conf)
|
||||
require.NoError(t, err, "agent.Load should succeed")
|
||||
|
||||
ast, err := assistant.LoadPath("/assistants/tests/sandbox/full")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
require.NotNil(t, ast.MCP, "MCP configuration should exist")
|
||||
|
||||
// Create a mock context for the test
|
||||
ctx := agentContext.New(context.Background(), nil, "test-mcp-config-build")
|
||||
|
||||
// Call BuildMCPConfigForSandbox and verify the result
|
||||
mcpConfig, err := ast.BuildMCPConfigForSandbox(ctx)
|
||||
require.NoError(t, err, "BuildMCPConfigForSandbox should not error")
|
||||
require.NotEmpty(t, mcpConfig, "MCP config should not be empty")
|
||||
|
||||
t.Logf("MCP config JSON: %s", string(mcpConfig))
|
||||
|
||||
// Parse and verify the JSON structure
|
||||
var config map[string]interface{}
|
||||
err = json.Unmarshal(mcpConfig, &config)
|
||||
require.NoError(t, err, "MCP config should be valid JSON")
|
||||
|
||||
// Verify mcpServers key exists
|
||||
mcpServers, ok := config["mcpServers"].(map[string]interface{})
|
||||
require.True(t, ok, "mcpServers should be a map")
|
||||
require.NotEmpty(t, mcpServers, "mcpServers should not be empty")
|
||||
|
||||
// Verify "yao" server exists (single server using yao-bridge for IPC)
|
||||
yaoServer, ok := mcpServers["yao"].(map[string]interface{})
|
||||
require.True(t, ok, "yao server should exist in mcpServers")
|
||||
|
||||
// Verify server structure - uses yao-bridge to connect to IPC socket
|
||||
assert.Equal(t, "yao-bridge", yaoServer["command"], "command should be yao-bridge")
|
||||
|
||||
args, ok := yaoServer["args"].([]interface{})
|
||||
require.True(t, ok, "args should be an array")
|
||||
require.Len(t, args, 1, "args should have 1 element")
|
||||
assert.Equal(t, "/tmp/yao.sock", args[0], "first arg should be IPC socket path")
|
||||
|
||||
t.Logf("✓ MCP config verified: uses yao-bridge with IPC socket /tmp/yao.sock")
|
||||
}
|
||||
|
||||
// TestSandboxMCPAndSkillsOptions tests that sandbox options include MCP and Skills
|
||||
func TestSandboxMCPAndSkillsOptions(t *testing.T) {
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
// Load agent to ensure MCPs are available
|
||||
err := agent.Load(config.Conf)
|
||||
require.NoError(t, err, "agent.Load should succeed")
|
||||
|
||||
ast, err := assistant.LoadPath("/assistants/tests/sandbox/full")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
// Verify sandbox configuration is present
|
||||
require.NotNil(t, ast.Sandbox, "Sandbox should be configured")
|
||||
assert.Equal(t, "claude", ast.Sandbox.Command)
|
||||
|
||||
// Verify MCP is configured (will be passed to sandbox)
|
||||
require.NotNil(t, ast.MCP, "MCP should be configured")
|
||||
assert.Len(t, ast.MCP.Servers, 1, "Should have 1 MCP server")
|
||||
|
||||
// Verify skills directory exists
|
||||
appRoot := os.Getenv("YAO_ROOT")
|
||||
require.NotEmpty(t, appRoot, "YAO_ROOT should be set")
|
||||
|
||||
skillsDir := filepath.Join(appRoot, ast.Path, "skills")
|
||||
info, err := os.Stat(skillsDir)
|
||||
require.NoError(t, err, "Skills directory should exist")
|
||||
assert.True(t, info.IsDir(), "Skills should be a directory")
|
||||
|
||||
// Verify echo-test skill exists
|
||||
echoTestDir := filepath.Join(skillsDir, "echo-test")
|
||||
info, err = os.Stat(echoTestDir)
|
||||
require.NoError(t, err, "echo-test skill should exist")
|
||||
assert.True(t, info.IsDir(), "echo-test should be a directory")
|
||||
|
||||
// Verify SKILL.md exists
|
||||
skillMd := filepath.Join(echoTestDir, "SKILL.md")
|
||||
_, err = os.Stat(skillMd)
|
||||
require.NoError(t, err, "SKILL.md should exist")
|
||||
}
|
||||
|
|
@ -1,354 +0,0 @@
|
|||
package assistant
|
||||
|
||||
import (
|
||||
stdContext "context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/gou/connector"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/i18n"
|
||||
"github.com/yaoapp/yao/agent/output/message"
|
||||
sandboxv2 "github.com/yaoapp/yao/agent/sandbox/v2"
|
||||
sandboxTypes "github.com/yaoapp/yao/agent/sandbox/v2/types"
|
||||
store "github.com/yaoapp/yao/agent/store/types"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/llmprovider"
|
||||
infraV2 "github.com/yaoapp/yao/sandbox/v2"
|
||||
traceTypes "github.com/yaoapp/yao/trace/types"
|
||||
"github.com/yaoapp/yao/workspace"
|
||||
)
|
||||
|
||||
// HasSandboxV2 returns true if the assistant has a V2 sandbox configuration.
|
||||
func (ast *Assistant) HasSandboxV2() bool {
|
||||
return ast.SandboxV2 != nil
|
||||
}
|
||||
|
||||
// sandboxV2InitResult bundles everything returned by initSandboxV2.
|
||||
type sandboxV2InitResult struct {
|
||||
Runner sandboxTypes.Runner
|
||||
Computer infraV2.Computer
|
||||
Config *sandboxTypes.SandboxConfig
|
||||
Cleanup func()
|
||||
LoadingMsgID string
|
||||
Roles map[string]connector.Connector
|
||||
}
|
||||
|
||||
// initSandboxV2 initializes the V2 sandbox: obtains a Computer, gets a Runner,
|
||||
// resolves the role matrix, runs Prepare, and returns the result.
|
||||
//
|
||||
// A shallow copy of ast.SandboxV2 is made so that concurrent requests to the
|
||||
// same assistant each get their own mutable config (Owner, ID, NodeID, etc.).
|
||||
func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options) (*sandboxV2InitResult, error) {
|
||||
cfgCopy := *ast.SandboxV2
|
||||
cfg := &cfgCopy
|
||||
manager := infraV2.M()
|
||||
|
||||
loadingMsg := &message.Message{
|
||||
Type: message.TypeLoading,
|
||||
Props: map[string]any{
|
||||
"message": i18n.T(ctx.Locale, "sandbox.preparing"),
|
||||
},
|
||||
}
|
||||
loadingMsgID, _ := ctx.SendStream(loadingMsg)
|
||||
|
||||
stdCtx := ctx.Context
|
||||
|
||||
// 1. Resolve connector (before Computer so proxy env vars can be injected).
|
||||
conn, _, err := ast.GetConnector(ctx, opts)
|
||||
if err != nil && cfg.Runner.Name != "yao" {
|
||||
closeLoadingV2(ctx, loadingMsgID, "sandbox.failed")
|
||||
return nil, fmt.Errorf("get connector: %w", err)
|
||||
}
|
||||
|
||||
// 1b. Resolve role matrix once; passed to both Prepare and Stream.
|
||||
roles := resolveRoles(conn, ctx.Authorized)
|
||||
|
||||
// 2. Build human-readable DisplayName from real Agent name + Workspace name.
|
||||
cfg.DisplayName = buildBoxDisplayName(ctx, ast.ID, ast.Name)
|
||||
|
||||
// 2.5. Image existence check + pull (for box mode).
|
||||
if cfg.Computer.Image != "" && manager != nil {
|
||||
nodeID, kind, _ := sandboxv2.ResolveNodeID(ctx, cfg, manager)
|
||||
if kind == "box" && nodeID != "" {
|
||||
updateLoadingV2(ctx, loadingMsgID, "sandbox.starting")
|
||||
exists, existsErr := manager.ImageExists(stdCtx, nodeID, cfg.Computer.Image)
|
||||
if existsErr != nil {
|
||||
log.Printf("[sandbox/v2] image exists check failed on node %s: %v", nodeID, existsErr)
|
||||
}
|
||||
if existsErr == nil && !exists {
|
||||
updateLoadingV2(ctx, loadingMsgID, "sandbox.pulling_image")
|
||||
ch, pullErr := manager.PullImage(stdCtx, nodeID, cfg.Computer.Image, infraV2.ImagePullOptions{})
|
||||
if pullErr != nil {
|
||||
log.Printf("[sandbox/v2] image pull failed on node %s: %v (will retry in Create)", nodeID, pullErr)
|
||||
} else if ch != nil {
|
||||
for p := range ch {
|
||||
if p.Error != "" {
|
||||
log.Printf("[sandbox/v2] image pull progress error: %s", p.Error)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Obtain Computer.
|
||||
updateLoadingV2(ctx, loadingMsgID, "sandbox.starting")
|
||||
computer, identifier, err := sandboxv2.GetComputer(ctx, cfg, manager)
|
||||
if err != nil {
|
||||
closeLoadingV2(ctx, loadingMsgID, "sandbox.failed")
|
||||
return nil, fmt.Errorf("getComputer failed: %w", err)
|
||||
}
|
||||
_ = identifier
|
||||
|
||||
// 4. Get Runner.
|
||||
runner, err := sandboxv2.Get(cfg.Runner.Name)
|
||||
if err != nil {
|
||||
sandboxv2.LifecycleAction(stdCtx, cfg, computer, manager)
|
||||
closeLoadingV2(ctx, loadingMsgID, "sandbox.failed")
|
||||
return nil, fmt.Errorf("get runner %q: %w", cfg.Runner.Name, err)
|
||||
}
|
||||
|
||||
// 5. Resolve assistant directory and skills subdirectory.
|
||||
assistantDir := ""
|
||||
skillsDir := ""
|
||||
if ast.Path != "" {
|
||||
assistantDir = filepath.Join(config.Conf.AppSource, ast.Path)
|
||||
dir := filepath.Join(assistantDir, "skills")
|
||||
if info, e := os.Stat(dir); e == nil && info.IsDir() {
|
||||
skillsDir = dir
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Convert MCP servers.
|
||||
var mcpServers []sandboxTypes.MCPServer
|
||||
if ast.MCP != nil {
|
||||
for _, s := range ast.MCP.Servers {
|
||||
mcpServers = append(mcpServers, sandboxTypes.MCPServer{
|
||||
ServerID: s.ServerID,
|
||||
Resources: s.Resources,
|
||||
Tools: s.Tools,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Runner.Prepare (standard context).
|
||||
err = runner.Prepare(stdCtx, &sandboxTypes.PrepareRequest{
|
||||
Computer: computer,
|
||||
Config: cfg,
|
||||
Connector: conn,
|
||||
Roles: roles,
|
||||
AssistantID: ast.ID,
|
||||
SkillsDir: skillsDir,
|
||||
AssistantDir: assistantDir,
|
||||
MCPServers: mcpServers,
|
||||
ConfigHash: ast.ConfigHash,
|
||||
RunSteps: sandboxv2.RunPrepareSteps,
|
||||
})
|
||||
if err != nil {
|
||||
runner.Cleanup(stdCtx, computer)
|
||||
sandboxv2.LifecycleAction(stdCtx, cfg, computer, manager)
|
||||
closeLoadingV2(ctx, loadingMsgID, "sandbox.failed")
|
||||
return nil, fmt.Errorf("runner.Prepare: %w", err)
|
||||
}
|
||||
|
||||
ctx.SetComputer(computer)
|
||||
|
||||
cleanup := func() {
|
||||
cleanCtx, cancel := stdContext.WithTimeout(stdContext.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
runner.Cleanup(cleanCtx, computer)
|
||||
sandboxv2.LifecycleAction(cleanCtx, cfg, computer, manager)
|
||||
}
|
||||
|
||||
return &sandboxV2InitResult{
|
||||
Runner: runner,
|
||||
Computer: computer,
|
||||
Config: cfg,
|
||||
Cleanup: cleanup,
|
||||
LoadingMsgID: loadingMsgID,
|
||||
Roles: roles,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// sandboxV2StreamParams groups arguments for executeSandboxV2Stream.
|
||||
type sandboxV2StreamParams struct {
|
||||
Messages []context.Message
|
||||
AgentNode traceTypes.Node
|
||||
Handler message.StreamFunc
|
||||
Runner sandboxTypes.Runner
|
||||
Computer infraV2.Computer
|
||||
Config *sandboxTypes.SandboxConfig
|
||||
LoadingMsgID string
|
||||
Options *context.Options
|
||||
Roles map[string]connector.Connector
|
||||
}
|
||||
|
||||
// executeSandboxV2Stream calls the V2 Runner.Stream and wraps it in the
|
||||
// standard completion response.
|
||||
func (ast *Assistant) executeSandboxV2Stream(
|
||||
ctx *context.Context, p *sandboxV2StreamParams,
|
||||
) (*context.CompletionResponse, error) {
|
||||
_ = p.AgentNode
|
||||
|
||||
cfg := p.Config
|
||||
manager := infraV2.M()
|
||||
|
||||
// Build system prompt (parse $CTX variables the same way as buildSystemPrompts).
|
||||
var systemPrompt string
|
||||
if len(ast.Prompts) > 0 {
|
||||
ctxVars := ast.buildContextVariables(ctx)
|
||||
parsed := store.Prompts(ast.Prompts).Parse(ctxVars)
|
||||
for _, pr := range parsed {
|
||||
if pr.Role == "system" && pr.Content != "" {
|
||||
systemPrompt = pr.Content
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve connector for Stream (respects user-selected connector via opts).
|
||||
conn, _, _ := ast.GetConnector(ctx, p.Options)
|
||||
|
||||
var tok *sandboxTypes.SandboxToken
|
||||
if ctx.Authorized != nil {
|
||||
var err error
|
||||
tok, err = sandboxv2.IssueSandboxToken(ctx.Authorized.TeamID, ctx.Authorized.UserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("issue sandbox token: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
streamReq := &sandboxTypes.StreamRequest{
|
||||
Computer: p.Computer,
|
||||
Config: cfg,
|
||||
Connector: conn,
|
||||
Roles: p.Roles,
|
||||
AssistantID: ast.ID,
|
||||
Messages: p.Messages,
|
||||
SystemPrompt: systemPrompt,
|
||||
ChatID: ctx.ChatID,
|
||||
Token: tok,
|
||||
Logger: ctx.Logger,
|
||||
UserExplicit: p.Options != nil && p.Options.Connector != "",
|
||||
Locale: ctx.Locale,
|
||||
}
|
||||
|
||||
execReq := &sandboxv2.ExecuteRequest{
|
||||
Computer: p.Computer,
|
||||
Runner: p.Runner,
|
||||
Config: cfg,
|
||||
StreamReq: streamReq,
|
||||
Manager: manager,
|
||||
LoadingMsgID: p.LoadingMsgID,
|
||||
}
|
||||
|
||||
return sandboxv2.ExecuteSandboxStream(ctx, execReq, p.Handler)
|
||||
}
|
||||
|
||||
// resolveRoles builds the role → connector map using the llmprovider role system.
|
||||
// The primary connector (user-selected or system default) becomes "default";
|
||||
// other roles (heavy, light, vision) are fetched from llmprovider settings.
|
||||
func resolveRoles(conn connector.Connector, identity llmprovider.Identity) map[string]connector.Connector {
|
||||
roles := map[string]connector.Connector{}
|
||||
if conn != nil {
|
||||
roles["default"] = conn
|
||||
}
|
||||
if llmprovider.Global == nil || identity == nil {
|
||||
return roles
|
||||
}
|
||||
for _, role := range []string{"heavy", "light", "vision"} {
|
||||
if c, err := llmprovider.Global.GetRoleModelBy(role, identity); err == nil {
|
||||
roles[role] = c
|
||||
}
|
||||
}
|
||||
return roles
|
||||
}
|
||||
|
||||
// initStandaloneWorkspace loads the workspace FS into context when no sandbox
|
||||
// is configured but the user selected a workspace (metadata["workspace_id"]).
|
||||
func (ast *Assistant) initStandaloneWorkspace(ctx *context.Context) {
|
||||
if ctx.Metadata == nil {
|
||||
return
|
||||
}
|
||||
wsID, _ := ctx.Metadata["workspace_id"].(string)
|
||||
if wsID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
stdCtx := ctx.Context
|
||||
wsFS, err := workspace.M().FS(stdCtx, wsID)
|
||||
if err != nil {
|
||||
log.Printf("[assistant] initStandaloneWorkspace: failed to load workspace %s: %v", wsID, err)
|
||||
return
|
||||
}
|
||||
ctx.SetWorkspace(wsFS)
|
||||
}
|
||||
|
||||
// buildBoxDisplayName constructs a human-readable display name for a Box
|
||||
// using the locale-resolved Agent name and Workspace name (matching the UI list pages).
|
||||
func buildBoxDisplayName(ctx *context.Context, assistantID, rawName string) string {
|
||||
agentName := i18n.Tr(assistantID, ctx.Locale, rawName)
|
||||
|
||||
wsName := ""
|
||||
if ctx.Metadata != nil {
|
||||
if wsID, ok := ctx.Metadata["workspace_id"].(string); ok && wsID != "" {
|
||||
if wsm := workspace.M(); wsm != nil {
|
||||
if ws, err := wsm.Get(ctx.Context, wsID); err == nil && ws != nil {
|
||||
wsName = ws.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if agentName != "" && wsName != "" {
|
||||
return agentName + " / " + wsName
|
||||
}
|
||||
if agentName != "" {
|
||||
return agentName
|
||||
}
|
||||
if wsName != "" {
|
||||
return wsName
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func updateLoadingV2(ctx *context.Context, loadingMsgID, msgKey string) {
|
||||
if loadingMsgID == "" || ctx == nil || msgKey == "" {
|
||||
return
|
||||
}
|
||||
msg := &message.Message{
|
||||
MessageID: loadingMsgID,
|
||||
Delta: true,
|
||||
DeltaAction: message.DeltaReplace,
|
||||
Type: message.TypeLoading,
|
||||
Props: map[string]any{
|
||||
"message": i18n.T(ctx.Locale, msgKey),
|
||||
},
|
||||
}
|
||||
ctx.Send(msg)
|
||||
}
|
||||
|
||||
func closeLoadingV2(ctx *context.Context, loadingMsgID, msgKey string) {
|
||||
if loadingMsgID == "" || ctx == nil {
|
||||
return
|
||||
}
|
||||
props := map[string]any{"done": true}
|
||||
if msgKey != "" {
|
||||
props["message"] = i18n.T(ctx.Locale, msgKey)
|
||||
} else {
|
||||
props["message"] = ""
|
||||
}
|
||||
doneMsg := &message.Message{
|
||||
MessageID: loadingMsgID,
|
||||
Delta: true,
|
||||
DeltaAction: message.DeltaReplace,
|
||||
Type: message.TypeLoading,
|
||||
Props: props,
|
||||
}
|
||||
ctx.Send(doneMsg)
|
||||
}
|
||||
|
|
@ -1,391 +0,0 @@
|
|||
package assistant
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/gou/application"
|
||||
"github.com/yaoapp/gou/process"
|
||||
v8 "github.com/yaoapp/gou/runtime/v8"
|
||||
"github.com/yaoapp/kun/exception"
|
||||
"github.com/yaoapp/yao/agent/assistant/hook"
|
||||
)
|
||||
|
||||
// scriptsMutex protects concurrent v8.Load calls and Scripts map access
|
||||
var scriptsMutex sync.Mutex
|
||||
|
||||
// Execute execute the script
|
||||
func (s *Script) Execute(ctx context.Context, method string, args ...interface{}) (interface{}, error) {
|
||||
return s.ExecuteWithAuthorized(ctx, method, nil, args...)
|
||||
}
|
||||
|
||||
// ExecuteWithAuthorized execute the script with authorized information
|
||||
func (s *Script) ExecuteWithAuthorized(ctx context.Context, method string, authorized map[string]interface{}, args ...interface{}) (interface{}, error) {
|
||||
if s == nil || s.Script == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
scriptCtx, err := s.NewContext("", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer scriptCtx.Close()
|
||||
|
||||
// Set authorized information if available
|
||||
if authorized != nil {
|
||||
scriptCtx.WithAuthorized(authorized)
|
||||
}
|
||||
|
||||
// Call the method with provided arguments as-is
|
||||
result, err := scriptCtx.CallWith(ctx, method, args...)
|
||||
|
||||
// Return error as-is (including "not defined" errors)
|
||||
return result, err
|
||||
}
|
||||
|
||||
// LoadScripts loads all scripts from a src directory path
|
||||
// It scans for .ts and .js files (excluding index.ts which is the hook script)
|
||||
// Returns the HookScript and a map of other scripts
|
||||
func LoadScripts(srcDir string) (*hook.Script, map[string]*Script, error) {
|
||||
// Check if src directory exists
|
||||
exists, err := application.App.Exists(srcDir)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !exists {
|
||||
return nil, nil, nil // No src directory
|
||||
}
|
||||
|
||||
var hookScript *hook.Script
|
||||
scripts := make(map[string]*Script)
|
||||
var loadErr error
|
||||
|
||||
// Walk through src directory to find all script files
|
||||
exts := []string{"*.ts", "*.js"}
|
||||
err = application.App.Walk(srcDir, func(root, file string, isdir bool) error {
|
||||
if isdir {
|
||||
return nil
|
||||
}
|
||||
|
||||
// file is the full path, root is srcDir
|
||||
// Get relative path for determining if it's index
|
||||
relPath := strings.TrimPrefix(file, root+"/")
|
||||
|
||||
// Skip test files (*_test.ts, *_test.js)
|
||||
if strings.HasSuffix(relPath, "_test.ts") || strings.HasSuffix(relPath, "_test.js") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if it's the root index.ts/js (hook script)
|
||||
// Only src/index.ts is the hook script, not src/foo/index.ts
|
||||
isRootIndex := relPath == "index.ts" || relPath == "index.js"
|
||||
|
||||
if isRootIndex {
|
||||
scriptsMutex.Lock()
|
||||
script, err := loadScriptFile(file)
|
||||
scriptsMutex.Unlock()
|
||||
if err != nil {
|
||||
loadErr = fmt.Errorf("failed to load hook script %s: %w", file, err)
|
||||
return loadErr
|
||||
}
|
||||
hookScript = script
|
||||
} else {
|
||||
// Generate script ID from relative path
|
||||
scriptID := generateScriptID(file, root)
|
||||
|
||||
// Load the script (v8.Load is not thread-safe)
|
||||
scriptsMutex.Lock()
|
||||
script, err := loadScriptV8(file)
|
||||
if err != nil {
|
||||
scriptsMutex.Unlock()
|
||||
loadErr = fmt.Errorf("failed to load script %s: %w", file, err)
|
||||
return loadErr
|
||||
}
|
||||
scripts[scriptID] = &Script{Script: script}
|
||||
scriptsMutex.Unlock()
|
||||
}
|
||||
|
||||
return nil
|
||||
}, exts...)
|
||||
|
||||
if loadErr != nil {
|
||||
return nil, nil, loadErr
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to walk src directory: %w", err)
|
||||
}
|
||||
|
||||
return hookScript, scripts, nil
|
||||
}
|
||||
|
||||
// generateScriptID generates a script ID from file path
|
||||
// Example: assistants/test/src/foo/bar/test.ts -> foo.bar.test
|
||||
func generateScriptID(filePath string, srcDir string) string {
|
||||
// Normalize path separators
|
||||
filePath = filepath.ToSlash(filePath)
|
||||
srcDir = filepath.ToSlash(srcDir)
|
||||
|
||||
// Remove src directory prefix
|
||||
relPath := strings.TrimPrefix(filePath, srcDir+"/")
|
||||
relPath = strings.TrimPrefix(relPath, "/")
|
||||
|
||||
// Remove file extension
|
||||
relPath = strings.TrimSuffix(relPath, filepath.Ext(relPath))
|
||||
|
||||
// Replace path separators with dots
|
||||
scriptID := strings.ReplaceAll(relPath, "/", ".")
|
||||
|
||||
return scriptID
|
||||
}
|
||||
|
||||
// loadScriptFile loads a hook script from file
|
||||
func loadScriptFile(file string) (*hook.Script, error) {
|
||||
id := makeScriptID(file, "")
|
||||
script, err := v8.Load(file, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &hook.Script{Script: script}, nil
|
||||
}
|
||||
|
||||
// loadScriptFromSource loads a script from source code
|
||||
// Uses MakeScriptInMemory which supports TypeScript syntax without file resolution
|
||||
func loadScriptFromSource(source string, file string) (*v8.Script, error) {
|
||||
script, err := v8.MakeScriptInMemory([]byte(source), file, 5*time.Second, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return script, nil
|
||||
}
|
||||
|
||||
// loadScriptV8 loads a v8.Script from file (used for non-hook scripts)
|
||||
func loadScriptV8(file string) (*v8.Script, error) {
|
||||
id := makeScriptID(file, "")
|
||||
script, err := v8.Load(file, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return script, nil
|
||||
}
|
||||
|
||||
// makeScriptID generates the script ID for v8.Load
|
||||
// Converts file path to a dot-separated ID
|
||||
// Example: assistants/tests/fullfields/src/index.ts -> assistants.tests.fullfields.src.index
|
||||
func makeScriptID(file string, root string) string {
|
||||
// Remove root prefix if provided
|
||||
id := file
|
||||
if root != "" {
|
||||
id = strings.TrimPrefix(file, root+"/")
|
||||
}
|
||||
|
||||
// Remove extension
|
||||
id = strings.TrimSuffix(id, filepath.Ext(id))
|
||||
|
||||
// Replace path separators with dots
|
||||
id = strings.ReplaceAll(id, "/", ".")
|
||||
id = strings.ReplaceAll(id, string(filepath.Separator), ".")
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
// LoadScriptsFromData loads scripts from data map
|
||||
// Handles script/scripts/source fields with priority: script > scripts > source > file system
|
||||
func LoadScriptsFromData(data map[string]interface{}, assistantID string) (*hook.Script, map[string]*Script, error) {
|
||||
// Priority 1: script field (hook script from string source)
|
||||
if data["script"] != nil {
|
||||
switch v := data["script"].(type) {
|
||||
case string:
|
||||
file := fmt.Sprintf("assistants/%s/src/index.ts", assistantID)
|
||||
script, err := loadScriptFromSource(v, file)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
hookScript := &hook.Script{Script: script}
|
||||
|
||||
// Load other scripts if provided
|
||||
scripts, err := loadScriptsField(data["scripts"])
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return hookScript, scripts, nil
|
||||
case *hook.Script:
|
||||
scripts, err := loadScriptsField(data["scripts"])
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return v, scripts, nil
|
||||
case *v8.Script:
|
||||
scripts, err := loadScriptsField(data["scripts"])
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &hook.Script{Script: v}, scripts, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: scripts field (map of scripts)
|
||||
if data["scripts"] != nil {
|
||||
// First extract index if present
|
||||
var hookScript *hook.Script
|
||||
if scriptsMap, ok := data["scripts"].(map[string]interface{}); ok {
|
||||
if indexSource, hasIndex := scriptsMap["index"]; hasIndex {
|
||||
switch v := indexSource.(type) {
|
||||
case string:
|
||||
file := fmt.Sprintf("assistants/%s/src/index.ts", assistantID)
|
||||
script, err := loadScriptFromSource(v, file)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
hookScript = &hook.Script{Script: script}
|
||||
case *Script:
|
||||
hookScript = &hook.Script{Script: v.Script}
|
||||
case *v8.Script:
|
||||
hookScript = &hook.Script{Script: v}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then load other scripts (loadScriptsField automatically filters out index)
|
||||
scripts, err := loadScriptsField(data["scripts"])
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return hookScript, scripts, nil
|
||||
}
|
||||
|
||||
// Priority 3: source field (legacy hook script from source)
|
||||
if source, ok := data["source"].(string); ok && source != "" {
|
||||
script, err := loadSource(source, assistantID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return script, nil, nil
|
||||
}
|
||||
|
||||
// Priority 4: file system (scan src directory)
|
||||
srcDir := fmt.Sprintf("assistants/%s/src", assistantID)
|
||||
return LoadScripts(srcDir)
|
||||
}
|
||||
|
||||
// loadScriptsField parses scripts field from data
|
||||
// Note: "index" is always filtered out as it's reserved for HookScript
|
||||
func loadScriptsField(scriptsData interface{}) (map[string]*Script, error) {
|
||||
if scriptsData == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
scripts := make(map[string]*Script)
|
||||
|
||||
switch v := scriptsData.(type) {
|
||||
case map[string]*Script:
|
||||
for id, script := range v {
|
||||
if id == "index" {
|
||||
continue // Skip index
|
||||
}
|
||||
scripts[id] = script
|
||||
}
|
||||
return scripts, nil
|
||||
case map[string]*v8.Script:
|
||||
for id, script := range v {
|
||||
if id == "index" {
|
||||
continue // Skip index
|
||||
}
|
||||
scripts[id] = &Script{Script: script}
|
||||
}
|
||||
return scripts, nil
|
||||
case map[string]interface{}:
|
||||
for id, item := range v {
|
||||
if id == "index" {
|
||||
continue // Skip index
|
||||
}
|
||||
switch s := item.(type) {
|
||||
case *Script:
|
||||
scripts[id] = s
|
||||
case *v8.Script:
|
||||
scripts[id] = &Script{Script: s}
|
||||
case string:
|
||||
// Load script from source code
|
||||
file := fmt.Sprintf("script_%s", id)
|
||||
script, err := loadScriptFromSource(s, file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load script %s: %w", id, err)
|
||||
}
|
||||
scripts[id] = &Script{Script: script}
|
||||
}
|
||||
}
|
||||
return scripts, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// RegisterScripts registers all scripts as process handlers
|
||||
// Handler naming: agents.<assistantID>.<scriptID>
|
||||
func (ast *Assistant) RegisterScripts() error {
|
||||
if len(ast.Scripts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
assistantID := ast.ID
|
||||
handlers := make(map[string]process.Handler)
|
||||
|
||||
for scriptID, script := range ast.Scripts {
|
||||
// Create handler for this script
|
||||
handlers[scriptID] = makeScriptHandler(script)
|
||||
}
|
||||
|
||||
// Register the handler group dynamically
|
||||
groupName := fmt.Sprintf("agents.%s", assistantID)
|
||||
process.RegisterDynamicGroup(groupName, handlers)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnregisterScripts unregisters all scripts from process handlers
|
||||
func (ast *Assistant) UnregisterScripts() error {
|
||||
if len(ast.Scripts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
assistantID := ast.ID
|
||||
|
||||
for scriptID := range ast.Scripts {
|
||||
handlerID := fmt.Sprintf("agents.%s.%s", strings.ToLower(assistantID), strings.ToLower(scriptID))
|
||||
delete(process.Handlers, handlerID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// makeScriptHandler creates a process handler for a script
|
||||
func makeScriptHandler(script *Script) process.Handler {
|
||||
return func(p *process.Process) interface{} {
|
||||
// Extract method name from process
|
||||
method := p.Method
|
||||
|
||||
// Get arguments from process
|
||||
args := p.Args
|
||||
|
||||
// Convert authorized info to map if available
|
||||
var authorized map[string]interface{}
|
||||
if p.Authorized != nil {
|
||||
authorized = p.Authorized.AuthorizedToMap()
|
||||
}
|
||||
|
||||
// Execute the script with authorized information
|
||||
result, err := script.ExecuteWithAuthorized(p.Context, method, authorized, args...)
|
||||
if err != nil {
|
||||
exception.New(err.Error(), 500).Throw()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
|
@ -1,210 +0,0 @@
|
|||
package assistant_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/yaoapp/gou/process"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
)
|
||||
|
||||
func TestScriptsProcessFlow(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
// Get the mcpload assistant
|
||||
assistantID := "tests.mcpload"
|
||||
ast, err := assistant.Get(assistantID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, ast, "Assistant should be loaded")
|
||||
|
||||
// Check that scripts were loaded
|
||||
assert.NotNil(t, ast.Scripts)
|
||||
assert.Greater(t, len(ast.Scripts), 0, "Should have loaded at least one script")
|
||||
|
||||
// Verify tools.ts was loaded
|
||||
toolsScript, hasTools := ast.Scripts["tools"]
|
||||
assert.True(t, hasTools, "Should have loaded tools script")
|
||||
assert.NotNil(t, toolsScript)
|
||||
|
||||
// Register scripts as process handlers
|
||||
err = ast.RegisterScripts()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test 1: Call Hello function
|
||||
t.Run("CallHelloFunction", func(t *testing.T) {
|
||||
handlerID := "agents.tests.mcpload.tools"
|
||||
handler, exists := process.Handlers[handlerID]
|
||||
assert.True(t, exists, "Handler should be registered")
|
||||
|
||||
p := &process.Process{
|
||||
ID: handlerID + ".Hello",
|
||||
Method: "Hello",
|
||||
Args: []interface{}{map[string]interface{}{"name": "Yao"}},
|
||||
Context: context.Background(),
|
||||
}
|
||||
|
||||
result := handler(p)
|
||||
assert.NotNil(t, result)
|
||||
|
||||
resultStr, ok := result.(string)
|
||||
assert.True(t, ok, "Result should be a string")
|
||||
assert.Contains(t, resultStr, "Hello, Yao")
|
||||
})
|
||||
|
||||
// Test 2: Call Ping function
|
||||
t.Run("CallPingFunction", func(t *testing.T) {
|
||||
handlerID := "agents.tests.mcpload.tools"
|
||||
handler, exists := process.Handlers[handlerID]
|
||||
assert.True(t, exists, "Handler should be registered")
|
||||
|
||||
p := &process.Process{
|
||||
ID: handlerID + ".Ping",
|
||||
Method: "Ping",
|
||||
Args: []interface{}{map[string]interface{}{"message": "test"}},
|
||||
Context: context.Background(),
|
||||
}
|
||||
|
||||
result := handler(p)
|
||||
assert.NotNil(t, result)
|
||||
|
||||
resultMap, ok := result.(map[string]interface{})
|
||||
assert.True(t, ok, "Result should be a map")
|
||||
assert.Equal(t, "test", resultMap["message"])
|
||||
assert.Contains(t, resultMap["echo"], "Pong")
|
||||
})
|
||||
|
||||
// Test 3: Call Calculate function
|
||||
t.Run("CallCalculateFunction", func(t *testing.T) {
|
||||
handlerID := "agents.tests.mcpload.tools"
|
||||
handler, exists := process.Handlers[handlerID]
|
||||
assert.True(t, exists, "Handler should be registered")
|
||||
|
||||
p := &process.Process{
|
||||
ID: handlerID + ".Calculate",
|
||||
Method: "Calculate",
|
||||
Args: []interface{}{map[string]interface{}{
|
||||
"operation": "add",
|
||||
"a": float64(10),
|
||||
"b": float64(5),
|
||||
}},
|
||||
Context: context.Background(),
|
||||
}
|
||||
|
||||
result := handler(p)
|
||||
assert.NotNil(t, result)
|
||||
|
||||
resultMap, ok := result.(map[string]interface{})
|
||||
assert.True(t, ok, "Result should be a map")
|
||||
assert.Equal(t, float64(15), resultMap["result"])
|
||||
})
|
||||
|
||||
// Test 4: Unregister scripts
|
||||
t.Run("UnregisterScripts", func(t *testing.T) {
|
||||
err := ast.UnregisterScripts()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify handlers are removed
|
||||
handlerID := "agents.tests.mcpload.tools"
|
||||
_, exists := process.Handlers[handlerID]
|
||||
assert.False(t, exists, "Handler should be unregistered")
|
||||
})
|
||||
}
|
||||
|
||||
func TestScriptsProcessUsing(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
// Get the mcpload assistant
|
||||
assistantID := "tests.mcpload"
|
||||
ast, err := assistant.Get(assistantID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, ast)
|
||||
|
||||
// Register scripts
|
||||
err = ast.RegisterScripts()
|
||||
assert.NoError(t, err)
|
||||
defer ast.UnregisterScripts()
|
||||
|
||||
// Test 1: Call Hello using process.New().Execute()
|
||||
t.Run("ProcessHello", func(t *testing.T) {
|
||||
proc := process.New("agents.tests.mcpload.tools.Hello", map[string]interface{}{
|
||||
"name": "Yao",
|
||||
})
|
||||
|
||||
err := proc.Execute()
|
||||
assert.NoError(t, err)
|
||||
|
||||
result := proc.Value()
|
||||
assert.NotNil(t, result)
|
||||
|
||||
resultStr, ok := result.(string)
|
||||
assert.True(t, ok, "Result should be a string")
|
||||
assert.Contains(t, resultStr, "Hello, Yao")
|
||||
})
|
||||
|
||||
// Test 2: Call Ping using process.New().Execute()
|
||||
t.Run("ProcessPing", func(t *testing.T) {
|
||||
proc := process.New("agents.tests.mcpload.tools.Ping", map[string]interface{}{
|
||||
"message": "test message",
|
||||
})
|
||||
|
||||
err := proc.Execute()
|
||||
assert.NoError(t, err)
|
||||
|
||||
result := proc.Value()
|
||||
assert.NotNil(t, result)
|
||||
|
||||
resultMap, ok := result.(map[string]interface{})
|
||||
assert.True(t, ok, "Result should be a map")
|
||||
assert.Equal(t, "test message", resultMap["message"])
|
||||
assert.Contains(t, resultMap["echo"], "Pong")
|
||||
})
|
||||
|
||||
// Test 3: Call Calculate using process.New().Execute()
|
||||
t.Run("ProcessCalculate", func(t *testing.T) {
|
||||
proc := process.New("agents.tests.mcpload.tools.Calculate", map[string]interface{}{
|
||||
"operation": "multiply",
|
||||
"a": float64(6),
|
||||
"b": float64(7),
|
||||
})
|
||||
|
||||
err := proc.Execute()
|
||||
assert.NoError(t, err)
|
||||
|
||||
result := proc.Value()
|
||||
assert.NotNil(t, result)
|
||||
|
||||
resultMap, ok := result.(map[string]interface{})
|
||||
assert.True(t, ok, "Result should be a map")
|
||||
assert.Equal(t, float64(42), resultMap["result"])
|
||||
assert.Equal(t, "multiply", resultMap["operation"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestScriptsProcessError(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
// Get the mcpload assistant
|
||||
assistantID := "tests.mcpload"
|
||||
ast, err := assistant.Get(assistantID)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, ast)
|
||||
|
||||
// Register scripts
|
||||
err = ast.RegisterScripts()
|
||||
assert.NoError(t, err)
|
||||
defer ast.UnregisterScripts()
|
||||
|
||||
// Test calling non-existent method
|
||||
t.Run("CallNonExistentMethod", func(t *testing.T) {
|
||||
proc := process.New("agents.tests.mcpload.tools.NonExistent")
|
||||
|
||||
err := proc.Execute()
|
||||
assert.NotNil(t, err, "Should return error when calling non-existent method")
|
||||
assert.Contains(t, err.Error(), "Exception|500")
|
||||
})
|
||||
}
|
||||
|
|
@ -1,307 +0,0 @@
|
|||
package assistant
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/gou/process"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/test"
|
||||
)
|
||||
|
||||
// TestLoadScripts tests loading scripts from file system
|
||||
// Note: These tests are commented out due to path format differences
|
||||
// The functionality is tested by existing integration tests in the codebase
|
||||
|
||||
func TestLoadScriptsFromData(t *testing.T) {
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
t.Run("LoadFromScriptField", func(t *testing.T) {
|
||||
// Use JavaScript instead of TypeScript to avoid compilation path issues
|
||||
data := map[string]interface{}{
|
||||
"script": `function Create(ctx) { return null; }`,
|
||||
}
|
||||
|
||||
// Need to provide a real assistant path for compilation
|
||||
data["path"] = "assistants/tests/mcpload"
|
||||
|
||||
hookScript, scripts, err := LoadScriptsFromData(data, "tests.mcpload")
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, hookScript, "HookScript should be loaded from script field")
|
||||
assert.Nil(t, scripts, "Scripts should be nil when only script field is provided")
|
||||
|
||||
t.Logf("✓ Successfully loaded from script field")
|
||||
})
|
||||
|
||||
t.Run("LoadFromScriptsField", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"scripts": map[string]interface{}{
|
||||
"tool1": `function tool1() { return "tool1"; }`,
|
||||
"tool2": `function tool2() { return "tool2"; }`,
|
||||
},
|
||||
}
|
||||
|
||||
hookScript, scripts, err := LoadScriptsFromData(data, "test.assistant")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, hookScript, "HookScript should be nil when no index in scripts")
|
||||
require.NotNil(t, scripts, "Scripts should be loaded")
|
||||
assert.Len(t, scripts, 2, "Should have 2 scripts")
|
||||
assert.Contains(t, scripts, "tool1")
|
||||
assert.Contains(t, scripts, "tool2")
|
||||
|
||||
t.Logf("✓ Successfully loaded from scripts field")
|
||||
})
|
||||
|
||||
t.Run("LoadFromScriptsFieldWithIndex", func(t *testing.T) {
|
||||
// Test that index is properly extracted and not present in Scripts map
|
||||
// Note: We skip actual script compilation here to avoid path issues
|
||||
data := map[string]interface{}{
|
||||
"scripts": map[string]interface{}{
|
||||
"tool1": `function tool1() { return "tool1"; }`,
|
||||
"tool2": `function tool2() { return "tool2"; }`,
|
||||
},
|
||||
}
|
||||
|
||||
hookScript, scripts, err := LoadScriptsFromData(data, "test.assistant")
|
||||
require.NoError(t, err)
|
||||
// Without index in scripts field, hookScript should be nil
|
||||
assert.Nil(t, hookScript, "HookScript should be nil when no index in scripts")
|
||||
require.NotNil(t, scripts, "Scripts should be loaded")
|
||||
assert.Len(t, scripts, 2, "Should have 2 scripts")
|
||||
assert.Contains(t, scripts, "tool1")
|
||||
assert.Contains(t, scripts, "tool2")
|
||||
assert.NotContains(t, scripts, "index", "index should never be in Scripts map")
|
||||
|
||||
t.Logf("✓ Successfully loaded from scripts field, index properly filtered")
|
||||
})
|
||||
|
||||
t.Run("LoadFromSourceField", func(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"source": `function Create(ctx) { return null; }`,
|
||||
}
|
||||
|
||||
hookScript, scripts, err := LoadScriptsFromData(data, "test.assistant")
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, hookScript, "HookScript should be loaded from source field")
|
||||
assert.Nil(t, scripts, "Scripts should be nil when only source field is provided")
|
||||
|
||||
t.Logf("✓ Successfully loaded from source field")
|
||||
})
|
||||
|
||||
t.Run("PriorityOrder", func(t *testing.T) {
|
||||
// script field should take priority over scripts field
|
||||
data := map[string]interface{}{
|
||||
"script": `function Create1() { return null; }`,
|
||||
"scripts": map[string]interface{}{
|
||||
"tool1": `function tool1() { return "tool1"; }`,
|
||||
},
|
||||
"source": `function Create2() { return null; }`,
|
||||
"path": "assistants/tests/mcpload",
|
||||
}
|
||||
|
||||
hookScript, scripts, err := LoadScriptsFromData(data, "tests.mcpload")
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, hookScript, "HookScript should be loaded")
|
||||
require.NotNil(t, scripts, "Scripts should be loaded")
|
||||
assert.Len(t, scripts, 1, "Should have 1 script from scripts field")
|
||||
|
||||
t.Logf("✓ Priority order works: script > scripts > source")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenerateScriptID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
filePath string
|
||||
srcDir string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Simple file",
|
||||
filePath: "assistants/test/src/tools.ts",
|
||||
srcDir: "assistants/test/src",
|
||||
expected: "tools",
|
||||
},
|
||||
{
|
||||
name: "Nested directory",
|
||||
filePath: "assistants/test/src/foo/bar/test.ts",
|
||||
srcDir: "assistants/test/src",
|
||||
expected: "foo.bar.test",
|
||||
},
|
||||
{
|
||||
name: "Single level nested",
|
||||
filePath: "assistants/test/src/utils/helper.js",
|
||||
srcDir: "assistants/test/src",
|
||||
expected: "utils.helper",
|
||||
},
|
||||
{
|
||||
name: "Deep nesting",
|
||||
filePath: "assistants/test/src/a/b/c/d/file.ts",
|
||||
srcDir: "assistants/test/src",
|
||||
expected: "a.b.c.d.file",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := generateScriptID(tt.filePath, tt.srcDir)
|
||||
assert.Equal(t, tt.expected, result, "Script ID should match expected value")
|
||||
t.Logf("✓ %s: %s → %s", tt.name, tt.filePath, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadScriptsThreadSafety tests concurrent script loading
|
||||
// Note: This test is commented out due to path format differences
|
||||
// Thread safety is ensured by the scriptsMutex in LoadScripts function
|
||||
|
||||
func TestExecuteWithAuthorized(t *testing.T) {
|
||||
test.Prepare(t, config.Conf)
|
||||
defer test.Clean()
|
||||
|
||||
t.Run("ExecuteWithAuthorizedInfo", func(t *testing.T) {
|
||||
// Create a script that returns the authorized info from __yao_data
|
||||
scriptSource := `
|
||||
function GetAuth() {
|
||||
if (typeof __yao_data !== 'undefined' && __yao_data.AUTHORIZED) {
|
||||
return __yao_data.AUTHORIZED;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
`
|
||||
|
||||
data := map[string]interface{}{
|
||||
"scripts": map[string]interface{}{
|
||||
"auth_test": scriptSource,
|
||||
},
|
||||
}
|
||||
|
||||
_, scripts, err := LoadScriptsFromData(data, "test.authorized")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, scripts)
|
||||
require.Contains(t, scripts, "auth_test")
|
||||
|
||||
script := scripts["auth_test"]
|
||||
|
||||
// Create authorized info
|
||||
authorized := map[string]interface{}{
|
||||
"user_id": "user123",
|
||||
"team_id": "team456",
|
||||
"scope": "read write",
|
||||
"constraints": map[string]interface{}{
|
||||
"team_only": true,
|
||||
},
|
||||
}
|
||||
|
||||
// Execute with authorized info
|
||||
ctx := context.Background()
|
||||
result, err := script.ExecuteWithAuthorized(ctx, "GetAuth", authorized)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// Verify the authorized info was passed correctly
|
||||
resultMap, ok := result.(map[string]interface{})
|
||||
require.True(t, ok, "Result should be a map")
|
||||
|
||||
assert.Equal(t, "user123", resultMap["user_id"])
|
||||
assert.Equal(t, "team456", resultMap["team_id"])
|
||||
assert.Equal(t, "read write", resultMap["scope"])
|
||||
|
||||
constraints, ok := resultMap["constraints"].(map[string]interface{})
|
||||
require.True(t, ok, "Constraints should be a map")
|
||||
assert.Equal(t, true, constraints["team_only"])
|
||||
|
||||
t.Logf("✓ Authorized info passed correctly to script")
|
||||
})
|
||||
|
||||
t.Run("ExecuteWithoutAuthorizedInfo", func(t *testing.T) {
|
||||
// Create a script that checks for authorized info
|
||||
scriptSource := `
|
||||
function CheckAuth() {
|
||||
if (typeof __yao_data !== 'undefined' && __yao_data.AUTHORIZED) {
|
||||
return { hasAuth: true, data: __yao_data.AUTHORIZED };
|
||||
}
|
||||
return { hasAuth: false };
|
||||
}
|
||||
`
|
||||
|
||||
data := map[string]interface{}{
|
||||
"scripts": map[string]interface{}{
|
||||
"no_auth_test": scriptSource,
|
||||
},
|
||||
}
|
||||
|
||||
_, scripts, err := LoadScriptsFromData(data, "test.noauth")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, scripts)
|
||||
require.Contains(t, scripts, "no_auth_test")
|
||||
|
||||
script := scripts["no_auth_test"]
|
||||
|
||||
// Execute without authorized info
|
||||
ctx := context.Background()
|
||||
result, err := script.Execute(ctx, "CheckAuth")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
resultMap, ok := result.(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, false, resultMap["hasAuth"])
|
||||
|
||||
t.Logf("✓ Script executed correctly without authorized info")
|
||||
})
|
||||
|
||||
t.Run("MakeScriptHandlerWithAuthorized", func(t *testing.T) {
|
||||
// Create a script that returns authorized user_id
|
||||
scriptSource := `
|
||||
function GetUserID() {
|
||||
if (typeof __yao_data !== 'undefined' && __yao_data.AUTHORIZED) {
|
||||
return __yao_data.AUTHORIZED.user_id || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
`
|
||||
|
||||
data := map[string]interface{}{
|
||||
"scripts": map[string]interface{}{
|
||||
"handler_test": scriptSource,
|
||||
},
|
||||
}
|
||||
|
||||
_, scripts, err := LoadScriptsFromData(data, "test.handler")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, scripts)
|
||||
require.Contains(t, scripts, "handler_test")
|
||||
|
||||
script := scripts["handler_test"]
|
||||
|
||||
// Create a process handler
|
||||
handler := makeScriptHandler(script)
|
||||
require.NotNil(t, handler)
|
||||
|
||||
// Create a mock process with authorized info
|
||||
ctx := context.Background()
|
||||
p := &process.Process{
|
||||
Method: "GetUserID",
|
||||
Args: []interface{}{},
|
||||
Context: ctx,
|
||||
Authorized: &process.AuthorizedInfo{
|
||||
UserID: "user999",
|
||||
TeamID: "team888",
|
||||
Scope: "admin",
|
||||
},
|
||||
}
|
||||
|
||||
// Execute the handler
|
||||
result := handler(p)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// Verify the result
|
||||
assert.Equal(t, "user999", result)
|
||||
|
||||
t.Logf("✓ Process handler correctly passed authorized info")
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,103 +0,0 @@
|
|||
package assistant
|
||||
|
||||
import (
|
||||
"github.com/yaoapp/gou/query/gou"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
)
|
||||
|
||||
// BuildDBAuthWheres builds where clauses for DB search based on authorization
|
||||
// This applies permission-based filtering to database queries
|
||||
// Returns gou.Where clauses to filter records by authorization scope
|
||||
func BuildDBAuthWheres(ctx *context.Context) []gou.Where {
|
||||
if ctx == nil || ctx.Authorized == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
authInfo := ctx.Authorized
|
||||
|
||||
// No constraints, no filter needed
|
||||
if !authInfo.Constraints.TeamOnly && !authInfo.Constraints.OwnerOnly {
|
||||
return nil
|
||||
}
|
||||
|
||||
var wheres []gou.Where
|
||||
|
||||
// Team only - User can access:
|
||||
// 1. Public records (public = true)
|
||||
// 2. Records in their team where:
|
||||
// - They created the record (__yao_created_by matches)
|
||||
// - OR the record is shared with team (share = "team")
|
||||
if authInfo.Constraints.TeamOnly && authInfo.TeamID != "" {
|
||||
wheres = append(wheres, gou.Where{
|
||||
Wheres: []gou.Where{
|
||||
// Public records
|
||||
{Condition: gou.Condition{
|
||||
Field: &gou.Expression{Field: "public"},
|
||||
Value: true,
|
||||
OP: "=",
|
||||
OR: true,
|
||||
}},
|
||||
// Team records
|
||||
{
|
||||
Wheres: []gou.Where{
|
||||
{Condition: gou.Condition{
|
||||
Field: &gou.Expression{Field: "__yao_team_id"},
|
||||
Value: authInfo.TeamID,
|
||||
OP: "=",
|
||||
}},
|
||||
{Wheres: []gou.Where{
|
||||
{Condition: gou.Condition{
|
||||
Field: &gou.Expression{Field: "__yao_created_by"},
|
||||
Value: authInfo.UserID,
|
||||
OP: "=",
|
||||
}},
|
||||
{Condition: gou.Condition{
|
||||
Field: &gou.Expression{Field: "share"},
|
||||
Value: "team",
|
||||
OP: "=",
|
||||
OR: true,
|
||||
}},
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return wheres
|
||||
}
|
||||
|
||||
// Owner only - User can access:
|
||||
// 1. Public records (public = true)
|
||||
// 2. Records they created where:
|
||||
// - __yao_team_id is null (not team records)
|
||||
// - __yao_created_by matches their user ID
|
||||
if authInfo.Constraints.OwnerOnly && authInfo.UserID != "" {
|
||||
wheres = append(wheres, gou.Where{
|
||||
Wheres: []gou.Where{
|
||||
// Public records
|
||||
{Condition: gou.Condition{
|
||||
Field: &gou.Expression{Field: "public"},
|
||||
Value: true,
|
||||
OP: "=",
|
||||
OR: true,
|
||||
}},
|
||||
// Owner records
|
||||
{
|
||||
Wheres: []gou.Where{
|
||||
{Condition: gou.Condition{
|
||||
Field: &gou.Expression{Field: "__yao_team_id"},
|
||||
OP: "null",
|
||||
}},
|
||||
{Condition: gou.Condition{
|
||||
Field: &gou.Expression{Field: "__yao_created_by"},
|
||||
Value: authInfo.UserID,
|
||||
OP: "=",
|
||||
}},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return wheres
|
||||
}
|
||||
|
||||
return wheres
|
||||
}
|
||||
|
|
@ -1,529 +0,0 @@
|
|||
package assistant_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
graphragtypes "github.com/yaoapp/gou/graphrag/types"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
agentContext "github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/search"
|
||||
searchTypes "github.com/yaoapp/yao/agent/search/types"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
"github.com/yaoapp/yao/kb"
|
||||
"github.com/yaoapp/yao/kb/api"
|
||||
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
// ========== Test Constants ==========
|
||||
|
||||
const (
|
||||
// Test users and teams
|
||||
TestUserA = "user_a"
|
||||
TestUserB = "user_b"
|
||||
TestTeam1 = "team_1"
|
||||
TestTeam2 = "team_2"
|
||||
)
|
||||
|
||||
// authTestCollections holds dynamically generated collection IDs for a test run
|
||||
type authTestCollections struct {
|
||||
Team1 string
|
||||
Team2 string
|
||||
Public string
|
||||
}
|
||||
|
||||
// newAuthTestCollections creates unique collection IDs for a test run
|
||||
func newAuthTestCollections() *authTestCollections {
|
||||
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
return &authTestCollections{
|
||||
Team1: fmt.Sprintf("auth_test_team1_%s", suffix),
|
||||
Team2: fmt.Sprintf("auth_test_team2_%s", suffix),
|
||||
Public: fmt.Sprintf("auth_test_public_%s", suffix),
|
||||
}
|
||||
}
|
||||
|
||||
// cleanup removes all test collections
|
||||
func (c *authTestCollections) cleanup(ctx context.Context, t *testing.T) {
|
||||
collections := []string{c.Team1, c.Team2, c.Public}
|
||||
for _, id := range collections {
|
||||
if result, err := kb.API.RemoveCollection(ctx, id); err == nil && result.Removed {
|
||||
t.Logf(" Removed: %s", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== KB Collection-Level Auth Filter Tests ==========
|
||||
|
||||
// Note: KB permission filtering works at the Collection level.
|
||||
// The Collection metadata contains __yao_team_id, __yao_created_by, public, share fields.
|
||||
// FilterKBCollectionsByAuth filters collections based on user authorization.
|
||||
|
||||
func TestKBCollectionAuthFilter(t *testing.T) {
|
||||
t.Skip("KB/DB search temporarily disabled")
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
if kb.API == nil {
|
||||
t.Fatal("KB API not initialized")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
cols := newAuthTestCollections()
|
||||
defer cols.cleanup(ctx, t)
|
||||
|
||||
// Create test collections
|
||||
t.Log("Creating test collections...")
|
||||
createAuthCollection(ctx, t, cols.Team1, TestUserA, TestTeam1, false, "team")
|
||||
createAuthCollection(ctx, t, cols.Team2, TestUserB, TestTeam2, false, "team")
|
||||
createAuthCollection(ctx, t, cols.Public, TestUserA, TestTeam1, true, "")
|
||||
|
||||
t.Run("TeamMemberCanAccessTeamCollection", func(t *testing.T) {
|
||||
// UserA from Team1 should access Team1 collection
|
||||
authCtx := createAuthContext(TestUserA, TestTeam1, true, false)
|
||||
collections := []string{cols.Team1, cols.Team2}
|
||||
|
||||
allowed := assistant.FilterKBCollectionsByAuth(authCtx, collections)
|
||||
assert.Contains(t, allowed, cols.Team1, "Team1 member should access Team1 collection")
|
||||
t.Logf(" Allowed collections: %v", allowed)
|
||||
})
|
||||
|
||||
t.Run("TeamMemberCannotAccessOtherTeamCollection", func(t *testing.T) {
|
||||
// UserA from Team1 should NOT access Team2 collection
|
||||
authCtx := createAuthContext(TestUserA, TestTeam1, true, false)
|
||||
collections := []string{cols.Team2}
|
||||
|
||||
allowed := assistant.FilterKBCollectionsByAuth(authCtx, collections)
|
||||
assert.NotContains(t, allowed, cols.Team2, "Team1 member should NOT access Team2 collection")
|
||||
t.Logf(" Allowed collections: %v (expected empty)", allowed)
|
||||
})
|
||||
|
||||
t.Run("OwnerCanAccessOwnCollection", func(t *testing.T) {
|
||||
// UserA with OwnerOnly should access collections they created
|
||||
authCtx := createAuthContext(TestUserA, "", false, true)
|
||||
collections := []string{cols.Team1, cols.Team2}
|
||||
|
||||
allowed := assistant.FilterKBCollectionsByAuth(authCtx, collections)
|
||||
assert.Contains(t, allowed, cols.Team1, "Owner should access own collection")
|
||||
assert.NotContains(t, allowed, cols.Team2, "Owner should NOT access other's collection")
|
||||
t.Logf(" Allowed collections: %v", allowed)
|
||||
})
|
||||
|
||||
t.Run("PublicCollectionAccessibleToAll", func(t *testing.T) {
|
||||
// Note: The 'public' field in Metadata is not automatically saved to the database
|
||||
// by the current KB API. This test documents the expected behavior.
|
||||
// When public=true is properly set in DB, this should pass.
|
||||
|
||||
// First, check the collection metadata
|
||||
collection, err := kb.API.GetCollection(ctx, cols.Public)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Check if public is set correctly
|
||||
publicVal := collection["public"]
|
||||
t.Logf(" Public collection public field: %v (type: %T)", publicVal, publicVal)
|
||||
|
||||
// If public is not set (0 or false), the test documents current behavior
|
||||
// The collection should be accessible via owner check since UserA created it
|
||||
authCtx := createAuthContext(TestUserA, TestTeam1, false, true) // Owner check
|
||||
collections := []string{cols.Public}
|
||||
|
||||
allowed := assistant.FilterKBCollectionsByAuth(authCtx, collections)
|
||||
assert.Contains(t, allowed, cols.Public, "Owner should access their collection")
|
||||
t.Logf(" Allowed collections (owner check): %v", allowed)
|
||||
})
|
||||
|
||||
t.Run("NoConstraintsMeansFullAccess", func(t *testing.T) {
|
||||
// User with no constraints should access all collections
|
||||
authCtx := createAuthContext(TestUserA, TestTeam1, false, false)
|
||||
collections := []string{cols.Team1, cols.Team2, cols.Public}
|
||||
|
||||
allowed := assistant.FilterKBCollectionsByAuth(authCtx, collections)
|
||||
assert.Len(t, allowed, 3, "No constraints should allow all collections")
|
||||
t.Logf(" Allowed collections: %v", allowed)
|
||||
})
|
||||
|
||||
t.Run("NilContextMeansFullAccess", func(t *testing.T) {
|
||||
collections := []string{cols.Team1, cols.Team2}
|
||||
|
||||
allowed := assistant.FilterKBCollectionsByAuth(nil, collections)
|
||||
assert.Len(t, allowed, 2, "Nil context should allow all collections")
|
||||
t.Logf(" Allowed collections: %v", allowed)
|
||||
})
|
||||
}
|
||||
|
||||
// ========== DB Auth Wheres Tests ==========
|
||||
|
||||
func TestDBAuthWheresFilter(t *testing.T) {
|
||||
t.Skip("KB/DB search temporarily disabled")
|
||||
// Note: This test doesn't need KB, just tests the BuildDBAuthWheres function
|
||||
t.Run("TeamOnlyGeneratesCorrectWheres", func(t *testing.T) {
|
||||
ctx := createAuthContext(TestUserA, TestTeam1, true, false)
|
||||
wheres := assistant.BuildDBAuthWheres(ctx)
|
||||
|
||||
assert.NotNil(t, wheres)
|
||||
assert.Len(t, wheres, 1)
|
||||
|
||||
// Verify structure: should have 2 top-level conditions (public OR team filter)
|
||||
where := wheres[0]
|
||||
assert.Len(t, where.Wheres, 2, "Should have 2 conditions: public OR team")
|
||||
|
||||
// First condition: public = true (OR)
|
||||
publicCond := where.Wheres[0]
|
||||
assert.NotNil(t, publicCond.Condition.Field)
|
||||
assert.Equal(t, "public", publicCond.Condition.Field.Field)
|
||||
assert.Equal(t, true, publicCond.Condition.Value)
|
||||
assert.True(t, publicCond.Condition.OR)
|
||||
|
||||
// Second condition: team filter with nested conditions
|
||||
teamCond := where.Wheres[1]
|
||||
assert.Len(t, teamCond.Wheres, 2, "Team filter should have team_id and (created_by OR share)")
|
||||
|
||||
// Team ID check
|
||||
teamIDCond := teamCond.Wheres[0]
|
||||
assert.Equal(t, "__yao_team_id", teamIDCond.Condition.Field.Field)
|
||||
assert.Equal(t, TestTeam1, teamIDCond.Condition.Value)
|
||||
|
||||
// Created by OR share = team
|
||||
ownerOrShareCond := teamCond.Wheres[1]
|
||||
assert.Len(t, ownerOrShareCond.Wheres, 2)
|
||||
assert.Equal(t, "__yao_created_by", ownerOrShareCond.Wheres[0].Condition.Field.Field)
|
||||
assert.Equal(t, TestUserA, ownerOrShareCond.Wheres[0].Condition.Value)
|
||||
assert.Equal(t, "share", ownerOrShareCond.Wheres[1].Condition.Field.Field)
|
||||
assert.Equal(t, "team", ownerOrShareCond.Wheres[1].Condition.Value)
|
||||
assert.True(t, ownerOrShareCond.Wheres[1].Condition.OR)
|
||||
|
||||
t.Logf(" TeamOnly: Verified team_id=%s, created_by=%s", TestTeam1, TestUserA)
|
||||
})
|
||||
|
||||
t.Run("OwnerOnlyGeneratesCorrectWheres", func(t *testing.T) {
|
||||
ctx := createAuthContext(TestUserA, "", false, true)
|
||||
wheres := assistant.BuildDBAuthWheres(ctx)
|
||||
|
||||
assert.NotNil(t, wheres)
|
||||
assert.Len(t, wheres, 1)
|
||||
|
||||
// Verify structure: should have 2 top-level conditions (public OR owner filter)
|
||||
where := wheres[0]
|
||||
assert.Len(t, where.Wheres, 2, "Should have 2 conditions: public OR owner")
|
||||
|
||||
// First condition: public = true (OR)
|
||||
publicCond := where.Wheres[0]
|
||||
assert.NotNil(t, publicCond.Condition.Field)
|
||||
assert.Equal(t, "public", publicCond.Condition.Field.Field)
|
||||
assert.Equal(t, true, publicCond.Condition.Value)
|
||||
assert.True(t, publicCond.Condition.OR)
|
||||
|
||||
// Second condition: owner filter with nested conditions
|
||||
ownerCond := where.Wheres[1]
|
||||
assert.Len(t, ownerCond.Wheres, 2, "Owner filter should have team_id IS NULL and created_by")
|
||||
|
||||
// Team ID is null check
|
||||
teamNullCond := ownerCond.Wheres[0]
|
||||
assert.Equal(t, "__yao_team_id", teamNullCond.Condition.Field.Field)
|
||||
assert.Equal(t, "null", teamNullCond.Condition.OP)
|
||||
|
||||
// Created by check
|
||||
createdByCond := ownerCond.Wheres[1]
|
||||
assert.Equal(t, "__yao_created_by", createdByCond.Condition.Field.Field)
|
||||
assert.Equal(t, TestUserA, createdByCond.Condition.Value)
|
||||
|
||||
t.Logf(" OwnerOnly: Verified created_by=%s, team_id IS NULL", TestUserA)
|
||||
})
|
||||
|
||||
t.Run("NoConstraintsReturnsNil", func(t *testing.T) {
|
||||
ctx := createAuthContext(TestUserA, TestTeam1, false, false)
|
||||
wheres := assistant.BuildDBAuthWheres(ctx)
|
||||
|
||||
assert.Nil(t, wheres, "No constraints should return nil")
|
||||
t.Log(" No constraints: nil wheres (no filter)")
|
||||
})
|
||||
|
||||
t.Run("EmptyTeamIDReturnsNil", func(t *testing.T) {
|
||||
ctx := createAuthContext(TestUserA, "", true, false)
|
||||
wheres := assistant.BuildDBAuthWheres(ctx)
|
||||
|
||||
assert.Nil(t, wheres, "Empty TeamID with TeamOnly should return nil")
|
||||
t.Log(" Empty TeamID with TeamOnly: nil wheres")
|
||||
})
|
||||
|
||||
t.Run("EmptyUserIDReturnsNil", func(t *testing.T) {
|
||||
ctx := createAuthContext("", TestTeam1, false, true)
|
||||
wheres := assistant.BuildDBAuthWheres(ctx)
|
||||
|
||||
assert.Nil(t, wheres, "Empty UserID with OwnerOnly should return nil")
|
||||
t.Log(" Empty UserID with OwnerOnly: nil wheres")
|
||||
})
|
||||
|
||||
t.Run("NilContextReturnsNil", func(t *testing.T) {
|
||||
wheres := assistant.BuildDBAuthWheres(nil)
|
||||
|
||||
assert.Nil(t, wheres, "Nil context should return nil")
|
||||
t.Log(" Nil context: nil wheres")
|
||||
})
|
||||
|
||||
t.Run("NilAuthorizedReturnsNil", func(t *testing.T) {
|
||||
ctx := agentContext.New(context.Background(), nil, "test-chat")
|
||||
wheres := assistant.BuildDBAuthWheres(ctx)
|
||||
|
||||
assert.Nil(t, wheres, "Nil Authorized should return nil")
|
||||
t.Log(" Nil Authorized: nil wheres")
|
||||
})
|
||||
}
|
||||
|
||||
// ========== KB Search Integration Tests ==========
|
||||
|
||||
func TestKBSearchIntegration(t *testing.T) {
|
||||
t.Skip("KB/DB search temporarily disabled")
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
if kb.API == nil {
|
||||
t.Fatal("KB API not initialized")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
cols := newAuthTestCollections()
|
||||
defer cols.cleanup(ctx, t)
|
||||
|
||||
// Create test collections with documents
|
||||
t.Log("Creating test collections with documents...")
|
||||
createAuthCollection(ctx, t, cols.Team1, TestUserA, TestTeam1, false, "team")
|
||||
addAuthDocument(ctx, t, cols.Team1, "Team1 Doc1", "Team1 private document about quantum physics and relativity theory.")
|
||||
addAuthDocument(ctx, t, cols.Team1, "Team1 Doc2", "Team1 shared document about machine learning and neural networks.")
|
||||
|
||||
createAuthCollection(ctx, t, cols.Team2, TestUserB, TestTeam2, false, "team")
|
||||
addAuthDocument(ctx, t, cols.Team2, "Team2 Doc1", "Team2 private document about deep learning algorithms.")
|
||||
addAuthDocument(ctx, t, cols.Team2, "Team2 Doc2", "Team2 shared document about computer vision techniques.")
|
||||
|
||||
createAuthCollection(ctx, t, cols.Public, TestUserA, TestTeam1, true, "")
|
||||
addAuthDocument(ctx, t, cols.Public, "Public Doc1", "Public document about artificial intelligence and robotics.")
|
||||
addAuthDocument(ctx, t, cols.Public, "Public Doc2", "Public document about natural language processing.")
|
||||
|
||||
// Wait for indexing
|
||||
t.Log("Waiting for indexing...")
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
t.Run("TeamMemberSearchOnlyFindsTeamData", func(t *testing.T) {
|
||||
// UserA from Team1 searches - should ONLY find Team1 data
|
||||
authCtx := createAuthContext(TestUserA, TestTeam1, true, false)
|
||||
|
||||
// Filter collections first
|
||||
allCollections := []string{cols.Team1, cols.Team2}
|
||||
allowed := assistant.FilterKBCollectionsByAuth(authCtx, allCollections)
|
||||
|
||||
// Should only allow Team1
|
||||
assert.Contains(t, allowed, cols.Team1)
|
||||
assert.NotContains(t, allowed, cols.Team2)
|
||||
assert.Len(t, allowed, 1, "Should only have 1 allowed collection")
|
||||
|
||||
// Search on allowed collections
|
||||
result := executeKBSearchOnCollections(t, allowed, "quantum physics deep learning")
|
||||
assert.Greater(t, len(result.Items), 0, "Should find Team1 documents")
|
||||
|
||||
// Verify ALL results are from Team1 collection only
|
||||
for _, item := range result.Items {
|
||||
assert.Equal(t, cols.Team1, item.Collection,
|
||||
"All results should be from Team1 collection, got: %s", item.Collection)
|
||||
}
|
||||
t.Logf(" ✓ Team1 member found %d items, all from Team1 collection", len(result.Items))
|
||||
})
|
||||
|
||||
t.Run("TeamMemberCannotAccessOtherTeamData", func(t *testing.T) {
|
||||
// UserA from Team1 tries to access Team2 - should be blocked
|
||||
authCtx := createAuthContext(TestUserA, TestTeam1, true, false)
|
||||
|
||||
// Try to filter Team2 collection
|
||||
collections := []string{cols.Team2}
|
||||
allowed := assistant.FilterKBCollectionsByAuth(authCtx, collections)
|
||||
|
||||
// Should be empty - no access
|
||||
assert.Empty(t, allowed, "Team1 member should NOT have access to Team2 collection")
|
||||
t.Log(" ✓ Team1 member correctly blocked from Team2 collection")
|
||||
})
|
||||
|
||||
t.Run("OwnerSearchOnlyFindsOwnData", func(t *testing.T) {
|
||||
// UserA with OwnerOnly - should only find collections they created
|
||||
authCtx := createAuthContext(TestUserA, "", false, true)
|
||||
|
||||
// Filter all collections
|
||||
allCollections := []string{cols.Team1, cols.Team2, cols.Public}
|
||||
allowed := assistant.FilterKBCollectionsByAuth(authCtx, allCollections)
|
||||
|
||||
// UserA created Team1 and Public, not Team2
|
||||
assert.Contains(t, allowed, cols.Team1, "Owner should access Team1 (created by UserA)")
|
||||
assert.Contains(t, allowed, cols.Public, "Owner should access Public (created by UserA)")
|
||||
assert.NotContains(t, allowed, cols.Team2, "Owner should NOT access Team2 (created by UserB)")
|
||||
|
||||
// Search and verify results
|
||||
result := executeKBSearchOnCollections(t, allowed, "quantum artificial intelligence")
|
||||
assert.Greater(t, len(result.Items), 0, "Should find owner's documents")
|
||||
|
||||
// Verify NO results from Team2
|
||||
for _, item := range result.Items {
|
||||
assert.NotEqual(t, cols.Team2, item.Collection,
|
||||
"Should NOT have results from Team2, got: %s", item.Collection)
|
||||
}
|
||||
t.Logf(" ✓ Owner found %d items, none from Team2", len(result.Items))
|
||||
})
|
||||
|
||||
t.Run("NoConstraintsSearchFindsAllData", func(t *testing.T) {
|
||||
// User with no constraints - should find all data
|
||||
authCtx := createAuthContext(TestUserA, TestTeam1, false, false)
|
||||
|
||||
// Filter all collections
|
||||
allCollections := []string{cols.Team1, cols.Team2, cols.Public}
|
||||
allowed := assistant.FilterKBCollectionsByAuth(authCtx, allCollections)
|
||||
|
||||
// Should have access to all
|
||||
assert.Len(t, allowed, 3, "No constraints should allow all collections")
|
||||
|
||||
// Search and verify results from multiple collections
|
||||
result := executeKBSearchOnCollections(t, allowed, "quantum deep learning artificial")
|
||||
|
||||
// Should find results from multiple collections
|
||||
collectionsFound := make(map[string]bool)
|
||||
for _, item := range result.Items {
|
||||
collectionsFound[item.Collection] = true
|
||||
}
|
||||
assert.Greater(t, len(collectionsFound), 1, "Should find results from multiple collections")
|
||||
t.Logf(" ✓ No constraints: found %d items from %d collections", len(result.Items), len(collectionsFound))
|
||||
})
|
||||
|
||||
t.Run("SearchResultsMatchCollectionFilter", func(t *testing.T) {
|
||||
// Verify that search results ONLY come from allowed collections
|
||||
authCtx := createAuthContext(TestUserB, TestTeam2, true, false)
|
||||
|
||||
// UserB from Team2 - should only access Team2
|
||||
allCollections := []string{cols.Team1, cols.Team2, cols.Public}
|
||||
allowed := assistant.FilterKBCollectionsByAuth(authCtx, allCollections)
|
||||
|
||||
assert.Contains(t, allowed, cols.Team2, "Team2 member should access Team2")
|
||||
assert.NotContains(t, allowed, cols.Team1, "Team2 member should NOT access Team1")
|
||||
|
||||
// Search
|
||||
result := executeKBSearchOnCollections(t, allowed, "deep learning computer vision")
|
||||
|
||||
// Verify results
|
||||
if len(result.Items) > 0 {
|
||||
for _, item := range result.Items {
|
||||
// Results should only be from allowed collections
|
||||
assert.Contains(t, allowed, item.Collection,
|
||||
"Result from %s should be in allowed list %v", item.Collection, allowed)
|
||||
}
|
||||
t.Logf(" ✓ Team2 member found %d items, all from allowed collections", len(result.Items))
|
||||
} else {
|
||||
t.Log(" ✓ Team2 member found 0 items (collection may be empty)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
func createAuthContext(userID, teamID string, teamOnly, ownerOnly bool) *agentContext.Context {
|
||||
authorized := &oauthtypes.AuthorizedInfo{
|
||||
UserID: userID,
|
||||
TeamID: teamID,
|
||||
Constraints: oauthtypes.DataConstraints{
|
||||
TeamOnly: teamOnly,
|
||||
OwnerOnly: ownerOnly,
|
||||
},
|
||||
}
|
||||
return agentContext.New(context.Background(), authorized, "test-chat")
|
||||
}
|
||||
|
||||
func createAuthCollection(ctx context.Context, t *testing.T, id, userID, teamID string, public bool, share string) {
|
||||
params := &api.CreateCollectionParams{
|
||||
ID: id,
|
||||
Metadata: map[string]interface{}{
|
||||
"name": id,
|
||||
"public": public,
|
||||
"share": share,
|
||||
},
|
||||
EmbeddingProviderID: "__yao.openai",
|
||||
EmbeddingOptionID: "text-embedding-3-small",
|
||||
Locale: "en",
|
||||
Config: &graphragtypes.CreateCollectionOptions{
|
||||
Distance: "cosine",
|
||||
IndexType: "hnsw",
|
||||
},
|
||||
AuthScope: map[string]interface{}{
|
||||
"__yao_created_by": userID,
|
||||
"__yao_team_id": teamID,
|
||||
},
|
||||
}
|
||||
|
||||
_, err := kb.API.CreateCollection(ctx, params)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create collection %s: %v", id, err)
|
||||
}
|
||||
t.Logf(" ✓ Created: %s", id)
|
||||
}
|
||||
|
||||
func addAuthDocument(ctx context.Context, t *testing.T, collectionID, title, content string) {
|
||||
params := &api.AddTextParams{
|
||||
CollectionID: collectionID,
|
||||
Text: content,
|
||||
DocID: fmt.Sprintf("%s__%s", collectionID, sanitizeForID(title)),
|
||||
Metadata: map[string]interface{}{
|
||||
"title": title,
|
||||
},
|
||||
Chunking: &api.ProviderConfigParams{
|
||||
ProviderID: "__yao.structured",
|
||||
OptionID: "standard",
|
||||
},
|
||||
Embedding: &api.ProviderConfigParams{
|
||||
ProviderID: "__yao.openai",
|
||||
OptionID: "text-embedding-3-small",
|
||||
},
|
||||
}
|
||||
|
||||
_, err := kb.API.AddText(ctx, params)
|
||||
if err != nil {
|
||||
t.Logf(" Warning: Failed to add document '%s': %v", title, err)
|
||||
return
|
||||
}
|
||||
t.Logf(" ✓ Added: %s", title)
|
||||
}
|
||||
|
||||
func sanitizeForID(s string) string {
|
||||
result := ""
|
||||
for _, c := range s {
|
||||
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') {
|
||||
result += string(c)
|
||||
} else if c == ' ' {
|
||||
result += "_"
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func executeKBSearchOnCollections(t *testing.T, collections []string, query string) *searchTypes.Result {
|
||||
if len(collections) == 0 {
|
||||
return &searchTypes.Result{Items: []*searchTypes.ResultItem{}}
|
||||
}
|
||||
|
||||
cfg := &searchTypes.Config{
|
||||
KB: &searchTypes.KBConfig{
|
||||
Collections: collections,
|
||||
Threshold: 0.3,
|
||||
},
|
||||
}
|
||||
searcher := search.New(cfg, nil)
|
||||
|
||||
req := &searchTypes.Request{
|
||||
Type: searchTypes.SearchTypeKB,
|
||||
Query: query,
|
||||
Collections: collections,
|
||||
Threshold: 0.3,
|
||||
Limit: 20,
|
||||
Source: searchTypes.SourceAuto,
|
||||
}
|
||||
|
||||
result, err := searcher.Search(nil, req)
|
||||
if err != nil {
|
||||
t.Fatalf("Search failed: %v", err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
package assistant
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
agentContext "github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/kb"
|
||||
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
// FilterKBCollectionsByAuth filters collections based on user authorization.
|
||||
// Returns only collections that the user has permission to access.
|
||||
// Permission is determined by Collection's metadata (public, share, __yao_team_id, __yao_created_by).
|
||||
func FilterKBCollectionsByAuth(ctx *agentContext.Context, collections []string) []string {
|
||||
if ctx == nil || ctx.Authorized == nil {
|
||||
return collections // No auth context, return all
|
||||
}
|
||||
|
||||
authInfo := ctx.Authorized
|
||||
|
||||
// No constraints, return all collections
|
||||
if !authInfo.Constraints.TeamOnly && !authInfo.Constraints.OwnerOnly {
|
||||
return collections
|
||||
}
|
||||
|
||||
// Check KB API
|
||||
if kb.API == nil {
|
||||
return collections // KB not initialized, return all
|
||||
}
|
||||
|
||||
var allowed []string
|
||||
bgCtx := context.Background()
|
||||
|
||||
for _, collectionID := range collections {
|
||||
// Get collection metadata
|
||||
collection, err := kb.API.GetCollection(bgCtx, collectionID)
|
||||
if err != nil {
|
||||
continue // Skip if can't get collection
|
||||
}
|
||||
|
||||
if hasCollectionAccess(authInfo, collection) {
|
||||
allowed = append(allowed, collectionID)
|
||||
}
|
||||
}
|
||||
|
||||
return allowed
|
||||
}
|
||||
|
||||
// hasCollectionAccess checks if user has access to a collection based on its metadata.
|
||||
func hasCollectionAccess(authInfo *oauthtypes.AuthorizedInfo, collection map[string]interface{}) bool {
|
||||
if authInfo == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
// No constraints, allow access
|
||||
if !authInfo.Constraints.TeamOnly && !authInfo.Constraints.OwnerOnly {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check public access (handle different types: bool, int, float64)
|
||||
if isPublicValue(collection["public"]) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Get metadata for permission fields
|
||||
metadata, _ := collection["metadata"].(map[string]interface{})
|
||||
if metadata == nil {
|
||||
metadata = collection
|
||||
}
|
||||
|
||||
// Team only check
|
||||
if authInfo.Constraints.TeamOnly && authInfo.TeamID != "" {
|
||||
teamID, _ := metadata["__yao_team_id"].(string)
|
||||
if teamID == "" {
|
||||
teamID, _ = collection["__yao_team_id"].(string)
|
||||
}
|
||||
|
||||
if teamID == authInfo.TeamID {
|
||||
createdBy, _ := metadata["__yao_created_by"].(string)
|
||||
if createdBy == "" {
|
||||
createdBy, _ = collection["__yao_created_by"].(string)
|
||||
}
|
||||
share, _ := metadata["share"].(string)
|
||||
if share == "" {
|
||||
share, _ = collection["share"].(string)
|
||||
}
|
||||
|
||||
if createdBy == authInfo.UserID || share == "team" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Owner only check
|
||||
if authInfo.Constraints.OwnerOnly && authInfo.UserID != "" {
|
||||
createdBy, _ := metadata["__yao_created_by"].(string)
|
||||
if createdBy == "" {
|
||||
createdBy, _ = collection["__yao_created_by"].(string)
|
||||
}
|
||||
if createdBy == authInfo.UserID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// isPublicValue checks if a value represents "public" access
|
||||
func isPublicValue(v interface{}) bool {
|
||||
switch val := v.(type) {
|
||||
case bool:
|
||||
return val
|
||||
case int:
|
||||
return val == 1
|
||||
case int64:
|
||||
return val == 1
|
||||
case float64:
|
||||
return val == 1
|
||||
case string:
|
||||
return val == "true" || val == "1"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
package assistant_test
|
||||
|
||||
import (
|
||||
stdContext "context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/output/message"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
// newSearchAutoDisabledTestContext creates a test context
|
||||
func newSearchAutoDisabledTestContext(chatID, assistantID string) *context.Context {
|
||||
authorized := &types.AuthorizedInfo{
|
||||
Subject: "test-user",
|
||||
UserID: "test-123",
|
||||
TenantID: "test-tenant",
|
||||
}
|
||||
|
||||
ctx := context.New(stdContext.Background(), authorized, chatID)
|
||||
ctx.ID = chatID
|
||||
ctx.AssistantID = assistantID
|
||||
ctx.Locale = "en-us"
|
||||
ctx.Client = context.Client{
|
||||
Type: "web",
|
||||
IP: "127.0.0.1",
|
||||
}
|
||||
ctx.Referer = context.RefererAPI
|
||||
ctx.Accept = context.AcceptWebCUI
|
||||
ctx.IDGenerator = message.NewIDGenerator()
|
||||
ctx.Metadata = make(map[string]interface{})
|
||||
return ctx
|
||||
}
|
||||
|
||||
func TestSearchAutoDisabled(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ast, err := assistant.LoadPath("/assistants/tests/search-auto-disabled")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
t.Run("ShouldHaveSearchConfig", func(t *testing.T) {
|
||||
// Search config is set but uses.search is disabled
|
||||
assert.NotNil(t, ast.Search, "search config should be set")
|
||||
assert.NotNil(t, ast.Search.Web, "web search config should be set")
|
||||
})
|
||||
|
||||
t.Run("ShouldHaveDisabledUses", func(t *testing.T) {
|
||||
assert.NotNil(t, ast.Uses, "uses config should be set")
|
||||
assert.Equal(t, "disabled", ast.Uses.Search, "uses.search should be disabled")
|
||||
})
|
||||
|
||||
t.Run("StreamShouldNotExecuteSearch", func(t *testing.T) {
|
||||
// Get agent via assistant.Get (required for Stream)
|
||||
agent, err := assistant.Get("tests.search-auto-disabled")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, agent)
|
||||
|
||||
// Create context
|
||||
ctx := newSearchAutoDisabledTestContext("test-search-auto-disabled", "tests.search-auto-disabled")
|
||||
|
||||
// Create messages
|
||||
messages := []context.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: "Hello, how are you?",
|
||||
},
|
||||
}
|
||||
|
||||
// Execute stream - should NOT trigger search because uses.search is "disabled"
|
||||
response, err := agent.Stream(ctx, messages)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, response)
|
||||
|
||||
assert.NotNil(t, response.Completion, "should have completion")
|
||||
t.Logf("✓ Stream executed without search (disabled)")
|
||||
})
|
||||
}
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
package assistant_test
|
||||
|
||||
import (
|
||||
stdContext "context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/output/message"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
// newSearchAutoFullTestContext creates a test context
|
||||
func newSearchAutoFullTestContext(chatID, assistantID string) *context.Context {
|
||||
authorized := &types.AuthorizedInfo{
|
||||
Subject: "test-user",
|
||||
UserID: "test-123",
|
||||
TenantID: "test-tenant",
|
||||
}
|
||||
|
||||
ctx := context.New(stdContext.Background(), authorized, chatID)
|
||||
ctx.ID = chatID
|
||||
ctx.AssistantID = assistantID
|
||||
ctx.Locale = "en-us"
|
||||
ctx.Client = context.Client{
|
||||
Type: "web",
|
||||
IP: "127.0.0.1",
|
||||
}
|
||||
ctx.Referer = context.RefererAPI
|
||||
ctx.Accept = context.AcceptWebCUI
|
||||
ctx.IDGenerator = message.NewIDGenerator()
|
||||
ctx.Metadata = make(map[string]interface{})
|
||||
return ctx
|
||||
}
|
||||
|
||||
func TestSearchAutoFull(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ast, err := assistant.LoadPath("/assistants/tests/search-auto-full")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
t.Run("ShouldHaveWebSearchConfig", func(t *testing.T) {
|
||||
assert.NotNil(t, ast.Search, "search config should be set")
|
||||
assert.NotNil(t, ast.Search.Web, "web search config should be set")
|
||||
assert.Equal(t, "tavily", ast.Search.Web.Provider)
|
||||
assert.Equal(t, 3, ast.Search.Web.MaxResults)
|
||||
})
|
||||
|
||||
// KB/DB search temporarily disabled
|
||||
t.Run("ShouldHaveKBSearchConfig", func(t *testing.T) {
|
||||
t.Skip("KB/DB search temporarily disabled")
|
||||
assert.NotNil(t, ast.Search.KB, "kb search config should be set")
|
||||
assert.Equal(t, 0.7, ast.Search.KB.Threshold)
|
||||
assert.False(t, ast.Search.KB.Graph)
|
||||
})
|
||||
|
||||
t.Run("ShouldHaveDBSearchConfig", func(t *testing.T) {
|
||||
t.Skip("KB/DB search temporarily disabled")
|
||||
assert.NotNil(t, ast.Search.DB, "db search config should be set")
|
||||
assert.Equal(t, 10, ast.Search.DB.MaxResults)
|
||||
})
|
||||
|
||||
t.Run("ShouldHaveKBCollections", func(t *testing.T) {
|
||||
t.Skip("KB/DB search temporarily disabled")
|
||||
assert.NotNil(t, ast.KB, "kb config should be set")
|
||||
assert.Contains(t, ast.KB.Collections, "test-collection")
|
||||
})
|
||||
|
||||
t.Run("ShouldHaveDBModels", func(t *testing.T) {
|
||||
t.Skip("KB/DB search temporarily disabled")
|
||||
assert.NotNil(t, ast.DB, "db config should be set")
|
||||
assert.Contains(t, ast.DB.Models, "user")
|
||||
assert.Contains(t, ast.DB.Models, "article")
|
||||
})
|
||||
|
||||
t.Run("ShouldHaveCitationConfig", func(t *testing.T) {
|
||||
assert.NotNil(t, ast.Search.Citation, "citation config should be set")
|
||||
assert.Equal(t, "xml", ast.Search.Citation.Format)
|
||||
assert.True(t, ast.Search.Citation.AutoInjectPrompt)
|
||||
})
|
||||
|
||||
t.Run("ShouldHaveUsesConfig", func(t *testing.T) {
|
||||
assert.NotNil(t, ast.Uses, "uses config should be set")
|
||||
assert.Equal(t, "builtin", ast.Uses.Search)
|
||||
assert.Equal(t, "builtin", ast.Uses.Web)
|
||||
})
|
||||
|
||||
t.Run("StreamShouldExecuteMultipleSearchTypes", func(t *testing.T) {
|
||||
t.Skip("KB/DB search temporarily disabled")
|
||||
// Get agent via assistant.Get (required for Stream)
|
||||
agent, err := assistant.Get("tests.search-auto-full")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, agent)
|
||||
|
||||
// Create context
|
||||
ctx := newSearchAutoFullTestContext("test-search-auto-full", "tests.search-auto-full")
|
||||
|
||||
// Create messages with a search query
|
||||
messages := []context.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: "Find information about machine learning",
|
||||
},
|
||||
}
|
||||
|
||||
// Execute stream - should trigger Web + KB + DB searches
|
||||
response, err := agent.Stream(ctx, messages)
|
||||
|
||||
// Assert no error (if API key is configured)
|
||||
if err != nil {
|
||||
// If error contains "API key", it's expected in CI without keys
|
||||
if strings.Contains(err.Error(), "API key") || strings.Contains(err.Error(), "api_key") {
|
||||
t.Logf("Expected error without API key: %v", err)
|
||||
return
|
||||
}
|
||||
// Other errors should fail
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
require.NotNil(t, response)
|
||||
assert.NotNil(t, response.Completion, "should have completion")
|
||||
t.Logf("✓ Stream executed with full search config (Web + KB + DB)")
|
||||
})
|
||||
}
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
package assistant_test
|
||||
|
||||
import (
|
||||
stdContext "context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/output/message"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
// newSearchAutoHookDisableTestContext creates a test context
|
||||
func newSearchAutoHookDisableTestContext(chatID, assistantID string) *context.Context {
|
||||
authorized := &types.AuthorizedInfo{
|
||||
Subject: "test-user",
|
||||
UserID: "test-123",
|
||||
TenantID: "test-tenant",
|
||||
}
|
||||
|
||||
ctx := context.New(stdContext.Background(), authorized, chatID)
|
||||
ctx.ID = chatID
|
||||
ctx.AssistantID = assistantID
|
||||
ctx.Locale = "en-us"
|
||||
ctx.Client = context.Client{
|
||||
Type: "web",
|
||||
IP: "127.0.0.1",
|
||||
}
|
||||
ctx.Referer = context.RefererAPI
|
||||
ctx.Accept = context.AcceptWebCUI
|
||||
ctx.IDGenerator = message.NewIDGenerator()
|
||||
ctx.Metadata = make(map[string]interface{})
|
||||
return ctx
|
||||
}
|
||||
|
||||
func TestSearchAutoHookDisable(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ast, err := assistant.LoadPath("/assistants/tests/search-auto-hook-disable")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
t.Run("ShouldHaveSearchConfigEnabled", func(t *testing.T) {
|
||||
// Search config is enabled in package.yao
|
||||
assert.NotNil(t, ast.Search, "search config should be set")
|
||||
assert.NotNil(t, ast.Uses, "uses config should be set")
|
||||
assert.Equal(t, "builtin", ast.Uses.Search, "uses.search should be builtin in config")
|
||||
})
|
||||
|
||||
t.Run("ShouldHaveHookScript", func(t *testing.T) {
|
||||
// Hook script should be loaded
|
||||
assert.NotNil(t, ast.HookScript, "hook script should be loaded")
|
||||
})
|
||||
|
||||
t.Run("HookShouldDisableSearch", func(t *testing.T) {
|
||||
// Create context
|
||||
ctx := newSearchAutoHookDisableTestContext("test-chat-id", "tests.search-auto-hook-disable")
|
||||
|
||||
// Create messages
|
||||
messages := []context.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: "Test message",
|
||||
},
|
||||
}
|
||||
|
||||
// Call Create hook directly
|
||||
opts := &context.Options{}
|
||||
response, _, err := ast.HookScript.Create(ctx, messages, opts)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, response)
|
||||
|
||||
// Verify hook returns uses.search = "disabled"
|
||||
assert.NotNil(t, response.Uses, "hook should return uses")
|
||||
assert.Equal(t, "disabled", response.Uses.Search, "hook should disable search")
|
||||
})
|
||||
|
||||
t.Run("StreamShouldRespectHookDisable", func(t *testing.T) {
|
||||
// Get agent via assistant.Get (required for Stream)
|
||||
agent, err := assistant.Get("tests.search-auto-hook-disable")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, agent)
|
||||
|
||||
// Create context
|
||||
ctx := newSearchAutoHookDisableTestContext("test-search-hook-disable", "tests.search-auto-hook-disable")
|
||||
|
||||
// Create messages
|
||||
messages := []context.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: "What is AI?",
|
||||
},
|
||||
}
|
||||
|
||||
// Execute stream - hook will disable search
|
||||
response, err := agent.Stream(ctx, messages)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, response)
|
||||
|
||||
assert.NotNil(t, response.Completion, "should have completion")
|
||||
t.Logf("✓ Stream executed with hook disabling search")
|
||||
})
|
||||
}
|
||||
|
|
@ -1,183 +0,0 @@
|
|||
package assistant_test
|
||||
|
||||
import (
|
||||
stdContext "context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/output/message"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
// newKeywordTestContext creates a test context for keyword extraction tests
|
||||
func newKeywordTestContext(chatID, assistantID string) *context.Context {
|
||||
authorized := &types.AuthorizedInfo{
|
||||
Subject: "test-user",
|
||||
UserID: "test-123",
|
||||
TenantID: "test-tenant",
|
||||
}
|
||||
|
||||
ctx := context.New(stdContext.Background(), authorized, chatID)
|
||||
ctx.ID = chatID
|
||||
ctx.AssistantID = assistantID
|
||||
ctx.Locale = "en-us"
|
||||
ctx.Client = context.Client{
|
||||
Type: "web",
|
||||
IP: "127.0.0.1",
|
||||
}
|
||||
ctx.Referer = context.RefererAPI
|
||||
ctx.Accept = context.AcceptWebCUI
|
||||
ctx.IDGenerator = message.NewIDGenerator()
|
||||
ctx.Metadata = make(map[string]interface{})
|
||||
return ctx
|
||||
}
|
||||
|
||||
func TestSearchAutoKeyword(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ast, err := assistant.LoadPath("/assistants/tests/search-auto-keyword")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
t.Run("ShouldHaveKeywordConfig", func(t *testing.T) {
|
||||
assert.NotNil(t, ast.Search, "search config should be set")
|
||||
assert.NotNil(t, ast.Search.Keyword, "keyword config should be set")
|
||||
assert.Equal(t, 5, ast.Search.Keyword.MaxKeywords)
|
||||
assert.Equal(t, "auto", ast.Search.Keyword.Language)
|
||||
})
|
||||
|
||||
t.Run("ShouldHaveKeywordInUses", func(t *testing.T) {
|
||||
assert.NotNil(t, ast.Uses, "uses config should be set")
|
||||
assert.Equal(t, "builtin", ast.Uses.Keyword)
|
||||
})
|
||||
|
||||
t.Run("StreamWithKeywordExtraction", func(t *testing.T) {
|
||||
// Get agent via assistant.Get (required for Stream)
|
||||
agent, err := assistant.Get("tests.search-auto-keyword")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, agent)
|
||||
|
||||
// Create context
|
||||
ctx := newKeywordTestContext("test-search-keyword", "tests.search-auto-keyword")
|
||||
|
||||
// Create messages with a verbose query that should benefit from keyword extraction
|
||||
messages := []context.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: "I want to find the best wireless headphones under 100 dollars for programming and music",
|
||||
},
|
||||
}
|
||||
|
||||
// Execute stream without Skip.Keyword (keyword extraction should happen)
|
||||
response, err := agent.Stream(ctx, messages)
|
||||
|
||||
// Assert no error (if API key is configured)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "API key") || strings.Contains(err.Error(), "api_key") {
|
||||
t.Logf("Expected error without API key: %v", err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
require.NotNil(t, response)
|
||||
assert.NotNil(t, response.Completion, "should have completion")
|
||||
t.Logf("✓ Stream with keyword extraction executed successfully")
|
||||
})
|
||||
|
||||
t.Run("StreamWithSkipKeyword", func(t *testing.T) {
|
||||
// Get agent via assistant.Get (required for Stream)
|
||||
agent, err := assistant.Get("tests.search-auto-keyword")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, agent)
|
||||
|
||||
// Create context
|
||||
ctx := newKeywordTestContext("test-search-skip-keyword", "tests.search-auto-keyword")
|
||||
|
||||
// Create messages
|
||||
messages := []context.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: "I want to find the best wireless headphones under 100 dollars",
|
||||
},
|
||||
}
|
||||
|
||||
// Execute stream with Skip.Keyword = true (keyword extraction should be skipped)
|
||||
opts := &context.Options{
|
||||
Skip: &context.Skip{
|
||||
Keyword: true,
|
||||
},
|
||||
}
|
||||
response, err := agent.Stream(ctx, messages, opts)
|
||||
|
||||
// Assert no error (if API key is configured)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "API key") || strings.Contains(err.Error(), "api_key") {
|
||||
t.Logf("Expected error without API key: %v", err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
require.NotNil(t, response)
|
||||
assert.NotNil(t, response.Completion, "should have completion")
|
||||
t.Logf("✓ Stream with Skip.Keyword executed successfully")
|
||||
})
|
||||
}
|
||||
|
||||
func TestSearchAutoKeywordNotConfigured(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
// Use the search-auto-web assistant which does NOT have uses.keyword configured
|
||||
ast, err := assistant.LoadPath("/assistants/tests/search-auto-web")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
t.Run("ShouldNotHaveKeywordInUses", func(t *testing.T) {
|
||||
// uses.keyword should be empty (not configured)
|
||||
if ast.Uses != nil {
|
||||
assert.Empty(t, ast.Uses.Keyword, "uses.keyword should be empty")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("StreamShouldSkipKeywordExtraction", func(t *testing.T) {
|
||||
// Get agent via assistant.Get (required for Stream)
|
||||
agent, err := assistant.Get("tests.search-auto-web")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, agent)
|
||||
|
||||
// Create context
|
||||
ctx := newKeywordTestContext("test-no-keyword", "tests.search-auto-web")
|
||||
|
||||
// Create messages
|
||||
messages := []context.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: "What is the latest news about AI?",
|
||||
},
|
||||
}
|
||||
|
||||
// Execute stream - keyword extraction should NOT happen because uses.keyword is not set
|
||||
response, err := agent.Stream(ctx, messages)
|
||||
|
||||
// Assert no error (if API key is configured)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "API key") || strings.Contains(err.Error(), "api_key") {
|
||||
t.Logf("Expected error without API key: %v", err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
require.NotNil(t, response)
|
||||
assert.NotNil(t, response.Completion, "should have completion")
|
||||
t.Logf("✓ Stream without keyword config executed successfully")
|
||||
})
|
||||
}
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
package assistant_test
|
||||
|
||||
import (
|
||||
stdContext "context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/output/message"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
// newSearchAutoTestContext creates a test context for search auto tests
|
||||
func newSearchAutoTestContext(chatID, assistantID string) *context.Context {
|
||||
authorized := &types.AuthorizedInfo{
|
||||
Subject: "test-user",
|
||||
UserID: "test-123",
|
||||
TenantID: "test-tenant",
|
||||
}
|
||||
|
||||
ctx := context.New(stdContext.Background(), authorized, chatID)
|
||||
ctx.ID = chatID
|
||||
ctx.AssistantID = assistantID
|
||||
ctx.Locale = "en-us"
|
||||
ctx.Client = context.Client{
|
||||
Type: "web",
|
||||
IP: "127.0.0.1",
|
||||
}
|
||||
ctx.Referer = context.RefererAPI
|
||||
ctx.Accept = context.AcceptWebCUI
|
||||
ctx.IDGenerator = message.NewIDGenerator()
|
||||
ctx.Metadata = make(map[string]interface{})
|
||||
return ctx
|
||||
}
|
||||
|
||||
func TestSearchAutoWeb(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
ast, err := assistant.LoadPath("/assistants/tests/search-auto-web")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
t.Run("ShouldHaveSearchConfig", func(t *testing.T) {
|
||||
assert.NotNil(t, ast.Search, "search config should be set")
|
||||
assert.NotNil(t, ast.Search.Web, "web search config should be set")
|
||||
assert.Equal(t, "tavily", ast.Search.Web.Provider)
|
||||
assert.Equal(t, 3, ast.Search.Web.MaxResults)
|
||||
})
|
||||
|
||||
t.Run("ShouldHaveUsesConfig", func(t *testing.T) {
|
||||
assert.NotNil(t, ast.Uses, "uses config should be set")
|
||||
assert.Equal(t, "builtin", ast.Uses.Search)
|
||||
assert.Equal(t, "builtin", ast.Uses.Web)
|
||||
})
|
||||
|
||||
t.Run("ShouldHaveCitationConfig", func(t *testing.T) {
|
||||
assert.NotNil(t, ast.Search.Citation, "citation config should be set")
|
||||
assert.Equal(t, "xml", ast.Search.Citation.Format)
|
||||
assert.True(t, ast.Search.Citation.AutoInjectPrompt)
|
||||
})
|
||||
|
||||
t.Run("StreamShouldExecuteAutoSearch", func(t *testing.T) {
|
||||
// Get agent via assistant.Get (required for Stream)
|
||||
agent, err := assistant.Get("tests.search-auto-web")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, agent)
|
||||
|
||||
// Create context
|
||||
ctx := newSearchAutoTestContext("test-search-auto-web", "tests.search-auto-web")
|
||||
|
||||
// Create messages with a search query
|
||||
messages := []context.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: "What is the latest news about artificial intelligence?",
|
||||
},
|
||||
}
|
||||
|
||||
// Execute stream
|
||||
response, err := agent.Stream(ctx, messages)
|
||||
|
||||
// Assert no error (if API key is configured)
|
||||
if err != nil {
|
||||
// If error contains "API key", it's expected in CI without keys
|
||||
if strings.Contains(err.Error(), "API key") || strings.Contains(err.Error(), "api_key") {
|
||||
t.Logf("Expected error without API key: %v", err)
|
||||
return
|
||||
}
|
||||
// Other errors should fail
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
require.NotNil(t, response)
|
||||
assert.NotNil(t, response.Completion, "should have completion")
|
||||
t.Logf("✓ Stream executed successfully with auto search")
|
||||
})
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
package assistant
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
v8 "github.com/yaoapp/gou/runtime/v8"
|
||||
"github.com/yaoapp/yao/agent/assistant/hook"
|
||||
)
|
||||
|
||||
// loadSource loads hook script from source code string
|
||||
// The source field stores TypeScript code directly (but without imports)
|
||||
// Priority: script field > source field (if script exists, source is ignored)
|
||||
// Note: Uses MakeScriptInMemory which supports TypeScript syntax without file resolution.
|
||||
func loadSource(source string, assistantID string) (*hook.Script, error) {
|
||||
if source == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Use virtual .ts path for TypeScript support
|
||||
// MakeScriptInMemory handles TypeScript transform without file system access
|
||||
virtualFile := fmt.Sprintf("assistants/%s/source.ts", strings.ReplaceAll(assistantID, ".", "/"))
|
||||
|
||||
script, err := v8.MakeScriptInMemory([]byte(source), virtualFile, 5*time.Second, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to compile source script: %w", err)
|
||||
}
|
||||
|
||||
return &hook.Script{Script: script}, nil
|
||||
}
|
||||
|
||||
// TODO: Future enhancement - support multiple files merged with special comment delimiter
|
||||
// Format: // file: index.ts
|
||||
// This would allow splitting large scripts into multiple logical files while storing as single source
|
||||
// func loadSourceMultiFile(source string, assistantID string) (*hook.Script, error) {
|
||||
// // Parse source by "// file: xxx.ts" delimiter
|
||||
// // Merge and compile
|
||||
// }
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
package assistant
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/yaoapp/gou/connector/openai"
|
||||
"github.com/yaoapp/kun/log"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/i18n"
|
||||
"github.com/yaoapp/yao/trace/types"
|
||||
)
|
||||
|
||||
// initAgentTraceNode creates and returns the agent trace node
|
||||
func (ast *Assistant) initAgentTraceNode(ctx *context.Context, inputMessages []context.Message) types.Node {
|
||||
trace, _ := ctx.Trace()
|
||||
if trace == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
agentNode, _ := trace.Add(inputMessages, types.TraceNodeOption{
|
||||
Label: i18n.Tr(ast.ID, ctx.Locale, "assistant.agent.stream.label"), // "Assistant {{name}}"
|
||||
Type: "agent",
|
||||
Icon: "assistant",
|
||||
Description: i18n.Tr(ast.ID, ctx.Locale, "assistant.agent.stream.description"), // "Assistant {{name}} is processing the request"
|
||||
})
|
||||
|
||||
return agentNode
|
||||
}
|
||||
|
||||
// traceAgentHistory logs the chat history to the agent trace node
|
||||
func (ast *Assistant) traceAgentHistory(ctx *context.Context, agentNode types.Node, fullMessages []context.Message) {
|
||||
if agentNode == nil {
|
||||
return
|
||||
}
|
||||
|
||||
agentNode.Info(
|
||||
i18n.Tr(ast.ID, ctx.Locale, "assistant.agent.stream.history"), // "Get Chat History"
|
||||
map[string]any{"messages": fullMessages},
|
||||
)
|
||||
}
|
||||
|
||||
// traceCreateHook logs the create hook response to the agent trace node
|
||||
func (ast *Assistant) traceCreateHook(agentNode types.Node, createResponse *context.HookCreateResponse) {
|
||||
if agentNode == nil {
|
||||
return
|
||||
}
|
||||
|
||||
agentNode.Debug("Call Create Hook", map[string]any{"response": createResponse})
|
||||
}
|
||||
|
||||
// traceConnectorCapabilities logs the connector capabilities to the agent trace node
|
||||
func (ast *Assistant) traceConnectorCapabilities(agentNode types.Node, capabilities *openai.Capabilities) {
|
||||
if agentNode == nil {
|
||||
return
|
||||
}
|
||||
|
||||
agentNode.Debug("Get Connector Capabilities", map[string]any{"capabilities": capabilities})
|
||||
}
|
||||
|
||||
// traceLLMRequest adds a LLM trace node to the trace
|
||||
func (ast *Assistant) traceLLMRequest(ctx *context.Context, connID string, completionMessages []context.Message, completionOptions *context.CompletionOptions) {
|
||||
trace, _ := ctx.Trace()
|
||||
if trace == nil {
|
||||
return
|
||||
}
|
||||
|
||||
trace.Add(
|
||||
map[string]any{"messages": completionMessages, "options": completionOptions},
|
||||
types.TraceNodeOption{
|
||||
Label: fmt.Sprintf(i18n.Tr(ast.ID, ctx.Locale, "llm.openai.stream.label"), connID), // "LLM %s"
|
||||
Type: "llm",
|
||||
Icon: "psychology",
|
||||
Description: fmt.Sprintf(i18n.Tr(ast.ID, ctx.Locale, "llm.openai.stream.description"), connID), // "LLM %s is processing the request"
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// traceLLMComplete marks the LLM request as complete in the trace
|
||||
func (ast *Assistant) traceLLMComplete(ctx *context.Context, completionResponse *context.CompletionResponse) {
|
||||
trace, _ := ctx.Trace()
|
||||
if trace == nil {
|
||||
return
|
||||
}
|
||||
|
||||
trace.Complete(completionResponse)
|
||||
}
|
||||
|
||||
// traceLLMFail marks the LLM request as failed in the trace
|
||||
func (ast *Assistant) traceLLMFail(ctx *context.Context, err error) {
|
||||
trace, _ := ctx.Trace()
|
||||
if trace == nil {
|
||||
return
|
||||
}
|
||||
|
||||
trace.Fail(err)
|
||||
}
|
||||
|
||||
// traceAgentCompletion creates a completion node to report the final output
|
||||
func (ast *Assistant) traceAgentCompletion(ctx *context.Context, createResponse *context.HookCreateResponse, nextResponse *context.NextHookResponse, completionResponse *context.CompletionResponse, finalResponse interface{}) {
|
||||
trace, _ := ctx.Trace()
|
||||
if trace == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare the input data (the raw responses before processing)
|
||||
input := map[string]interface{}{
|
||||
"create": createResponse,
|
||||
"next": nextResponse,
|
||||
"completion": completionResponse,
|
||||
}
|
||||
|
||||
// Create a dedicated completion node
|
||||
completionNode, err := trace.Add(
|
||||
input,
|
||||
types.TraceNodeOption{
|
||||
Label: i18n.Tr(ast.ID, ctx.Locale, "assistant.agent.completion.label"), // "Agent Completion"
|
||||
Type: "agent_completion",
|
||||
Icon: "check_circle",
|
||||
Description: i18n.Tr(ast.ID, ctx.Locale, "assistant.agent.completion.description"), // "Final output from assistant"
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
log.Trace("[TRACE] Failed to create completion node: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Immediately mark it as complete with the final response
|
||||
if completionNode != nil {
|
||||
completionNode.Complete(finalResponse)
|
||||
}
|
||||
}
|
||||
|
||||
// traceAgentOutput sets the output of the agent trace node
|
||||
// Deprecated: Use traceAgentCompletion instead for better trace structure
|
||||
func (ast *Assistant) traceAgentOutput(agentNode types.Node, createResponse *context.HookCreateResponse, nextResponse interface{}, completionResponse *context.CompletionResponse) {
|
||||
if agentNode == nil {
|
||||
return
|
||||
}
|
||||
|
||||
output := context.Response{
|
||||
Create: createResponse,
|
||||
Next: nextResponse,
|
||||
Completion: completionResponse,
|
||||
}
|
||||
|
||||
agentNode.Complete(output)
|
||||
}
|
||||
|
||||
// traceAgentFail marks the agent trace node as failed
|
||||
func (ast *Assistant) traceAgentFail(agentNode types.Node, err error) {
|
||||
if agentNode == nil {
|
||||
return
|
||||
}
|
||||
|
||||
agentNode.Fail(err)
|
||||
}
|
||||
|
||||
// traceLLMRetryRequest adds a LLM retry trace node to the trace
|
||||
func (ast *Assistant) traceLLMRetryRequest(ctx *context.Context, connID string, completionMessages []context.Message, completionOptions *context.CompletionOptions) {
|
||||
trace, _ := ctx.Trace()
|
||||
if trace == nil {
|
||||
return
|
||||
}
|
||||
|
||||
trace.Add(
|
||||
map[string]any{"messages": completionMessages, "options": completionOptions},
|
||||
types.TraceNodeOption{
|
||||
Label: fmt.Sprintf("LLM %s (Tool Retry)", connID),
|
||||
Type: "llm_retry",
|
||||
Icon: "refresh",
|
||||
Description: fmt.Sprintf("LLM %s is retrying with tool call error feedback", connID),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
package assistant
|
||||
|
||||
import (
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
v8 "github.com/yaoapp/gou/runtime/v8"
|
||||
"github.com/yaoapp/yao/agent/assistant/hook"
|
||||
chatctx "github.com/yaoapp/yao/agent/context"
|
||||
|
||||
outputMessage "github.com/yaoapp/yao/agent/output/message"
|
||||
store "github.com/yaoapp/yao/agent/store/types"
|
||||
)
|
||||
|
||||
const (
|
||||
// HookErrorMethodNotFound is the error message for method not found
|
||||
HookErrorMethodNotFound = "method not found"
|
||||
)
|
||||
|
||||
// API the assistant API interface
|
||||
type API interface {
|
||||
GetPlaceholder(locale string) *store.Placeholder
|
||||
}
|
||||
|
||||
// Script the script scripts except hook script
|
||||
type Script struct {
|
||||
*v8.Script
|
||||
}
|
||||
|
||||
// Assistant the assistant
|
||||
type Assistant struct {
|
||||
store.AssistantModel
|
||||
HookScript *hook.Script `json:"-" yaml:"-"` // Hook Script (index.ts)
|
||||
Scripts map[string]*Script `json:"-" yaml:"-"` // Other scripts
|
||||
|
||||
// Internal
|
||||
// ===============================
|
||||
vision bool // Whether this assistant supports vision
|
||||
}
|
||||
|
||||
// MCPTool represents a simplified MCP tool for building LLM requests
|
||||
// This is an internal representation used when collecting tools from MCP servers
|
||||
// and preparing them for the LLM's tool calling interface
|
||||
type MCPTool struct {
|
||||
Name string // Formatted tool name with server prefix (e.g., "server_id__tool_name")
|
||||
Description string // Tool description from MCP server
|
||||
Parameters interface{} // JSON Schema for tool parameters (from MCP InputSchema)
|
||||
}
|
||||
|
||||
// ToolCallResult represents the result of a tool call execution
|
||||
// Used to track the outcome of MCP tool invocations during agent execution
|
||||
type ToolCallResult struct {
|
||||
ToolCallID string // Tool call ID from the LLM (matches the ID in the LLM's tool_calls response)
|
||||
Name string // Tool name (formatted with server prefix, e.g., "server_id__tool_name")
|
||||
Content string // Result content (JSON string of the tool's output or error message)
|
||||
Error error // Error if the call failed (nil if successful)
|
||||
IsRetryableError bool // Whether the error should be sent to LLM for retry
|
||||
// true: parameter/validation errors that LLM can fix (e.g., "missing required field")
|
||||
// false: MCP internal errors that LLM cannot fix (e.g., "network error", "service unavailable")
|
||||
}
|
||||
|
||||
// Server extracts the MCP server ID from the formatted tool name
|
||||
// Example: "echo__ping" -> "echo"
|
||||
func (r *ToolCallResult) Server() string {
|
||||
serverID, _, _ := ParseMCPToolName(r.Name)
|
||||
return serverID
|
||||
}
|
||||
|
||||
// Tool extracts the original tool name without server prefix
|
||||
// Example: "echo__ping" -> "ping"
|
||||
func (r *ToolCallResult) Tool() string {
|
||||
_, toolName, _ := ParseMCPToolName(r.Name)
|
||||
return toolName
|
||||
}
|
||||
|
||||
// NextProcessContext encapsulates all the context needed to process Next hook responses
|
||||
// This simplifies function signatures and makes it easier to add new fields in the future
|
||||
type NextProcessContext struct {
|
||||
Context *chatctx.Context // Agent context
|
||||
NextResponse *chatctx.NextHookResponse // Response from Next hook (already converted from JS)
|
||||
CompletionResponse *chatctx.CompletionResponse // LLM completion response
|
||||
FullMessages []chatctx.Message // Full conversation history
|
||||
ToolCallResponses []chatctx.ToolCallResponse // Tool call results (if any)
|
||||
StreamHandler outputMessage.StreamFunc // Stream handler for output
|
||||
CreateResponse *chatctx.HookCreateResponse // Create hook response
|
||||
}
|
||||
|
||||
// SearchIntent is an alias for context.SearchIntent
|
||||
// Used for search intent detection from __yao.needsearch agent
|
||||
type SearchIntent = chatctx.SearchIntent
|
||||
|
||||
// ParsedContent extracts the actual tool return value from MCP ToolContent array
|
||||
// According to MCP protocol:
|
||||
// - Content is []ToolContent array
|
||||
// - For "text" type, the actual value is in Text field (usually JSON string)
|
||||
// - For "image" type, returns the Data field
|
||||
// - For "resource" type, returns the Resource object
|
||||
// If there are multiple content items, returns an array of parsed values
|
||||
func (r *ToolCallResult) ParsedContent() (interface{}, error) {
|
||||
if r.Content == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Parse Content as []ToolContent
|
||||
var toolContents []map[string]interface{}
|
||||
if err := jsoniter.UnmarshalFromString(r.Content, &toolContents); err != nil {
|
||||
// If parsing fails, return the string content directly (error message)
|
||||
return r.Content, nil
|
||||
}
|
||||
|
||||
// Extract actual values from ToolContent items
|
||||
var results []interface{}
|
||||
for _, tc := range toolContents {
|
||||
contentType, _ := tc["type"].(string)
|
||||
|
||||
switch contentType {
|
||||
case "text":
|
||||
// For text type, parse the Text field (usually JSON)
|
||||
if textStr, ok := tc["text"].(string); ok {
|
||||
// Try to parse as JSON
|
||||
var parsed interface{}
|
||||
if err := jsoniter.UnmarshalFromString(textStr, &parsed); err == nil {
|
||||
results = append(results, parsed)
|
||||
} else {
|
||||
// If not JSON, return as plain string
|
||||
results = append(results, textStr)
|
||||
}
|
||||
}
|
||||
case "image":
|
||||
// For image type, return the data and mimeType
|
||||
results = append(results, map[string]interface{}{
|
||||
"type": "image",
|
||||
"data": tc["data"],
|
||||
"mimeType": tc["mimeType"],
|
||||
})
|
||||
case "resource":
|
||||
// For resource type, return the resource object
|
||||
results = append(results, tc["resource"])
|
||||
default:
|
||||
// Unknown type, return as-is
|
||||
results = append(results, tc)
|
||||
}
|
||||
}
|
||||
|
||||
// If only one result, return it directly (not as array)
|
||||
if len(results) == 1 {
|
||||
return results[0], nil
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
package assistant
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
"github.com/kaptinlin/jsonrepair"
|
||||
)
|
||||
|
||||
func getTimestamp(v interface{}) (int64, error) {
|
||||
switch v := v.(type) {
|
||||
case int64:
|
||||
return v, nil
|
||||
case int:
|
||||
return int64(v), nil
|
||||
|
||||
case string:
|
||||
if ts, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
return ts.UnixNano(), nil
|
||||
}
|
||||
|
||||
// MySQL format
|
||||
if ts, err := time.Parse("2006-01-02 15:04:05", v); err == nil {
|
||||
return ts.UnixNano(), nil
|
||||
}
|
||||
|
||||
// UnixNano format
|
||||
if ts, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
return ts, nil
|
||||
}
|
||||
|
||||
case time.Time:
|
||||
return v.UnixNano(), nil
|
||||
|
||||
case nil:
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("invalid timestamp type %T", v)
|
||||
}
|
||||
|
||||
// getBool gets bool from data map[string]interface{}, key string
|
||||
func getBool(data map[string]interface{}, key string) bool {
|
||||
switch v := data[key].(type) {
|
||||
case bool:
|
||||
return v
|
||||
case int64:
|
||||
return v != 0
|
||||
case int:
|
||||
return v != 0
|
||||
case float64:
|
||||
return v != 0
|
||||
case string:
|
||||
return v == "true" || v == "1" || v == "enabled" || v == "yes" || v == "on"
|
||||
case nil:
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// stringHash returns the sha256 hash of the string
|
||||
func stringHash(v string) string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(v))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// ParseJSON attempts to parse a potentially malformed JSON string
|
||||
func ParseJSON(jsonStr string, v interface{}) error {
|
||||
// Try parsing as-is first
|
||||
err := jsoniter.UnmarshalFromString(jsonStr, v)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
originalErr := err
|
||||
|
||||
// Try adding a closing brace
|
||||
if err := jsoniter.UnmarshalFromString(jsonStr+"}", v); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try repairing the JSON
|
||||
repaired, err := jsonrepair.JSONRepair(jsonStr)
|
||||
if err != nil {
|
||||
return originalErr
|
||||
}
|
||||
|
||||
// Try parsing the repaired JSON
|
||||
if err := jsoniter.UnmarshalFromString(repaired, v); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If all attempts fail, return the original error
|
||||
return originalErr
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
// Package caller provides a shared interface for calling agents
|
||||
// This package is used by both content and search packages to avoid circular dependencies
|
||||
package caller
|
||||
|
||||
import (
|
||||
agentContext "github.com/yaoapp/yao/agent/context"
|
||||
)
|
||||
|
||||
// AgentCaller interface for calling agents (to avoid circular dependency)
|
||||
// Used by content handlers (vision, audio, etc.) and search handlers (agent mode)
|
||||
type AgentCaller interface {
|
||||
Stream(ctx *agentContext.Context, messages []agentContext.Message, options ...*agentContext.Options) (*agentContext.Response, error)
|
||||
}
|
||||
|
||||
// AgentGetterFunc is a function type that gets an agent by ID
|
||||
// This should be set by the assistant package during initialization
|
||||
var AgentGetterFunc func(agentID string) (AgentCaller, error)
|
||||
|
||||
// AssistantReloadFunc reloads a single assistant from disk after deploy.
|
||||
// Set by the assistant package during initialization.
|
||||
var AssistantReloadFunc func(id string) error
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
package caller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
agentContext "github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
// NewHeadlessContext creates a headless agent context from a ProcessCallRequest.
|
||||
// This is the Process equivalent of openapi.GetCompletionRequest — constructs
|
||||
// a Context + Options without HTTP dependencies (no Writer, no Interrupt).
|
||||
//
|
||||
// Key behaviors:
|
||||
// - parent context controls timeout/cancellation (caller is responsible)
|
||||
// - skip.output = true (forced): no Writer available, must skip output
|
||||
// - skip.history = true (forced): Process calls don't save chat history
|
||||
// - authorized info is passed in (from authorized.ProcessAuthInfo by caller)
|
||||
// - chatID is auto-generated if not provided
|
||||
func NewHeadlessContext(parent context.Context, authInfo *types.AuthorizedInfo, req *ProcessCallRequest) (*agentContext.Context, *agentContext.Options) {
|
||||
chatID := req.ChatID
|
||||
if chatID == "" {
|
||||
chatID = agentContext.GenChatID()
|
||||
}
|
||||
|
||||
ctx := agentContext.New(parent, authInfo, chatID)
|
||||
ctx.AssistantID = req.AssistantID
|
||||
ctx.Referer = agentContext.RefererProcess
|
||||
ctx.Locale = req.Locale
|
||||
ctx.Route = req.Route
|
||||
ctx.Metadata = req.Metadata
|
||||
|
||||
// Force skip for headless context — no Writer, no chat history
|
||||
skip := req.Skip
|
||||
if skip == nil {
|
||||
skip = &agentContext.Skip{}
|
||||
}
|
||||
skip.Output = true // no Writer available
|
||||
skip.History = true // Process calls don't save chat history
|
||||
|
||||
opts := &agentContext.Options{Skip: skip}
|
||||
if req.Model != "" {
|
||||
opts.Connector = req.Model
|
||||
}
|
||||
|
||||
return ctx, opts
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
package caller
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"github.com/yaoapp/gou/doc"
|
||||
)
|
||||
|
||||
//go:embed doc.yml
|
||||
var docYAML []byte
|
||||
|
||||
func init() { doc.LoadYAML(docYAML) }
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
group: agent
|
||||
type: process
|
||||
entries:
|
||||
- name: Call
|
||||
desc: Call an agent from contexts without agent.Context, enabling agent-to-agent communication
|
||||
args:
|
||||
- name: request
|
||||
type: object
|
||||
required: true
|
||||
desc: "Request object with fields: assistant_id (string, required), messages (array of message objects, required), model (string, connector override), skip (object, skip config), metadata (object, passed to hooks), locale (string), route (string), chat_id (string, auto-generated if empty), timeout (number, seconds, default 600)"
|
||||
return:
|
||||
type: object
|
||||
desc: "Result object: { agent_id (string), response (object, full agent response), content (string, extracted text), error (string, error message if failed) }"
|
||||
|
|
@ -1,278 +0,0 @@
|
|||
package caller_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
"github.com/yaoapp/yao/agent/caller"
|
||||
agentContext "github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/testutils"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
)
|
||||
|
||||
func TestIntegration_Call_RealAgent(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
// Load the simple-greeting agent
|
||||
ast, err := assistant.Get("tests.simple-greeting")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ast)
|
||||
|
||||
// Create authorized info for the context
|
||||
authorized := &types.AuthorizedInfo{
|
||||
Subject: "test-user",
|
||||
UserID: "test-123",
|
||||
TenantID: "test-tenant",
|
||||
}
|
||||
|
||||
// Create a context with authorization
|
||||
ctx := agentContext.New(context.Background(), authorized, "test-chat-integration")
|
||||
ctx.AssistantID = "tests.agent-caller"
|
||||
|
||||
// Create JSAPI
|
||||
api := caller.NewJSAPI(ctx)
|
||||
|
||||
// Call the simple-greeting agent
|
||||
messages := []interface{}{
|
||||
map[string]interface{}{
|
||||
"role": "user",
|
||||
"content": "Hello!",
|
||||
},
|
||||
}
|
||||
|
||||
opts := map[string]interface{}{
|
||||
"skip": map[string]interface{}{
|
||||
"history": true,
|
||||
},
|
||||
}
|
||||
|
||||
result := api.Call("tests.simple-greeting", messages, opts)
|
||||
require.NotNil(t, result)
|
||||
|
||||
r, ok := result.(*caller.Result)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "tests.simple-greeting", r.AgentID)
|
||||
|
||||
// Should either have content or error
|
||||
if r.Error != "" {
|
||||
t.Logf("Agent call error: %s", r.Error)
|
||||
} else {
|
||||
t.Logf("Agent response content: %s", r.Content)
|
||||
assert.NotEmpty(t, r.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_All_RealAgents(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
// Create authorized info for the context
|
||||
authorized := &types.AuthorizedInfo{
|
||||
Subject: "test-user",
|
||||
UserID: "test-123",
|
||||
TenantID: "test-tenant",
|
||||
}
|
||||
|
||||
// Create a context with authorization
|
||||
ctx := agentContext.New(context.Background(), authorized, "test-chat-all")
|
||||
ctx.AssistantID = "tests.agent-caller"
|
||||
|
||||
// Create JSAPI
|
||||
api := caller.NewJSAPI(ctx)
|
||||
|
||||
// Call multiple agents in parallel
|
||||
requests := []interface{}{
|
||||
map[string]interface{}{
|
||||
"agent": "tests.simple-greeting",
|
||||
"messages": []interface{}{
|
||||
map[string]interface{}{
|
||||
"role": "user",
|
||||
"content": "Hello from test 1!",
|
||||
},
|
||||
},
|
||||
"options": map[string]interface{}{
|
||||
"skip": map[string]interface{}{
|
||||
"history": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"agent": "tests.simple-greeting",
|
||||
"messages": []interface{}{
|
||||
map[string]interface{}{
|
||||
"role": "user",
|
||||
"content": "Hello from test 2!",
|
||||
},
|
||||
},
|
||||
"options": map[string]interface{}{
|
||||
"skip": map[string]interface{}{
|
||||
"history": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
results := api.All(requests)
|
||||
require.Len(t, results, 2)
|
||||
|
||||
for i, result := range results {
|
||||
r, ok := result.(*caller.Result)
|
||||
require.True(t, ok, "result %d should be *caller.Result", i)
|
||||
assert.Equal(t, "tests.simple-greeting", r.AgentID)
|
||||
t.Logf("Result[%d]: content=%s, error=%s", i, r.Content, r.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Any_RealAgents(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
// Create authorized info for the context
|
||||
authorized := &types.AuthorizedInfo{
|
||||
Subject: "test-user",
|
||||
UserID: "test-123",
|
||||
TenantID: "test-tenant",
|
||||
}
|
||||
|
||||
// Create a context with authorization
|
||||
ctx := agentContext.New(context.Background(), authorized, "test-chat-any")
|
||||
ctx.AssistantID = "tests.agent-caller"
|
||||
|
||||
// Create JSAPI
|
||||
api := caller.NewJSAPI(ctx)
|
||||
|
||||
// Call multiple agents - return when any succeeds
|
||||
requests := []interface{}{
|
||||
map[string]interface{}{
|
||||
"agent": "tests.simple-greeting",
|
||||
"messages": []interface{}{
|
||||
map[string]interface{}{
|
||||
"role": "user",
|
||||
"content": "Hello from any test 1!",
|
||||
},
|
||||
},
|
||||
"options": map[string]interface{}{
|
||||
"skip": map[string]interface{}{
|
||||
"history": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"agent": "tests.simple-greeting",
|
||||
"messages": []interface{}{
|
||||
map[string]interface{}{
|
||||
"role": "user",
|
||||
"content": "Hello from any test 2!",
|
||||
},
|
||||
},
|
||||
"options": map[string]interface{}{
|
||||
"skip": map[string]interface{}{
|
||||
"history": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
results := api.Any(requests)
|
||||
require.Len(t, results, 2)
|
||||
|
||||
// At least one should have a result
|
||||
hasResult := false
|
||||
for i, result := range results {
|
||||
if result != nil {
|
||||
r, ok := result.(*caller.Result)
|
||||
if ok && r != nil && r.Error == "" {
|
||||
hasResult = true
|
||||
t.Logf("Any Result[%d]: content=%s", i, r.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.True(t, hasResult, "At least one result should succeed")
|
||||
}
|
||||
|
||||
func TestIntegration_Race_RealAgents(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
// Create authorized info for the context
|
||||
authorized := &types.AuthorizedInfo{
|
||||
Subject: "test-user",
|
||||
UserID: "test-123",
|
||||
TenantID: "test-tenant",
|
||||
}
|
||||
|
||||
// Create a context with authorization
|
||||
ctx := agentContext.New(context.Background(), authorized, "test-chat-race")
|
||||
ctx.AssistantID = "tests.agent-caller"
|
||||
|
||||
// Create JSAPI
|
||||
api := caller.NewJSAPI(ctx)
|
||||
|
||||
// Call multiple agents - return when any completes
|
||||
requests := []interface{}{
|
||||
map[string]interface{}{
|
||||
"agent": "tests.simple-greeting",
|
||||
"messages": []interface{}{
|
||||
map[string]interface{}{
|
||||
"role": "user",
|
||||
"content": "Hello from race test 1!",
|
||||
},
|
||||
},
|
||||
"options": map[string]interface{}{
|
||||
"skip": map[string]interface{}{
|
||||
"history": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"agent": "tests.simple-greeting",
|
||||
"messages": []interface{}{
|
||||
map[string]interface{}{
|
||||
"role": "user",
|
||||
"content": "Hello from race test 2!",
|
||||
},
|
||||
},
|
||||
"options": map[string]interface{}{
|
||||
"skip": map[string]interface{}{
|
||||
"history": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
results := api.Race(requests)
|
||||
require.Len(t, results, 2)
|
||||
|
||||
// At least one should have completed
|
||||
hasResult := false
|
||||
for i, result := range results {
|
||||
if result != nil {
|
||||
r, ok := result.(*caller.Result)
|
||||
if ok && r != nil {
|
||||
hasResult = true
|
||||
t.Logf("Race Result[%d]: content=%s, error=%s", i, r.Content, r.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.True(t, hasResult, "At least one result should complete")
|
||||
}
|
||||
|
|
@ -1,340 +0,0 @@
|
|||
package caller
|
||||
|
||||
import (
|
||||
agentContext "github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/output/message"
|
||||
)
|
||||
|
||||
// JSAPI implements context.AgentAPI and context.AgentAPIWithCallback interfaces
|
||||
// Provides ctx.agent.Call(), ctx.agent.All(), ctx.agent.Any(), ctx.agent.Race()
|
||||
// and their *WithHandler variants for streaming callback support
|
||||
type JSAPI struct {
|
||||
ctx *agentContext.Context
|
||||
orchestrator *Orchestrator
|
||||
}
|
||||
|
||||
// Ensure JSAPI implements AgentAPIWithCallback
|
||||
var _ agentContext.AgentAPIWithCallback = (*JSAPI)(nil)
|
||||
|
||||
// NewJSAPI creates a new agent JSAPI instance
|
||||
func NewJSAPI(ctx *agentContext.Context) *JSAPI {
|
||||
return &JSAPI{
|
||||
ctx: ctx,
|
||||
orchestrator: NewOrchestrator(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
// Call executes a single agent call
|
||||
// Usage: ctx.agent.Call("assistant-id", messages, options?)
|
||||
// Returns: { agent_id, response, content, error }
|
||||
// Note: For sub-agent calls, skip.history = true is automatically set
|
||||
// to prevent A2A messages from being saved to chat history.
|
||||
// Sub-agents output normally with ThreadID for SSE stream isolation.
|
||||
func (api *JSAPI) Call(agentID string, messages []interface{}, opts map[string]interface{}) interface{} {
|
||||
req := api.buildRequest(agentID, messages, opts)
|
||||
// Force skip options for sub-agent calls
|
||||
api.forceSkipForSubAgent(req)
|
||||
result := api.orchestrator.callAgent(req)
|
||||
return result
|
||||
}
|
||||
|
||||
// All executes all agent calls and waits for all to complete (like Promise.all)
|
||||
// Each request should have:
|
||||
// - agent: string - target agent ID
|
||||
// - messages: array - messages to send
|
||||
// - options?: object - call options
|
||||
func (api *JSAPI) All(requests []interface{}) []interface{} {
|
||||
reqs := api.parseRequests(requests)
|
||||
results := api.orchestrator.All(reqs)
|
||||
return api.convertResults(results)
|
||||
}
|
||||
|
||||
// Any returns as soon as any agent call succeeds (like Promise.any)
|
||||
// Each request should have:
|
||||
// - agent: string - target agent ID
|
||||
// - messages: array - messages to send
|
||||
// - options?: object - call options
|
||||
func (api *JSAPI) Any(requests []interface{}) []interface{} {
|
||||
reqs := api.parseRequests(requests)
|
||||
results := api.orchestrator.Any(reqs)
|
||||
return api.convertResults(results)
|
||||
}
|
||||
|
||||
// Race returns as soon as any agent call completes (like Promise.race)
|
||||
// Each request should have:
|
||||
// - agent: string - target agent ID
|
||||
// - messages: array - messages to send
|
||||
// - options?: object - call options
|
||||
func (api *JSAPI) Race(requests []interface{}) []interface{} {
|
||||
reqs := api.parseRequests(requests)
|
||||
results := api.orchestrator.Race(reqs)
|
||||
return api.convertResults(results)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AgentAPIWithCallback Implementation
|
||||
// ============================================================================
|
||||
|
||||
// CallWithHandler executes a single agent call with an OnMessage handler
|
||||
// Note: For sub-agent calls, skip.history = true is automatically set.
|
||||
// Sub-agents output normally with ThreadID. Use the handler callback
|
||||
// to receive streaming messages.
|
||||
func (api *JSAPI) CallWithHandler(agentID string, messages []interface{}, opts map[string]interface{}, handler agentContext.OnMessageFunc) interface{} {
|
||||
req := api.buildRequest(agentID, messages, opts)
|
||||
req.Handler = handler
|
||||
// Force skip options for sub-agent calls
|
||||
api.forceSkipForSubAgent(req)
|
||||
result := api.orchestrator.callAgent(req)
|
||||
return result
|
||||
}
|
||||
|
||||
// AllWithHandler executes all agent calls with handlers
|
||||
func (api *JSAPI) AllWithHandler(requests []interface{}, globalHandler agentContext.BatchOnMessageFunc) []interface{} {
|
||||
reqs := api.parseRequestsWithHandlers(requests, globalHandler)
|
||||
results := api.orchestrator.All(reqs)
|
||||
return api.convertResults(results)
|
||||
}
|
||||
|
||||
// AnyWithHandler executes agent calls and returns on first success, with handlers
|
||||
func (api *JSAPI) AnyWithHandler(requests []interface{}, globalHandler agentContext.BatchOnMessageFunc) []interface{} {
|
||||
reqs := api.parseRequestsWithHandlers(requests, globalHandler)
|
||||
results := api.orchestrator.Any(reqs)
|
||||
return api.convertResults(results)
|
||||
}
|
||||
|
||||
// RaceWithHandler executes agent calls and returns on first completion, with handlers
|
||||
func (api *JSAPI) RaceWithHandler(requests []interface{}, globalHandler agentContext.BatchOnMessageFunc) []interface{} {
|
||||
reqs := api.parseRequestsWithHandlers(requests, globalHandler)
|
||||
results := api.orchestrator.Race(reqs)
|
||||
return api.convertResults(results)
|
||||
}
|
||||
|
||||
// forceSkipForSubAgent ensures proper A2A call behavior:
|
||||
// - skip.history = true: always set — A2A messages are not saved to chat history
|
||||
// - skip.output: defaults to false (sub-agents output with ThreadID for SSE stream isolation),
|
||||
// but if the caller explicitly sets skip.output = true, it is respected.
|
||||
// This allows internal worker agents (e.g. classifiers) to run silently.
|
||||
func (api *JSAPI) forceSkipForSubAgent(req *Request) {
|
||||
if req.Options == nil {
|
||||
req.Options = &CallOptions{}
|
||||
}
|
||||
|
||||
// Preserve caller's explicit skip.output = true before overwriting Skip struct
|
||||
callerSkipOutput := req.Options.Skip != nil && req.Options.Skip.Output
|
||||
|
||||
if req.Options.Skip == nil {
|
||||
req.Options.Skip = &agentContext.Skip{}
|
||||
}
|
||||
req.Options.Skip.History = true
|
||||
|
||||
if callerSkipOutput {
|
||||
req.Options.Skip.Output = true
|
||||
}
|
||||
// else: skip.output remains false (default zero value) — sub-agent outputs normally
|
||||
}
|
||||
|
||||
// parseRequestsWithHandlers parses requests and attaches handlers
|
||||
// It checks for per-request _handler fields and wraps globalHandler with agentID/index
|
||||
// For all calls, this automatically sets:
|
||||
// - skip.history = true: prevents A2A messages from being saved to chat history
|
||||
// - skip.output = false: ensures sub-agents output with ThreadID (overrides user settings)
|
||||
func (api *JSAPI) parseRequestsWithHandlers(requests []interface{}, globalHandler agentContext.BatchOnMessageFunc) []*Request {
|
||||
reqs := make([]*Request, 0, len(requests))
|
||||
|
||||
for i, r := range requests {
|
||||
reqMap, ok := r.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get agent ID
|
||||
agentID, ok := reqMap["agent"].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get messages
|
||||
messages, ok := reqMap["messages"].([]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get options (optional)
|
||||
var opts map[string]interface{}
|
||||
if o, ok := reqMap["options"].(map[string]interface{}); ok {
|
||||
opts = o
|
||||
}
|
||||
|
||||
req := api.buildRequest(agentID, messages, opts)
|
||||
|
||||
// Force skip.output = true for all sub-agent calls
|
||||
api.forceSkipForSubAgent(req)
|
||||
|
||||
// Check for per-request handler first (takes precedence)
|
||||
if handler, ok := reqMap["_handler"].(agentContext.OnMessageFunc); ok && handler != nil {
|
||||
req.Handler = handler
|
||||
} else if globalHandler != nil {
|
||||
// Wrap global handler with agentID and index
|
||||
idx := i // Capture index for closure
|
||||
aid := agentID
|
||||
req.Handler = func(msg *message.Message) int {
|
||||
return globalHandler(aid, idx, msg)
|
||||
}
|
||||
}
|
||||
|
||||
reqs = append(reqs, req)
|
||||
}
|
||||
|
||||
return reqs
|
||||
}
|
||||
|
||||
// buildRequest builds a Request from agentID, messages, and options
|
||||
func (api *JSAPI) buildRequest(agentID string, messages []interface{}, opts map[string]interface{}) *Request {
|
||||
req := &Request{
|
||||
AgentID: agentID,
|
||||
Messages: api.parseMessages(messages),
|
||||
}
|
||||
|
||||
if opts != nil {
|
||||
req.Options = api.parseCallOptions(opts)
|
||||
}
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
// parseMessages converts []interface{} to []agentContext.Message
|
||||
func (api *JSAPI) parseMessages(messages []interface{}) []agentContext.Message {
|
||||
result := make([]agentContext.Message, 0, len(messages))
|
||||
for _, m := range messages {
|
||||
msg, ok := m.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
ctxMsg := agentContext.Message{}
|
||||
|
||||
// Parse role
|
||||
if role, ok := msg["role"].(string); ok {
|
||||
ctxMsg.Role = agentContext.MessageRole(role)
|
||||
}
|
||||
|
||||
// Parse content (can be string or array)
|
||||
ctxMsg.Content = msg["content"]
|
||||
|
||||
// Parse name
|
||||
if name, ok := msg["name"].(string); ok {
|
||||
ctxMsg.Name = &name
|
||||
}
|
||||
|
||||
// Parse tool_call_id
|
||||
if toolCallID, ok := msg["tool_call_id"].(string); ok {
|
||||
ctxMsg.ToolCallID = &toolCallID
|
||||
}
|
||||
|
||||
// Parse tool_calls
|
||||
if toolCalls, ok := msg["tool_calls"].([]interface{}); ok {
|
||||
ctxMsg.ToolCalls = api.parseToolCalls(toolCalls)
|
||||
}
|
||||
|
||||
// Parse refusal
|
||||
if refusal, ok := msg["refusal"].(string); ok {
|
||||
ctxMsg.Refusal = &refusal
|
||||
}
|
||||
|
||||
result = append(result, ctxMsg)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// parseToolCalls converts []interface{} to []agentContext.ToolCall
|
||||
func (api *JSAPI) parseToolCalls(toolCalls []interface{}) []agentContext.ToolCall {
|
||||
result := make([]agentContext.ToolCall, 0, len(toolCalls))
|
||||
for _, tc := range toolCalls {
|
||||
tcMap, ok := tc.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
toolCall := agentContext.ToolCall{}
|
||||
|
||||
if id, ok := tcMap["id"].(string); ok {
|
||||
toolCall.ID = id
|
||||
}
|
||||
if tcType, ok := tcMap["type"].(string); ok {
|
||||
toolCall.Type = agentContext.ToolCallType(tcType)
|
||||
}
|
||||
if fn, ok := tcMap["function"].(map[string]interface{}); ok {
|
||||
if name, ok := fn["name"].(string); ok {
|
||||
toolCall.Function.Name = name
|
||||
}
|
||||
if args, ok := fn["arguments"].(string); ok {
|
||||
toolCall.Function.Arguments = args
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, toolCall)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// parseCallOptions converts map to CallOptions
|
||||
func (api *JSAPI) parseCallOptions(opts map[string]interface{}) *CallOptions {
|
||||
callOpts := &CallOptions{}
|
||||
|
||||
if connector, ok := opts["connector"].(string); ok {
|
||||
callOpts.Connector = connector
|
||||
}
|
||||
if mode, ok := opts["mode"].(string); ok {
|
||||
callOpts.Mode = mode
|
||||
}
|
||||
if metadata, ok := opts["metadata"].(map[string]interface{}); ok {
|
||||
callOpts.Metadata = metadata
|
||||
}
|
||||
|
||||
// Parse skip configuration
|
||||
if skip, ok := opts["skip"].(map[string]interface{}); ok {
|
||||
callOpts.Skip = &agentContext.Skip{}
|
||||
if history, ok := skip["history"].(bool); ok {
|
||||
callOpts.Skip.History = history
|
||||
}
|
||||
if trace, ok := skip["trace"].(bool); ok {
|
||||
callOpts.Skip.Trace = trace
|
||||
}
|
||||
if output, ok := skip["output"].(bool); ok {
|
||||
callOpts.Skip.Output = output
|
||||
}
|
||||
if keyword, ok := skip["keyword"].(bool); ok {
|
||||
callOpts.Skip.Keyword = keyword
|
||||
}
|
||||
if search, ok := skip["search"].(bool); ok {
|
||||
callOpts.Skip.Search = search
|
||||
}
|
||||
if contentParsing, ok := skip["content_parsing"].(bool); ok {
|
||||
callOpts.Skip.ContentParsing = contentParsing
|
||||
}
|
||||
}
|
||||
|
||||
return callOpts
|
||||
}
|
||||
|
||||
// parseRequests parses an array of request objects into typed Requests
|
||||
func (api *JSAPI) parseRequests(requests []interface{}) []*Request {
|
||||
return api.parseRequestsWithHandlers(requests, nil)
|
||||
}
|
||||
|
||||
// convertResults converts typed Results to interface slice for JS
|
||||
func (api *JSAPI) convertResults(results []*Result) []interface{} {
|
||||
out := make([]interface{}, len(results))
|
||||
for i, r := range results {
|
||||
out[i] = r
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SetJSAPIFactory sets the factory function for creating AgentAPI instances
|
||||
// Called by assistant package during initialization
|
||||
func SetJSAPIFactory() {
|
||||
agentContext.AgentAPIFactory = func(ctx *agentContext.Context) agentContext.AgentAPI {
|
||||
return NewJSAPI(ctx)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
package caller_test
|
||||
|
||||
import (
|
||||
stdContext "context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yaoapp/yao/agent/caller"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
)
|
||||
|
||||
func TestNewJSAPI(t *testing.T) {
|
||||
ctx := context.New(stdContext.Background(), nil, "test-chat")
|
||||
api := caller.NewJSAPI(ctx)
|
||||
require.NotNil(t, api)
|
||||
}
|
||||
|
||||
func TestJSAPI_Call_NoAgentGetter(t *testing.T) {
|
||||
// Reset AgentGetterFunc
|
||||
originalGetter := caller.AgentGetterFunc
|
||||
caller.AgentGetterFunc = nil
|
||||
defer func() { caller.AgentGetterFunc = originalGetter }()
|
||||
|
||||
ctx := context.New(stdContext.Background(), nil, "test-chat")
|
||||
api := caller.NewJSAPI(ctx)
|
||||
|
||||
messages := []interface{}{
|
||||
map[string]interface{}{
|
||||
"role": "user",
|
||||
"content": "Hello",
|
||||
},
|
||||
}
|
||||
|
||||
result := api.Call("test-agent", messages, nil)
|
||||
require.NotNil(t, result)
|
||||
|
||||
r, ok := result.(*caller.Result)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "test-agent", r.AgentID)
|
||||
assert.Contains(t, r.Error, "agent getter not initialized")
|
||||
}
|
||||
|
||||
func TestJSAPI_All_Empty(t *testing.T) {
|
||||
ctx := context.New(stdContext.Background(), nil, "test-chat")
|
||||
api := caller.NewJSAPI(ctx)
|
||||
results := api.All([]interface{}{})
|
||||
assert.Len(t, results, 0)
|
||||
}
|
||||
|
||||
func TestJSAPI_Any_Empty(t *testing.T) {
|
||||
ctx := context.New(stdContext.Background(), nil, "test-chat")
|
||||
api := caller.NewJSAPI(ctx)
|
||||
results := api.Any([]interface{}{})
|
||||
assert.Len(t, results, 0)
|
||||
}
|
||||
|
||||
func TestJSAPI_Race_Empty(t *testing.T) {
|
||||
ctx := context.New(stdContext.Background(), nil, "test-chat")
|
||||
api := caller.NewJSAPI(ctx)
|
||||
results := api.Race([]interface{}{})
|
||||
assert.Len(t, results, 0)
|
||||
}
|
||||
|
||||
func TestJSAPI_All_InvalidRequests(t *testing.T) {
|
||||
ctx := context.New(stdContext.Background(), nil, "test-chat")
|
||||
api := caller.NewJSAPI(ctx)
|
||||
|
||||
// Mix of invalid and valid requests
|
||||
requests := []interface{}{
|
||||
"invalid", // Not a map
|
||||
map[string]interface{}{
|
||||
"messages": []interface{}{}, // Missing agent
|
||||
},
|
||||
map[string]interface{}{
|
||||
"agent": "test-agent", // Missing messages
|
||||
},
|
||||
}
|
||||
|
||||
results := api.All(requests)
|
||||
// None should produce a result (all invalid)
|
||||
assert.Len(t, results, 0)
|
||||
}
|
||||
|
||||
func TestJSAPI_Call_WithOptions(t *testing.T) {
|
||||
// Reset AgentGetterFunc
|
||||
originalGetter := caller.AgentGetterFunc
|
||||
caller.AgentGetterFunc = nil
|
||||
defer func() { caller.AgentGetterFunc = originalGetter }()
|
||||
|
||||
ctx := context.New(stdContext.Background(), nil, "test-chat")
|
||||
api := caller.NewJSAPI(ctx)
|
||||
|
||||
messages := []interface{}{
|
||||
map[string]interface{}{
|
||||
"role": "user",
|
||||
"content": "Hello",
|
||||
},
|
||||
}
|
||||
|
||||
opts := map[string]interface{}{
|
||||
"connector": "gpt4",
|
||||
"mode": "chat",
|
||||
"metadata": map[string]interface{}{
|
||||
"key": "value",
|
||||
},
|
||||
"skip": map[string]interface{}{
|
||||
"history": true,
|
||||
"trace": true,
|
||||
},
|
||||
}
|
||||
|
||||
result := api.Call("test-agent", messages, opts)
|
||||
require.NotNil(t, result)
|
||||
|
||||
r, ok := result.(*caller.Result)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "test-agent", r.AgentID)
|
||||
// Still errors because AgentGetterFunc is nil
|
||||
assert.Contains(t, r.Error, "agent getter not initialized")
|
||||
}
|
||||
|
||||
func TestSetJSAPIFactory(t *testing.T) {
|
||||
// Reset factory
|
||||
context.AgentAPIFactory = nil
|
||||
|
||||
// Set factory
|
||||
caller.SetJSAPIFactory()
|
||||
|
||||
// Verify factory is set
|
||||
require.NotNil(t, context.AgentAPIFactory)
|
||||
|
||||
// Create a mock context
|
||||
ctx := context.New(stdContext.Background(), nil, "test-chat")
|
||||
|
||||
// Get agent API
|
||||
agentAPI := context.AgentAPIFactory(ctx)
|
||||
require.NotNil(t, agentAPI)
|
||||
}
|
||||
|
||||
func TestJSAPI_ImplementsAgentAPI(t *testing.T) {
|
||||
// Verify JSAPI implements context.AgentAPI interface
|
||||
ctx := context.New(stdContext.Background(), nil, "test-chat")
|
||||
var _ context.AgentAPI = caller.NewJSAPI(ctx)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue