Compare commits

..

No commits in common. "main" and "v1.0.0-alpha2" have entirely different histories.

514 changed files with 4638 additions and 54008 deletions

View file

@ -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

View file

@ -1,77 +1,23 @@
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
description: "Release macOS workflow run ID (to download artifacts from)"
required: true
version:
description: "Version used in the release build (e.g. 1.0.0 or 1.0.0-alpha)"
required: true
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:
@ -82,7 +28,7 @@ jobs:
with:
name: yao-darwin-${{ matrix.arch }}
path: bin
run-id: ${{ needs.resolve.outputs.run_id }}
run-id: ${{ github.event.inputs.run_id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Install Certificates
@ -140,72 +86,3 @@ jobs:
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}"

View file

@ -1227,7 +1227,7 @@ jobs:
strategy:
matrix:
go: ["1.25"]
db: [MySQL8.0, SQLite3, Postgres14.0]
db: [MySQL8.0, SQLite3]
if: >
${{ github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success' }}
@ -1358,8 +1358,6 @@ jobs:
echo "YAO_DB_DRIVER=$DB_DRIVER" >> $GITHUB_ENV
if [ "$DB_DRIVER" = "mysql" ]; then
echo "YAO_DB_PRIMARY=$DB_USER:$PASSWORD@$DB_HOST" >> $GITHUB_ENV
elif [ "$DB_DRIVER" = "postgres" ]; then
echo "YAO_DB_PRIMARY=postgres://$DB_USER:$PASSWORD@$DB_HOST" >> $GITHUB_ENV
else
echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV
mkdir -p ${{ github.WORKSPACE }}/../app/db
@ -1406,8 +1404,8 @@ jobs:
strategy:
matrix:
go: ["1.25"]
db: [MySQL8.0, SQLite3, Postgres14.0]
redis: [6]
db: [MySQL8.0, SQLite3]
redis: [4, 5, 6]
mongo: ["6.0"]
if: >
${{ github.event.workflow_run.event == 'pull_request' &&

View file

@ -1,6 +1,7 @@
name: Release Linux
on:
workflow_dispatch:
push:
tags:
- "v*"
@ -19,7 +20,20 @@ jobs:
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
aws configure set default.s3.signature_version s3v4
aws configure set default.s3.endpoint_url https://$R2_ACCOUNT_ID.r2.cloudflarestorage.com
- name: Build
run: |
export PATH=$PATH:/github/home/go/bin
@ -58,6 +72,13 @@ jobs:
mv /app/yao/dist/release/* /data/
ls -l /data
- name: Push To R2
run: |
for file in /data/*; do
aws s3 cp "$file" s3://$R2_BUCKET/archives/ \
--endpoint-url https://$R2_ACCOUNT_ID.r2.cloudflarestorage.com
done
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
@ -85,27 +106,6 @@ jobs:
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
@ -123,6 +123,8 @@ jobs:
with:
context: ./docker/development
platforms: linux/amd64,linux/arm64
build-args: |
VERSION=${{ steps.version.outputs.version }}
push: true
tags: |
${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-dev
@ -133,113 +135,9 @@ jobs:
with:
context: ./docker/production
platforms: linux/amd64,linux/arm64
build-args: |
VERSION=${{ steps.version.outputs.version }}
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
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 Linux Artifacts
uses: actions/download-artifact@v4
with:
name: yao-linux
path: artifacts
- name: Prepare Release Files
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-*
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: 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 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

View file

@ -1,6 +1,7 @@
name: Release macOS
on:
workflow_dispatch:
push:
tags:
- "v*"
@ -216,129 +217,3 @@ jobs:
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

114
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,114 @@
name: Release
on:
workflow_run:
workflows: ["Release Linux", "Release macOS"]
types:
- completed
permissions:
contents: write
jobs:
# ===================================================================
# Wait for both workflows to succeed, then create a unified release
# ===================================================================
release:
runs-on: ubuntu-latest
if: >
github.event.workflow_run.conclusion == 'success' &&
startsWith(github.event.workflow_run.head_branch, 'v')
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Get Version
id: version
run: |
TAG="${{ github.event.workflow_run.head_branch }}"
VERSION="${TAG#v}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "TAG=${TAG} VERSION=${VERSION}"
- name: Wait for Both Workflows
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${{ steps.version.outputs.tag }}"
echo "Waiting for both Release Linux and Release macOS to complete for $TAG..."
for i in $(seq 1 60); do
LINUX_STATUS=$(gh run list --workflow="Release Linux" --branch="$TAG" --limit=1 --json conclusion --jq '.[0].conclusion // "pending"')
MACOS_STATUS=$(gh run list --workflow="Release macOS" --branch="$TAG" --limit=1 --json conclusion --jq '.[0].conclusion // "pending"')
echo "Attempt $i: Linux=$LINUX_STATUS macOS=$MACOS_STATUS"
if [ "$LINUX_STATUS" = "success" ] && [ "$MACOS_STATUS" = "success" ]; then
echo "Both workflows completed successfully."
exit 0
fi
if [ "$LINUX_STATUS" = "failure" ] || [ "$MACOS_STATUS" = "failure" ]; then
echo "::error::One or both workflows failed (Linux=$LINUX_STATUS macOS=$MACOS_STATUS)"
exit 1
fi
sleep 60
done
echo "::error::Timed out waiting for workflows"
exit 1
- name: Download Linux Artifacts
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${{ steps.version.outputs.tag }}"
LINUX_RUN_ID=$(gh run list --workflow="Release Linux" --branch="$TAG" --limit=1 --json databaseId --jq '.[0].databaseId')
mkdir -p dist/linux
gh run download "$LINUX_RUN_ID" --name yao-linux --dir dist/linux
- name: Download macOS Artifacts
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${{ steps.version.outputs.tag }}"
MACOS_RUN_ID=$(gh run list --workflow="Release macOS" --branch="$TAG" --limit=1 --json databaseId --jq '.[0].databaseId')
mkdir -p dist/macos
gh run download "$MACOS_RUN_ID" --name yao-darwin-arm64 --dir dist/macos/arm64-prod
gh run download "$MACOS_RUN_ID" --name yao-darwin-arm64-dev --dir dist/macos/arm64-dev
gh run download "$MACOS_RUN_ID" --name yao-darwin-amd64 --dir dist/macos/amd64-prod
gh run download "$MACOS_RUN_ID" --name yao-darwin-amd64-dev --dir dist/macos/amd64-dev
gh run download "$MACOS_RUN_ID" --name yao-darwin-checksums --dir dist/macos/checksums
- name: Prepare Release Files
run: |
VERSION="${{ steps.version.outputs.version }}"
mkdir -p release
# Linux artifacts (already named correctly from build.sh)
cp dist/linux/* release/ 2>/dev/null || true
# macOS prod binaries
cp dist/macos/arm64-prod/yao "release/yao-${VERSION}-darwin-arm64"
cp dist/macos/amd64-prod/yao "release/yao-${VERSION}-darwin-amd64"
# macOS dev binaries
cp dist/macos/arm64-dev/yao "release/yao-${VERSION}-darwin-arm64-dev"
cp dist/macos/amd64-dev/yao "release/yao-${VERSION}-darwin-amd64-dev"
# Checksums
cp dist/macos/checksums/*.sha256 release/ 2>/dev/null || true
chmod +x release/yao-* 2>/dev/null || true
echo "=== Release files ==="
ls -lh release/
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.tag }}
name: Yao v${{ steps.version.outputs.version }}
files: release/*
generate_release_notes: true

View file

@ -924,7 +924,7 @@ jobs:
strategy:
matrix:
go: ["1.25"]
db: [MySQL8.0, SQLite3, Postgres14.0]
db: [MySQL8.0, SQLite3]
steps:
- name: Checkout Kun
uses: actions/checkout@v4
@ -1021,8 +1021,6 @@ jobs:
echo "YAO_DB_DRIVER=$DB_DRIVER" >> $GITHUB_ENV
if [ "$DB_DRIVER" = "mysql" ]; then
echo "YAO_DB_PRIMARY=$DB_USER:$PASSWORD@$DB_HOST" >> $GITHUB_ENV
elif [ "$DB_DRIVER" = "postgres" ]; then
echo "YAO_DB_PRIMARY=postgres://$DB_USER:$PASSWORD@$DB_HOST" >> $GITHUB_ENV
else
echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV
mkdir -p ${{ github.WORKSPACE }}/../app/db
@ -1068,8 +1066,8 @@ jobs:
strategy:
matrix:
go: ["1.25"]
db: [MySQL8.0, SQLite3, Postgres14.0]
redis: [6]
db: [MySQL8.0, SQLite3]
redis: [4, 5, 6]
mongo: ["6.0"]
steps:
- name: Checkout Kun

View file

@ -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"

10
.gitignore vendored
View file

@ -77,13 +77,3 @@ 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

61
COMMERCIAL_LICENSE.md Normal file
View file

@ -0,0 +1,61 @@
> **DEPRECATED**: This license is no longer in effect. Please refer to the [LICENSE](LICENSE) file for current licensing terms.
# Commercial License for Yao
This document outlines the terms for the commercial license of the **Yao** project. While the Yao project is primarily licensed under the **Apache License, Version 2.0**, certain commercial use cases require a separate commercial license.
## 1. Commercial License Requirements
The following use cases require a commercial license:
1. **Application Hosting Services**
If you use Yao, or any derivative product (such as a forked or modified version of Yao), to provide Yao-based application hosting services (e.g., Software-as-a-Service (SaaS) or Platform-as-a-Service (PaaS)) to users, you must obtain a commercial license. This restriction applies regardless of whether the original Yao code or a modified version is used to host and manage applications on behalf of third-party users for commercial purposes.
**In addition**, if you provide hosting services for applications that are built using Yao (even if they are customized or modified versions of Yao), a commercial license is required.
### Definition: Application Hosting Services
"Application Hosting Services" refers to any service that involves hosting Yao-based applications or web applications created with Yao (including modified versions of Yao) for third-party users. This includes, but is not limited to:
- **Hosting platforms** providing software or services built on top of Yao for third-party users.
- **SaaS or PaaS offerings** where you manage and host applications that are based on or utilize Yao, either in their original or modified form.
- **Managed hosting services** where Yao is used as the underlying technology for applications deployed for external clients.
In these cases, a commercial license is required, whether you are using the original Yao code or a fork/modified version.
2. **AI Web Application Generation Services**
If you provide services that generate AI-driven web applications using Yao, or any derivative product (such as a fork or modified version of Yao), to third-party users, you are required to purchase a commercial license.
### Definition: AI Web Application Generation Services
"AI Web Application Generation Services" refers to any service or functionality that utilizes Yao (or any forked or modified version of Yao) to automate the creation of web applications with AI capabilities. This includes, but is not limited to, providing third-party users with:
- **Automated web application development** driven by AI, where the service generates complete or partial web applications.
- **Customizable web solutions** that are powered by AI and built using Yao as the core technology.
- **On-demand application generation** for specific client needs, using Yao to dynamically build, configure, or deploy applications for users.
In these cases, whether Yao is directly used, forked, or modified, a commercial license is required to operate legally.
## 2. Use Under Apache License 2.0
For all other uses, the **Apache License, Version 2.0** applies. You are free to use, modify, and distribute the Yao project under the terms of Apache 2.0 as long as your usage does not fall within the restricted scenarios outlined above.
## 3. Obtaining a Commercial License
To inquire about or obtain a commercial license, please contact us at:
- **Email**: [friends@iqka.com]
- **Website**: [https://moapi.ai/contact]
Pricing and terms for commercial licenses vary based on usage scenarios, user scale, and other factors.
## 4. Compliance and Auditing
If you have any questions about whether your use case requires a commercial license, please contact us for clarification. We reserve the right to audit usage for compliance and enforce commercial licensing terms where necessary.
## 5. Disclaimer
Failure to comply with these licensing terms may result in a violation of the Yao licensing agreement and could lead to legal action.
---
**Note:** This commercial license is supplementary to the Apache 2.0 license and only applies in specific commercial scenarios outlined above.

View file

@ -0,0 +1,61 @@
> **已废弃**: 本许可证已不再生效。请参考 [LICENSE](LICENSE) 文件获取当前的许可条款。
# Yao 商业许可证
本文件概述了 **Yao** 项目的商业许可证条款。虽然 Yao 项目主要使用 **Apache 许可证 2.0 版** 授权,但某些商业使用场景需要单独的商业许可证。
## 1. 商业许可证要求
以下使用场景需要商业许可证:
1. **应用托管服务**
如果您使用 Yao 或其衍生产品(如 Yao 的分支版本或修改版本)为用户提供基于 Yao 的应用托管服务例如软件即服务SaaS或平台即服务PaaS您必须获得商业许可证。此限制适用于无论是否使用原始 Yao 代码或修改版 Yao 代码,托管和管理应用程序的行为只要是为第三方用户提供的商业目的。
**此外**,如果您提供的托管服务是为使用 Yao 构建的应用程序提供托管服务(即使它们是定制或修改版的 Yao也需要获得商业许可证。
### 定义:应用托管服务
"应用托管服务"指任何涉及托管基于 Yao 的应用程序或使用 Yao 创建的 WEB 应用程序(包括 Yao 的修改版本)的服务,服务对象为第三方用户。包括但不限于:
- **托管平台** 提供基于 Yao 的软件或服务给第三方用户。
- **SaaS 或 PaaS 服务**,在这些服务中,您管理并托管基于或利用 Yao 的应用程序,可能是原版或修改版。
- **托管服务**,其中 Yao 被用作为客户外部部署应用程序的基础技术。
在这些情况下,无论是使用原始 Yao 代码还是修改版 Yao都需要获得商业许可证。
2. **AI WEB 应用生成服务**
如果您提供利用 Yao 或其衍生产品(如 Yao 的分支版本或修改版本)为第三方用户生成 AI 驱动的 WEB 应用程序的服务,您需要购买商业许可证。
### 定义AI WEB 应用生成服务
"AI WEB 应用生成服务"指任何利用 Yao或任何分支版本或修改版本的 Yao自动化创建具有 AI 功能的 WEB 应用程序的服务或功能。包括但不限于,为第三方用户提供以下服务:
- **AI 驱动的自动化 WEB 应用开发**,该服务生成完整或部分 WEB 应用程序。
- **可定制的 WEB 解决方案**,这些解决方案由 AI 提供支持,并以 Yao 作为核心技术构建。
- **按需应用生成**,根据特定客户需求,使用 Yao 动态构建、配置或部署应用程序。
在这些情况下,无论是直接使用 Yao还是使用其分支或修改版均需要获得商业许可证。
## 2. 使用 Apache 许可证 2.0
对于所有其他用途,**Apache 许可证 2.0 版** 适用。只要您的使用不属于上述限制的商业场景,您可以自由地根据 Apache 2.0 许可证使用、修改和分发 Yao 项目。
## 3. 获取商业许可证
如需咨询或获取商业许可证,请通过以下方式联系我们:
- **电子邮件**[friends@iqka.com]
- **网站**[https://moapi.ai/contact](https://moapi.ai/contact)
商业许可证的定价和条款会根据使用场景、用户规模及其他因素有所不同。
## 4. 合规与审计
如果您对您的使用场景是否需要商业许可证有任何疑问,请联系我们以获取澄清。我们保留审核使用情况以确保合规,并在必要时执行商业许可条款的权利。
## 5. 免责声明
未遵守这些许可条款可能会导致违反 Yao 许可证协议,并可能导致法律诉讼。
---
**注意:** 此商业许可证是 Apache 2.0 许可证的补充,仅适用于上述特定的商业场景。

18
LICENSE
View file

@ -1,13 +1,13 @@
# Open Source License
Yao Engine is licensed under a modified version of the Apache License 2.0, with the following additional conditions:
Yao App Engine is licensed under a modified version of the Apache License 2.0, with the following additional conditions:
1. Commercial Usage Terms:
Yao Engine may be utilized commercially, A commercial license from the producer is required if:
Yao App Engine may be utilized commercially, A commercial license from the producer is required if:
a. Trademark and Branding Requirements
- The Yao Engine / Yao Agents / Tai / Tai Link console/application logo and copyright information must not be removed or modified
- The Yao App Engine 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
b. Authorization Verification Requirements
@ -15,16 +15,10 @@ Yao Engine is licensed under a modified version of the Apache License 2.0, with
- 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
c. Enterprise Scale Requirements
- 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
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.
- The producer reserves the right to modify the open-source agreement terms
- Contributed code may be used for commercial purposes, including cloud business operations
All other rights and restrictions follow the Apache License 2.0 (http://www.apache.org/licenses/LICENSE-2.0).
© 2026 Infinite Wisdom Software.
© 2025 Infinite Wisdom Software.

View file

@ -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.0http://www.apache.org/licenses/LICENSE-2.0)。
© 2026 Infinite Wisdom Software.

View file

@ -11,7 +11,6 @@ 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)
@ -37,17 +36,11 @@ TESTTAGS ?= ""
unit-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") -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; \
@ -69,17 +62,11 @@ unit-test:
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; \
$(GO) test -tags $(TESTTAGS) -v -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; \
@ -372,12 +359,6 @@ unit-test-grpc:
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; \

View file

@ -1,71 +1,65 @@
# Yao — App Runtime for the AI Era
# Yao — Build Autonomous Agents. Just Define the Role.
Yao is an open-source runtime for building AI agents and web applications — shipped as a single binary.
Yao is an open-source engine for autonomous agents — event-driven, proactive, and self-scheduling.
![Mission Control](docs/mission-control.png)
**🏠 Homepage:** [https://yaoagents.com](https://yaoagents.com)
**Quick Links:**
**📚 Docs:** [https://yaoagents.com/docs](https://yaoagents.com/docs)
**🏠 Homepage:** [https://yaoapps.com](https://yaoapps.com)
**🖥️ Yao Desktop:** [https://yaoagents.com/download](https://yaoagents.com/download)
**🚀 Quick Start:** [https://yaoapps.com/docs/documentation/en-us/getting-started](https://yaoapps.com/docs/documentation/en-us/getting-started#quickstart)
**📚 Documentation:** [https://yaoapps.com/docs](https://yaoapps.com/docs)
**✨ Why Yao?** [https://yaoapps.com/docs/why-yao](https://yaoapps.com/docs/documentation/en-us/getting-started/why-yao)
**🤖 Yao Agents:** [https://github.com/YaoAgents/awesome](https://github.com/YaoAgents/awesome) ( Preview )
---
## How It Works
## What Makes Yao Different?
Think of Yao Agent as a **cage, not an animal**. What you put inside determines the behavior; the cage keeps it controlled.
| Traditional AI Assistants | Yao Autonomous Agents |
| ----------------------------- | ------------------------------------- |
| Entry point: Chatbox | Entry point: Email, Events, Schedules |
| Passive: You ask, they answer | Proactive: They work autonomously |
| Role: Tool | Role: Team member |
Every request flows through the same pipeline:
![Pipeline](docs/pipeline.png)
`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.**
### Three Modes
| 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 |
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`.
> The entry point is not a chatbox — it's email, events, and scheduled tasks.
---
## Features
### Agent Framework
### Autonomous Agent Framework
- **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
Build agents that work like real team members:
- **Three Trigger Modes** — Clock (scheduled), Human (email/message), Event (webhook/database)
- **Six-Phase Execution** — Inspiration → Goals → Tasks → Run → Deliver → Learn
- **Multi-Agent Orchestration** — Agents delegate, collaborate, and compose dynamically
- **Continuous Learning** — Agents accumulate experience in private knowledge bases
### Native MCP Support
Integrate tools without writing adapters:
- **Process Transport** — Map Yao processes directly to MCP tools
- **External Servers** — Connect via SSE or STDIO
- **Schema Mapping** — Declarative input/output schemas
### Built-in GraphRAG
- **Vector Search** — Embeddings with OpenAI/FastEmbed
- **Knowledge Graph** — Entity-relationship retrieval
- **Hybrid Search** — Combine vector similarity with graph traversal
### Full-Stack Runtime
Everything in a single executable:
- **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
### Built-in Search
- **Vector Search** — Embeddings with OpenAI or FastEmbed
- **Knowledge Graph** — Entity-relationship retrieval
- **GraphRAG** — Hybrid vector + graph search
---
## About the Name
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.
- **All-in-One** — Data, API, Agent, UI in one engine
- **TypeScript Support** — Built-in V8 engine
- **Single Binary** — No Node.js, Python, or containers required
- **Edge-Ready** — Runs on ARM64/x64 devices

View file

@ -1,73 +1,83 @@
# Yao — AI 时代的应用运行时
# Yao
Yao 是一个开源的 AI Agent 和 Web 应用运行时,以单一二进制的形式发布,下载即用。
[![UnitTest](https://github.com/YaoApp/yao/actions/workflows/unit-test.yml/badge.svg)](https://github.com/YaoApp/yao/actions/workflows/unit-test.yml)
[![codecov](https://codecov.io/gh/YaoApp/yao/branch/main/graph/badge.svg?token=294Y05U71J)](https://codecov.io/gh/YaoApp/yao)
![Mission Control](docs/mission-control.png)
**🏠 官网:** [https://yaoagents.com](https://yaoagents.com)
**📚 文档:** [https://yaoagents.com/docs](https://yaoagents.com/docs)
**🖥️ Yao Desktop** [https://yaoagents.com/download](https://yaoagents.com/download)
https://github.com/YaoApp/yao/assets/1842210/6b23ac89-ef6e-4c24-874f-753a98370dec
[English](README.md)
---
YAO 是一款开源应用引擎,使用 Golang 编写,以一个命令行工具的形式存在, 下载即用。适合用于开发业务系统、网站/APP API 接口、管理后台、自建低代码平台等。
## 工作原理
YAO 采用 flow-based 的编程模式,通过编写 YAO DSL (JSON 格式逻辑描述) 或使用 JavaScript 编写处理器,实现各种功能。 YAO DSL 可以有多种编写方式:
Yao Agent 本质上是一个**笼子,而不是动物**。放进去的东西决定行为,笼子保证可控。
1. 纯手工编写
每个请求都经过同一套管道:
2. 使用自动化脚本,根据上下文逻辑生成
![Pipeline](docs/pipeline.png)
3. 使用可视化编辑器,通过“拖拉拽”制作
`Create Hook` 在执行器前运行 —— 注入上下文、施加约束、路由请求。
`Next Hook` 在执行器后运行 —— 校验输出、触发下游动作、驱动多步循环。
**AI 负责干活,你来划定边界。**
官网: [https://yaoapps.com](https://yaoapps.com)
### 三种模式
文档: [https://yaoapps.com/doc](https://yaoapps.com/doc)
| 模式 | 执行器 | 适用场景 |
|------|--------|---------|
| **LLM** | OpenAI、Anthropic 等 | 对话助手、问答、内容生成 |
| **CLI Agent** | 容器中的 OpenCode、Claude Code、Codex | Computer Use、沙箱隔离、SKILL 生态 |
| **纯 Hook** | 你自己的 TypeScript 代码 | 确定性逻辑、菜单路由、无需 AI 的业务流程 |
## 最新版本下载安装 (推荐)
三种模式共享同一套 Hook 接口,可以自由混合 —— 在一个 `Create Hook` 里,部分请求走 LLM部分用纯代码处理。
https://github.com/YaoApp/xgen-dev-app
---
## 演示
## 功能特性
![界面](docs/yao-setup-demo.jpg)
### Agent 框架
使用 YAO 开发的应用
- **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
| 应用 | 简介 | 代码仓库 |
| -------------------- | ---------------------------- | --------------------------------------- |
| yaoapp/yao-examples | Yao 应用示例 | https://github.com/YaoApp/yao-examples |
| yaoapp/yao-knowledge | ChatGPT 驱动的知识管理库应用 | https://github.com/YaoApp/yao-knowledge |
| yaoapp/xgen-dev-app | 演示应用 (演示) | https://github.com/YaoApp/xgen-dev-app |
| yaoapp/demo-project | 工程项目管理演示应用(演示) | https://github.com/yaoapp/demo-project |
| yaoapp/demo-finance | 财务管理演示应用(演示) | https://github.com/yaoapp/demo-finance |
| yaoapp/demo-plm | 生产项目管理演示应用(演示) | https://github.com/yaoapp/demo-plm |
### 全栈运行时
## 介绍
一个二进制文件包含所有能力:
Yao 是一个只需使用 JSON 即可创建数据库模型、编写 API 接口、描述管理后台界面的应用引擎,使用 Yao 构建的应用可运行在云端或物联网设备上。 开发者不需要写一行代码,就可以拥有 10 倍生产力。
- **数据模型** — 用 JSON/YAML 定义数据库表和关联关系
- **REST API** — 将路由映射到模型查询或 TypeScript 处理器
- **SUI 页面** — 组件化 Web UI支持服务端渲染
- **Chat UICUI** — 内置对话界面,开箱即用
- **TypeScript** — 内置 V8 引擎,不依赖 Node.js
- **单一二进制** — 支持 ARM64/x64宿主机无需 Python、Node 或容器
Yao 基于 **flow-based** 编程思想,采用 **Go** 语言开发,支持多种方式扩展数据流处理器。这使得 Yao 具有极好的**通用性**,大部分场景下可以代替编程语言, 在复用性和编码效率上是传统编程语言的 **10 倍**;应用性能和资源占比上优于 **PHP**, **JAVA** 等语言。
### 内置搜索
Yao 内置了一套数据管理系统,通过编写 **JSON** 描述界面布局,即可实现 90% 常见界面交互功能特别适合快速制作各类管理后台、CRM、ERP 等企业内部系统。对于特殊交互功能亦可通过编写扩展组件或 HTML 页面的方式实现。内置管理系统与 Yao 并不耦合,亦可采用 **VUE**, **React** 等任意前端技术实现管理界面。
- **向量搜索** — 支持 OpenAI 或 FastEmbed 嵌入模型
- **知识图谱** — 实体关系检索
- **GraphRAG** — 向量 + 图谱混合搜索
## 安装
---
Yao v0.10.4 使用说明
## 关于名字
https://github.com/YaoApp/xgen-dev-app/blob/main/README.zh-CN.md
Yao 的名字源于汉字**爻yáo**,是构成八卦的基本符号。八卦,是上古大神伏羲观测自然规律后创造的符号体系。爻有阴阳两种状态,就像 0 和 1。爻的阴阳转换驱动八卦更替记录事物的发展规律。
## 入门指南
详细说明请看[文档](https://yaoapps.com/doc/%E4%BB%8B%E7%BB%8D/%E5%85%A5%E9%97%A8%E6%8C%87%E5%8D%97)
### 创建应用
#### 新建一个空白应用
新建一个应用目录,进入应用目录,运行 `yao start` 命令, 启动安装界面。
```bash
mkdir -p /data/app # 创建应用目录
cd /data/app # 进入应用目录
yao start # 启动安装界面
```
**默认账号**
- 用户名: **xiang@iqka.com**
- 密码: **A123456p+**
![安装界面](docs/yao-setup-step2.jpg)
## 关于 Yao
Yao 的名字源于汉字**爻(yáo)**,是构成八卦的基本符号。八卦,是上古大神伏羲观测总结自然规律后,创造的一个可以指代万事万物的符号体系。爻,有阴阳两种状态,就像 0 和 1。爻的阴阳转换驱动八卦更替以此来总结记录事物的发展规律。

View file

@ -2,19 +2,20 @@ package assistant
import (
"fmt"
"log"
"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"
sandboxTypes "github.com/yaoapp/yao/agent/sandbox/v2/types"
infraV2 "github.com/yaoapp/yao/sandbox/v2"
)
// Stream stream the agent
@ -166,29 +167,22 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
var sandboxLoadingMsgID string
// V2 sandbox state
var v2Init *sandboxV2InitResult
var v2Runner sandboxTypes.Runner
var v2Computer infraV2.Computer
var v2LoadingMsgID string
if ast.HasSandboxV2() {
ctx.Logger.Phase("Sandbox V2")
var err error
v2Init, err = ast.initSandboxV2(ctx, opts)
var v2Cleanup func()
v2Runner, v2Computer, v2Cleanup, v2LoadingMsgID, err = ast.initSandboxV2(ctx, opts)
if err != nil {
ast.traceAgentFail(agentNode, err)
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
return nil, err
}
sandboxCleanup = v2Init.Cleanup
sandboxCleanup = v2Cleanup
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
@ -324,23 +318,13 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
// 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" {
if ast.HasSandboxV2() && v2Runner != nil && v2Computer != nil && v2Runner.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" {
completionResponse, err = ast.executeSandboxV2Stream(ctx, completionMessages, agentNode, streamHandler, v2Runner, v2Computer, v2LoadingMsgID)
} else if ast.HasSandboxV2() && v2Runner != nil && v2Runner.Name() == "yao" {
// V2 yao runner: Prepare is done, close loading, fall through to LLM
if v2Init.LoadingMsgID != "" {
closeLoadingV2(ctx, v2Init.LoadingMsgID, "")
if v2LoadingMsgID != "" {
closeLoadingV2(ctx, v2LoadingMsgID, "")
}
completionResponse, err = ast.executeLLMStream(ctx, completionMessages, completionOptions, agentNode, streamHandler, opts)
} else if ast.HasSandbox() {
@ -565,47 +549,11 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
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
// No Next hook: use standard response
finalResponse = ast.buildStandardResponse(&NextProcessContext{
Context: ctx,
NextResponse: nil,
CompletionResponse: completionResponse,
FullMessages: fullMessages,
ToolCallResponses: toolCallResponses,
@ -658,41 +606,35 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
return finalResponse, nil
}
// GetConnector get the connector object, capabilities, and error.
// Priority: opts.Connector > ast.Connector (may be "use::<role>") > "default" role > legacy fallback
// GetConnector get the connector object, capabilities, and error with priority:
// opts.Connector > ast.Connector > defaultConnector (fallback)
// Note: opts.Connector may be set by Create hook's applyOptionsAdjustments
// Returns: (connector, capabilities, error)
func (ast *Assistant) GetConnector(ctx *context.Context, opts ...*context.Options) (connector.Connector, *goullm.Capabilities, error) {
cid := ast.Connector
connectorID := ast.Connector
if len(opts) > 0 && opts[0] != nil && opts[0].Connector != "" {
cid = opts[0].Connector
connectorID = opts[0].Connector
}
// Extract identity for role-based resolution
var identity llmprovider.Identity
if ctx != nil && ctx.Authorized != nil {
identity = ctx.Authorized
if connectorID == "" {
connectorID = defaultConnector
}
// 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
}
if connectorID == "" {
return nil, nil, fmt.Errorf("connector not specified")
}
return nil, nil, fmt.Errorf("connector not specified")
conn, err := connector.Select(connectorID)
if err != nil && connectorID != defaultConnector && defaultConnector != "" {
log.Printf("[Assistant] connector %q not found, falling back to default %q", connectorID, defaultConnector)
conn, err = connector.Select(defaultConnector)
}
if err != nil {
return nil, nil, err
}
capabilities := llm.GetCapabilitiesFromConn(conn)
return conn, capabilities, nil
}
// Info get the assistant information
@ -748,6 +690,12 @@ func (ast *Assistant) sendAgentStreamEnd(ctx *context.Context, handler message.S
return
}
// Check if context is cancelled - if so, skip handler call to avoid blocking
if ctx.Context != nil && ctx.Context.Err() != nil {
ctx.Logger.Debug("Context cancelled, skipping sendAgentStreamEnd handler call")
return
}
endData := &message.EventStreamEndData{
RequestID: ctx.RequestID(),
ContextID: ctx.ID,
@ -779,17 +727,25 @@ func (ast *Assistant) sendStreamEndOnError(ctx *context.Context, handler message
// 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 {
// Handle based on interrupt type
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"))
}
// Force interrupt: context is already cancelled in handleSignal
// LLM streaming will detect ctx.Interrupt.Context().Done() and stop
ctx.Logger.Debug("Force interrupt: stopping current operations immediately")
case context.InterruptGraceful:
ctx.Logger.Debug("Graceful interrupt received: messages=%d", len(signal.Messages))
ctx.Logger.Debug("Graceful interrupt: will process after current step completes")
// Graceful interrupt: let current operation complete
// The signal is stored in current/pending, can be checked at checkpoints
}
// TODO: Implement actual interrupt handling logic:
// 1. For graceful: wait for current step, then merge messages and restart
// 2. For force: immediately stop and restart with new messages
// 3. Call Interrupted Hook if configured
// 4. Decide whether to continue, restart, or abort based on Hook response
return nil
}
@ -831,10 +787,9 @@ func (ast *Assistant) buildToolRetryMessages(
// Add assistant message with tool calls
assistantMsg := context.Message{
Role: context.RoleAssistant,
Content: completionResponse.Content,
ReasoningContent: completionResponse.ReasoningContent,
ToolCalls: completionResponse.ToolCalls,
Role: context.RoleAssistant,
Content: completionResponse.Content,
ToolCalls: completionResponse.ToolCalls,
}
retryMessages = append(retryMessages, assistantMsg)

View file

@ -3,7 +3,6 @@ package assistant
import (
"fmt"
"path"
"strings"
"github.com/yaoapp/gou/fs"
"github.com/yaoapp/yao/agent/caller"
@ -13,7 +12,6 @@ import (
"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"
)
@ -28,28 +26,6 @@ func init() {
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()
@ -171,8 +147,8 @@ func (ast *Assistant) Map() map[string]interface{} {
"uses": ast.Uses,
"search": ast.Search,
"dependencies": ast.Dependencies,
"created_at": utils.NanoToTime(ast.CreatedAt),
"updated_at": utils.NanoToTime(ast.UpdatedAt),
"created_at": store.ToMySQLTime(ast.CreatedAt),
"updated_at": store.ToMySQLTime(ast.UpdatedAt),
}
}
@ -184,6 +160,9 @@ func (ast *Assistant) Validate() error {
if ast.Name == "" {
return fmt.Errorf("name is required")
}
if ast.Connector == "" {
return fmt.Errorf("connector is required")
}
return nil
}

View file

@ -203,11 +203,6 @@ func (ast *Assistant) buildContextVariables(ctx *context.Context) map[string]str
}
}
// Workspace info
if workspaceID, err := ctx.GetWorkspaceID(); err == nil {
vars["WORKSPACE_ID"] = workspaceID
}
if ctx == nil {
return vars
}

View file

@ -18,7 +18,6 @@ import (
)
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"
@ -82,7 +81,6 @@ func TestGetChatKBID(t *testing.T) {
}
func TestPrepareKBCollection(t *testing.T) {
t.Skip("KB/DB search temporarily disabled")
testutils.Prepare(t)
defer testutils.Clean(t)

View file

@ -46,9 +46,6 @@ func DefaultStreamHandler(ctx *context.Context) message.StreamFunc {
case message.ChunkToolCall:
return state.handleToolCall(data)
case message.ChunkExecute:
return state.handleExecute(data)
case message.ChunkMetadata:
return state.handleMetadata(data)
@ -75,11 +72,9 @@ type streamState struct {
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)
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
}
// handleStreamStart handles stream start event
@ -117,6 +112,7 @@ func (s *streamState) handleMessageStart(data []byte) int {
startData.ThreadID = s.ctx.Stack.ID
}
// Initialize message state with the correct message ID
s.inGroup = true
s.currentGroupID = messageID
s.buffer = []byte{}
@ -294,75 +290,11 @@ func (s *streamState) handleToolCall(data []byte) int {
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.
// handleMetadata handles metadata chunks (usage, finish_reason, etc.)
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
// Metadata is usually not displayed to users
// Could be logged or stored for analytics
return 0 // Continue
}
// handleError handles error chunks
@ -380,6 +312,7 @@ func (s *streamState) handleMessageEnd(data []byte) int {
return 0
}
// Calculate duration
durationMs := time.Since(s.groupStartTime).Milliseconds()
// Use the tracked message type (thinking, text, tool_call, etc.)
@ -408,23 +341,16 @@ func (s *streamState) handleMessageEnd(data []byte) int {
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 {
if s.ctx.Buffer != nil && len(s.buffer) > 0 && !shouldSkipHistory {
assistantID := ""
if s.ctx.Stack != nil {
assistantID = s.ctx.Stack.AssistantID
}
// Build props based on message type
var props map[string]interface{}
switch msgType {
case message.TypeToolCall:
if msgType == message.TypeToolCall {
// For tool calls, try to parse the accumulated buffer as JSON
var toolCallData interface{}
if err := jsoniter.Unmarshal(s.buffer, &toolCallData); err == nil {
props = map[string]interface{}{
@ -435,25 +361,15 @@ func (s *streamState) handleMessageEnd(data []byte) int {
"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:
} else {
// For text/thinking, content is the accumulated text
props = map[string]interface{}{
"content": string(s.buffer),
}
}
s.ctx.Buffer.AddAssistantMessage(
s.currentGroupID,
s.currentGroupID, // Use the message ID
msgType,
props,
blockID,
@ -487,8 +403,6 @@ func (s *streamState) handleMessageEnd(data []byte) int {
s.currentType = ""
s.buffer = []byte{}
s.chunkCount = 0
s.lastExecStatus = ""
s.lastExecProps = nil
return 0 // Continue
}

View file

@ -390,7 +390,6 @@ func LoadPath(path string) (*Assistant, error) {
return nil, fmt.Errorf("load sandbox.yao: %w", sbErr)
}
data["__sandbox_v2"] = sbCfg
data["sandbox"] = sbCfg
}
ast, err := loadMap(data)
@ -881,11 +880,20 @@ func loadMap(data map[string]interface{}) (*Assistant, error) {
// Init init the assistant
// Choose the connector and initialize the assistant
func (ast *Assistant) initialize() error {
conn := defaultConnector
if ast.Connector != "" {
conn = ast.Connector
}
ast.Connector = conn
// Register scripts as process handlers
if len(ast.Scripts) > 0 {
if err := ast.RegisterScripts(); err != nil {
return fmt.Errorf("failed to register scripts: %w", err)
}
}
return nil
}
@ -1092,15 +1100,8 @@ func mergeSearchConfig(base, override *searchTypes.Config) *searchTypes.Config {
// extractSandboxVersion tries to read the "version" field from a sandbox config value.
func extractSandboxVersion(v any) string {
switch sb := v.(type) {
case *sandboxTypes.SandboxConfig:
if sb != nil {
return sb.Version
}
case sandboxTypes.SandboxConfig:
return sb.Version
case map[string]any:
if ver, ok := sb["version"].(string); ok {
if m, ok := v.(map[string]any); ok {
if ver, ok := m["version"].(string); ok {
return ver
}
}

View file

@ -27,30 +27,19 @@ var systemAgents = []string{
"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
Default string // Default connector for all system agents
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
}
// systemConfig holds the system agents configuration (global variable like others in load.go)
@ -167,9 +156,10 @@ func loadSystemAgent(id, pathPrefix string) (*Assistant, error) {
pkgData["type"] = "assistant"
}
// Override connector only if agent.yml has an explicit per-agent setting
if override := resolveSystemConnector(id); override != "" {
pkgData["connector"] = override
// Resolve connector for this system agent
connectorID := resolveSystemConnector(id)
if connectorID != "" {
pkgData["connector"] = connectorID
}
// Read prompts.yml from bindata (default prompts)
@ -213,36 +203,55 @@ func loadSystemAgent(id, pathPrefix string) (*Assistant, error) {
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.
// resolveSystemConnector resolves the connector for a system agent
// Priority: specific agent config > system.default > defaultConnector > fallback to first capable connector
func resolveSystemConnector(agentID string) string {
if systemConfig == nil {
return ""
// Try specific agent config first
if systemConfig != nil {
switch agentID {
case "__yao.keyword":
if systemConfig.Keyword != "" {
return systemConfig.Keyword
}
case "__yao.querydsl":
if systemConfig.QueryDSL != "" {
return systemConfig.QueryDSL
}
case "__yao.title":
if systemConfig.Title != "" {
return systemConfig.Title
}
case "__yao.prompt":
if systemConfig.Prompt != "" {
return systemConfig.Prompt
}
case "__yao.robot_prompt":
if systemConfig.RobotPrompt != "" {
return systemConfig.RobotPrompt
}
case "__yao.needsearch":
if systemConfig.NeedSearch != "" {
return systemConfig.NeedSearch
}
case "__yao.entity":
if systemConfig.Entity != "" {
return systemConfig.Entity
}
}
// Try system default
if systemConfig.Default != "" {
return systemConfig.Default
}
}
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
// Try global default connector
if defaultConnector != "" {
return defaultConnector
}
return ""
// Fallback: find first connector that supports tool calling
return findCapableConnector()
}
// findCapableConnector finds the first connector that supports tool calling

View file

@ -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"))
}

View file

@ -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)
}

View file

@ -375,6 +375,12 @@ func (ast *Assistant) executeSingleToolCall(ctx *agentContext.Context, toolCall
return []ToolCallResult{result}, true
}
// Check if result is an error
if callResult.IsError {
result.Error = fmt.Errorf("MCP tool error")
result.IsRetryableError = false // MCP internal error is not retryable
}
// Serialize the Content field only ([]ToolContent)
contentBytes, err := jsoniter.Marshal(callResult.Content)
if err != nil {
@ -390,19 +396,6 @@ func (ast *Assistant) executeSingleToolCall(ctx *agentContext.Context, toolCall
}
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 {
@ -552,7 +545,7 @@ func (ast *Assistant) executeServerToolsParallelWithTrace(mcpCtx context.Context
// Prepare parallel trace inputs
var parallelInputs []types.TraceParallelInput
mcpCalls := make([]mcpTypes.ToolCall, 0, len(toolCalls))
orderedCalls := make([]agentContext.ToolCall, 0, len(toolCalls))
callMap := make(map[string]agentContext.ToolCall)
for _, tc := range toolCalls {
_, toolName, ok := ParseMCPToolName(tc.Function.Name)
@ -572,7 +565,7 @@ func (ast *Assistant) executeServerToolsParallelWithTrace(mcpCtx context.Context
Name: toolName,
Arguments: args,
})
orderedCalls = append(orderedCalls, tc)
callMap[toolName] = tc
ctx.Logger.ToolStart(tc.Function.Name)
// Add trace input for this tool
@ -613,8 +606,10 @@ func (ast *Assistant) executeServerToolsParallelWithTrace(mcpCtx context.Context
if node != nil {
node.Fail(err)
}
if i < len(orderedCalls) {
ctx.Logger.ToolComplete(orderedCalls[i].Function.Name, false)
if i < len(mcpCalls) {
if tc, ok := callMap[mcpCalls[i].Name]; ok {
ctx.Logger.ToolComplete(tc.Function.Name, false)
}
}
}
return nil, true
@ -626,7 +621,7 @@ func (ast *Assistant) executeServerToolsParallelWithTrace(mcpCtx context.Context
for i, mcpResult := range mcpResponse.Results {
toolName := mcpCalls[i].Name
originalCall := orderedCalls[i]
originalCall := callMap[toolName]
var toolNode types.Node
if i < len(toolNodes) {
toolNode = toolNodes[i]
@ -814,12 +809,19 @@ func (ast *Assistant) executeServerToolsSequentialWithTrace(mcpCtx context.Conte
toolNode.Fail(err)
}
} else {
// Check if result is an error
if mcpResult.IsError {
result.Error = fmt.Errorf("MCP tool error")
result.IsRetryableError = false // MCP internal error is not retryable
hasErrors = true
}
// 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
result.IsRetryableError = false // Serialization error is not retryable
hasErrors = true
ctx.Logger.ToolComplete(tc.Function.Name, false)
if toolNode != nil {
@ -827,24 +829,11 @@ func (ast *Assistant) executeServerToolsSequentialWithTrace(mcpCtx context.Conte
}
} 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,
})
}
ctx.Logger.ToolComplete(tc.Function.Name, !mcpResult.IsError)
if toolNode != nil {
toolNode.Complete(map[string]any{
"result": mcpResult.Content,
})
}
}
}

View file

@ -10,7 +10,6 @@ import (
"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"
@ -267,26 +266,30 @@ func (ast *Assistant) buildSandboxOptions(ctx *context.Context, opts *context.Op
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
}
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)
// Extract extra connector options (thinking, max_tokens, temperature, etc.)
// These are backend-specific parameters that need to be passed through to the proxy
connectorOptions := make(map[string]interface{})
for k, v := range setting {
// Skip standard fields that are already handled
switch k {
case "host", "key", "model", "azure", "capabilities":
continue
default:
// Include all other fields as extra options
connectorOptions[k] = v
}
}
if len(connectorOptions) > 0 {
execOpts.ConnectorOptions = connectorOptions
ctx.Logger.Debug("Connector options extracted: %v", connectorOptions)

View file

@ -1,22 +1,17 @@
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"
@ -27,24 +22,13 @@ 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
// runs Prepare, and returns the runner, computer, cleanup closure, loading
// message ID, and any error.
func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options) (
sandboxTypes.Runner, infraV2.Computer, func(), string, error,
) {
cfg := ast.SandboxV2
manager := infraV2.M()
loadingMsg := &message.Message{
@ -61,12 +45,9 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
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)
return nil, nil, 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)
@ -96,12 +77,12 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
}
}
// 3. Obtain Computer.
// 3. Obtain Computer (passes connector for OPENAI_PROXY_* env injection).
updateLoadingV2(ctx, loadingMsgID, "sandbox.starting")
computer, identifier, err := sandboxv2.GetComputer(ctx, cfg, manager)
computer, identifier, err := sandboxv2.GetComputer(ctx, cfg, manager, conn)
if err != nil {
closeLoadingV2(ctx, loadingMsgID, "sandbox.failed")
return nil, fmt.Errorf("getComputer failed: %w", err)
return nil, nil, nil, "", fmt.Errorf("getComputer failed: %w", err)
}
_ = identifier
@ -110,15 +91,13 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
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)
return nil, nil, nil, "", fmt.Errorf("get runner %q: %w", cfg.Runner.Name, err)
}
// 5. Resolve assistant directory and skills subdirectory.
assistantDir := ""
// 5. Resolve skills directory.
skillsDir := ""
if ast.Path != "" {
assistantDir = filepath.Join(config.Conf.AppSource, ast.Path)
dir := filepath.Join(assistantDir, "skills")
dir := filepath.Join(config.Conf.AppSource, ast.Path, "skills")
if info, e := os.Stat(dir); e == nil && info.IsDir() {
skillsDir = dir
}
@ -138,81 +117,62 @@ func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options)
// 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,
Computer: computer,
Config: cfg,
Connector: conn,
SkillsDir: skillsDir,
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)
return nil, nil, nil, "", fmt.Errorf("runner.Prepare: %w", err)
}
// Inject computer + workspace into context so Create/Next hooks
// can access ctx.computer and ctx.workspace.
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)
// Defensive fallback — executeSandboxV2Stream defer handles the
// normal case; this covers paths that never reach execution.
}
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
return runner, computer, cleanup, loadingMsgID, nil
}
// executeSandboxV2Stream calls the V2 Runner.Stream and wraps it in the
// standard completion response.
func (ast *Assistant) executeSandboxV2Stream(
ctx *context.Context, p *sandboxV2StreamParams,
ctx *context.Context,
completionMessages []context.Message,
agentNode traceTypes.Node,
streamHandler message.StreamFunc,
runner sandboxTypes.Runner,
computer infraV2.Computer,
loadingMsgID string,
) (*context.CompletionResponse, error) {
_ = p.AgentNode
_ = agentNode
cfg := p.Config
cfg := ast.SandboxV2
manager := infraV2.M()
// Build system prompt (parse $CTX variables the same way as buildSystemPrompts).
// Build system prompt.
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
for _, p := range ast.Prompts {
if p.Role == "system" && p.Content != "" {
systemPrompt = p.Content
break
}
}
}
// Resolve connector for Stream (respects user-selected connector via opts).
conn, _, _ := ast.GetConnector(ctx, p.Options)
// Resolve connector for Stream.
conn, _, _ := ast.GetConnector(ctx)
var tok *sandboxTypes.SandboxToken
if ctx.Authorized != nil {
@ -224,49 +184,25 @@ func (ast *Assistant) executeSandboxV2Stream(
}
streamReq := &sandboxTypes.StreamRequest{
Computer: p.Computer,
Computer: computer,
Config: cfg,
Connector: conn,
Roles: p.Roles,
AssistantID: ast.ID,
Messages: p.Messages,
Messages: completionMessages,
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,
Computer: computer,
Runner: runner,
Config: cfg,
StreamReq: streamReq,
Manager: manager,
LoadingMsgID: p.LoadingMsgID,
LoadingMsgID: 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
return sandboxv2.ExecuteSandboxStream(ctx, execReq, streamHandler)
}
// initStandaloneWorkspace loads the workspace FS into context when no sandbox

View file

@ -91,7 +91,7 @@ func parseSearchField(search any) *SearchIntent {
if v {
return &SearchIntent{
NeedSearch: true,
SearchTypes: []string{"web"}, // TODO: 恢复 KB/DB 搜索时改回 []string{"web", "kb", "db"}
SearchTypes: []string{"web", "kb", "db"},
Confidence: 1.0,
Reason: "enabled by hook",
}

View file

@ -62,7 +62,6 @@ func (c *authTestCollections) cleanup(ctx context.Context, t *testing.T) {
// 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)
@ -156,7 +155,6 @@ func TestKBCollectionAuthFilter(t *testing.T) {
// ========== 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)
@ -275,7 +273,6 @@ func TestDBAuthWheresFilter(t *testing.T) {
// ========== KB Search Integration Tests ==========
func TestKBSearchIntegration(t *testing.T) {
t.Skip("KB/DB search temporarily disabled")
testutils.Prepare(t)
defer testutils.Clean(t)

View file

@ -52,28 +52,23 @@ func TestSearchAutoFull(t *testing.T) {
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")
@ -92,7 +87,6 @@ func TestSearchAutoFull(t *testing.T) {
})
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)

View file

@ -15,7 +15,3 @@ type AgentCaller interface {
// 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

View file

@ -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) }

View file

@ -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) }"

View file

@ -273,14 +273,14 @@ func TestProcessCall_Timeout_Short(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
// Set timeout=1 second — LLM round-trip will certainly exceed this.
// Set timeout=2 seconds — LLM round-trip will certainly exceed this.
// Verifies that the timeout parameter is respected and produces an error.
proc := newLLMProcess(t, "agent.call", map[string]interface{}{
"assistant_id": "tests.simple-greeting",
"messages": []interface{}{
map[string]interface{}{"role": "user", "content": "Tell me a very long story about the history of computing."},
},
"timeout": 1,
"timeout": 2,
})
err := proc.Execute()

View file

@ -77,15 +77,7 @@ func parseContentParts(ctx *agentContext.Context, message agentContext.Message,
for _, part := range content {
parsedPart, refs, err := parseContentPart(ctx, part, options)
if err != nil {
if part.Type == agentContext.ContentImageURL {
parts = append(parts, agentContext.ContentPart{
Type: agentContext.ContentText,
Text: "[Image content could not be processed]",
})
} else {
parts = append(parts, part)
}
log.Error("Failed to parse content part type=%s: %v", part.Type, err)
parts = append(parts, part)
continue
}
parts = append(parts, parsedPart)

View file

@ -12,7 +12,6 @@ import (
"github.com/yaoapp/yao/agent/output/message"
searchTypes "github.com/yaoapp/yao/agent/search/types"
"github.com/yaoapp/yao/attachment"
toolsImage "github.com/yaoapp/yao/tools/image"
)
// Image handles image content
@ -66,27 +65,24 @@ func (h *Image) Parse(ctx *agentContext.Context, content agentContext.ContentPar
return h.base64(ctx, content, visionFormat)
}
// Model doesn't support vision - fallback chain:
// 1. Cache -> 2. Uses.Vision (explicit config) -> 3. tools/vision (auto) -> 4. Placeholder text
// Model doesn't support vision - check cache first, then use vision agent/MCP
// Try to get cached text (from attachment's content_preview)
cachedText, found, err := h.readFromCache(ctx, content.ImageURL.URL)
if err == nil && found {
// Cache hit! Return as text content
return agentContext.ContentPart{
Type: agentContext.ContentText,
Text: cachedText,
}, nil, nil
}
// No cache, try to use vision agent/MCP
if h.options.CompletionOptions != nil && h.options.CompletionOptions.Uses != nil && h.options.CompletionOptions.Uses.Vision != "" {
return h.agent(ctx, content)
}
if text, err := h.readImageWithTools(ctx, content); err == nil {
h.saveToCache(ctx, content.ImageURL.URL, text)
return agentContext.ContentPart{Type: agentContext.ContentText, Text: text}, nil, nil
}
return agentContext.ContentPart{Type: agentContext.ContentText, Text: "[Image content - vision model not available]"}, nil, nil
// No vision support and no vision tool specified, return error
return content, nil, fmt.Errorf("model doesn't support vision and no vision tool specified in uses.Vision")
}
// base64 encodes image content to base64 (for vision support)
@ -364,37 +360,6 @@ func (h *Image) callMCPVisionTool(ctx *agentContext.Context, serverID string, co
return result, err
}
// readImageWithTools calls tools/vision.ReadImage to convert image to text
// using a vision-capable model resolved via llmprovider.
func (h *Image) readImageWithTools(ctx *agentContext.Context, content agentContext.ContentPart) (string, error) {
if ctx.Authorized == nil {
return "", fmt.Errorf("no auth info available for vision model resolution")
}
src := wrapperToAttachURI(content.ImageURL.URL)
loadingID := h.sendLoading(ctx, i18n.T(ctx.Locale, "content.image.analyzing"))
resp, err := toolsImage.ReadImage(ctx.Context, src, "Please describe this image in detail.", 1080, ctx.Authorized, "")
h.sendLoadingDone(ctx, loadingID)
if err != nil {
return "", err
}
return resp.Content, nil
}
// wrapperToAttachURI converts __uploader://fileID to attach://uploader/fileID
// format expected by tools/vision.readBytes.
func wrapperToAttachURI(url string) string {
uploaderName, fileID, ok := attachment.Parse(url)
if !ok {
return url
}
return "attach://" + uploaderName + "/" + fileID
}
// sendLoading sends a loading message and returns the message ID
// Returns empty string if SilentLoading is enabled
func (h *Image) sendLoading(ctx *agentContext.Context, msg string) string {

View file

@ -117,12 +117,11 @@ func TestParseWithoutVisionSupport(t *testing.T) {
}
handler := image.New(options)
result, _, err := handler.Parse(ctx, content)
_, _, err := handler.Parse(ctx, content)
// Should return placeholder text (no error) when no vision support
assert.NoError(t, err)
assert.Equal(t, agentContext.ContentText, result.Type)
assert.Contains(t, result.Text, "Image content")
// Should return error because no vision support and no vision tool specified
assert.Error(t, err)
assert.Contains(t, err.Error(), "no vision tool specified")
}
// TestParseWithEmptyURL tests parsing image with empty URL

View file

@ -382,14 +382,9 @@ func (b *ChatBuffer) GetStepsForResume(finalStatus string) []*BufferedStep {
b.currentStep.Status = finalStatus
}
// Only return steps with valid resume status (failed or interrupted)
result := make([]*BufferedStep, 0, len(b.steps))
for _, step := range b.steps {
if step.Status != ResumeStatusFailed && step.Status != ResumeStatusInterrupted {
continue
}
result = append(result, step)
}
// Return all steps (they will all have the context for recovery)
result := make([]*BufferedStep, len(b.steps))
copy(result, b.steps)
return result
}

View file

@ -498,10 +498,10 @@ func TestBufferGetStepsForResume(t *testing.T) {
steps := buffer.GetStepsForResume(context.ResumeStatusFailed)
require.NotNil(t, steps)
assert.Len(t, steps, 1)
assert.Len(t, steps, 2)
// Only the failed step should be returned
assert.Equal(t, context.ResumeStatusFailed, steps[0].Status)
// Current step should be marked as failed
assert.Equal(t, context.ResumeStatusFailed, steps[1].Status)
})
t.Run("InterruptedRequest", func(t *testing.T) {
@ -516,8 +516,8 @@ func TestBufferGetStepsForResume(t *testing.T) {
steps := buffer.GetStepsForResume(context.ResumeStatusInterrupted)
require.NotNil(t, steps)
assert.Len(t, steps, 1)
assert.Equal(t, context.ResumeStatusInterrupted, steps[0].Status)
assert.Len(t, steps, 3)
assert.Equal(t, context.ResumeStatusInterrupted, steps[2].Status)
})
}
@ -1014,10 +1014,10 @@ func TestBufferCompleteWorkflow(t *testing.T) {
// Get steps for resume
steps := buffer.GetStepsForResume(context.ResumeStatusInterrupted)
require.NotNil(t, steps)
assert.Len(t, steps, 1)
assert.Len(t, steps, 2)
// Only the interrupted step should be returned
lastStep := steps[0]
// Last step should be interrupted with space snapshot
lastStep := steps[len(steps)-1]
assert.Equal(t, context.ResumeStatusInterrupted, lastStep.Status)
assert.NotNil(t, lastStep.SpaceSnapshot)
assert.Equal(t, "previous conversation", lastStep.SpaceSnapshot["user_context"])

View file

@ -559,11 +559,3 @@ func (ctx *Context) MergeMetadata(metadata map[string]interface{}) {
ctx.Metadata[k] = v
}
}
// GetWorkspaceID returns the ID of the workspace
func (ctx *Context) GetWorkspaceID() (string, error) {
if ctx.workspace == nil {
return "", nil
}
return ctx.workspace.GetID()
}

View file

@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"github.com/yaoapp/gou/connector"
"github.com/yaoapp/gou/store"
"github.com/yaoapp/yao/openapi/oauth/types"
)
@ -62,7 +63,9 @@ func GetGRPCAgentRequest(parent context.Context, input GRPCAgentInput) ([]Messag
}
if connectorID := getStringOpt(rawOpts, "connector"); connectorID != "" {
opts.Connector = connectorID
if _, err := connector.Select(connectorID); err == nil {
opts.Connector = connectorID
}
}
ctx.Interrupt = NewInterruptController()

View file

@ -14,9 +14,6 @@ type LlmAPI interface {
// Returns *llm.Result or error information
Stream(connector string, messages []interface{}, opts map[string]interface{}) interface{}
// GenerateImage generates an image from a text prompt using an image generation model
GenerateImage(connector string, prompt string, opts map[string]interface{}) interface{}
// Parallel LLM call methods - inspired by JavaScript Promise
// All waits for all LLM calls to complete (like Promise.all)
All(requests []interface{}) []interface{}
@ -71,9 +68,6 @@ func (ctx *Context) newLlmObject(iso *v8go.Isolate) *v8go.ObjectTemplate {
// Single LLM call method
llmObj.Set("Stream", ctx.llmStreamMethod(iso))
// Image generation method
llmObj.Set("GenerateImage", ctx.llmGenerateImageMethod(iso))
// Parallel LLM call methods - inspired by JavaScript Promise
llmObj.Set("All", ctx.llmAllMethod(iso))
llmObj.Set("Any", ctx.llmAnyMethod(iso))
@ -169,54 +163,6 @@ func (ctx *Context) llmStreamMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
})
}
// llmGenerateImageMethod implements ctx.llm.GenerateImage(connector, prompt, options?)
// Usage: const result = ctx.llm.GenerateImage("dall-e-3", "A sunset over mountains", { size: "1024x1024" })
// Returns: { connector, image (base64), format, error }
func (ctx *Context) llmGenerateImageMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
v8ctx := info.Context()
args := info.Args()
if len(args) < 2 {
return bridge.JsException(v8ctx, "GenerateImage requires connector and prompt parameters")
}
if !args[0].IsString() {
return bridge.JsException(v8ctx, "connector must be a string")
}
connectorID := args[0].String()
if !args[1].IsString() {
return bridge.JsException(v8ctx, "prompt must be a string")
}
prompt := args[1].String()
var opts map[string]interface{}
if len(args) >= 3 && !args[2].IsUndefined() && !args[2].IsNull() {
goVal, err := bridge.GoValue(args[2], v8ctx)
if err == nil {
if optsMap, ok := goVal.(map[string]interface{}); ok {
opts = optsMap
}
}
}
llmAPI := ctx.Llm()
if llmAPI == nil {
return bridge.JsException(v8ctx, "LLM API not available")
}
result := llmAPI.GenerateImage(connectorID, prompt, opts)
jsVal, err := bridge.JsValue(v8ctx, result)
if err != nil {
return bridge.JsException(v8ctx, "failed to convert result: "+err.Error())
}
return jsVal
})
}
// llmAllMethod implements ctx.llm.All(requests, options?)
// Usage: const results = ctx.llm.All([
//

View file

@ -100,11 +100,6 @@ func WithParentID(parentID string) LoggerOption {
// noopLogger is a shared no-op logger instance
var noopLogger = &RequestLogger{noop: true}
// NoopLogger returns a shared no-op RequestLogger that silently discards all
// log calls. Use when a non-nil logger is required but no actual logging is
// desired (e.g., fallback when StreamRequest.Logger is nil).
func NoopLogger() *RequestLogger { return noopLogger }
// NewRequestLogger creates a new request-scoped logger with async processing
func NewRequestLogger(assistantID, chatID, requestID string, opts ...LoggerOption) *RequestLogger {
l := &RequestLogger{

View file

@ -9,6 +9,7 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/yaoapp/gou/connector"
"github.com/yaoapp/gou/store"
"github.com/yaoapp/yao/openapi/oauth/authorized"
)
@ -69,9 +70,19 @@ func GetCompletionRequest(c *gin.Context, cache store.Store) (*CompletionRequest
Mode: GetMode(c, completionReq),
}
// Pass model as connector ID; downstream ResolveConnector handles validation + lazy loading
if completionReq != nil && completionReq.Model != "" && !strings.Contains(completionReq.Model, "-yao_") {
opts.Connector = completionReq.Model
// Try to extract custom connector from model field
// If model is a valid connector ID, set it to opts.Connector
// Otherwise, keep the standard OpenAI-compatible behavior (model as assistant ID)
if completionReq != nil && completionReq.Model != "" {
// Check if model is a valid connector (not containing "-yao_" which indicates assistant ID format)
if !strings.Contains(completionReq.Model, "-yao_") {
// Try to validate if it's a real connector
if _, err := connector.Select(completionReq.Model); err == nil {
// It's a valid connector, use it
opts.Connector = completionReq.Model
}
// If not a valid connector, ignore it (keep opts.Connector empty to use assistant's default)
}
}
// Initialize interrupt controller

View file

@ -566,9 +566,8 @@ type Message struct {
ToolCallID *string `json:"tool_call_id,omitempty"` // Required for tool messages: tool call that this message is responding to
// Assistant message specific fields
ReasoningContent string `json:"reasoning_content,omitempty"` // Optional for assistant: reasoning/thinking content (DeepSeek, OpenAI o-series)
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // Optional for assistant: tool calls generated by the model
Refusal *string `json:"refusal,omitempty"` // Optional for assistant: refusal message (null when not refusing)
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // Optional for assistant: tool calls generated by the model
Refusal *string `json:"refusal,omitempty"` // Optional for assistant: refusal message (null when not refusing)
}
// ContentPartType represents the type of content part

View file

@ -53,28 +53,19 @@ func (a *VisionAdapter) removeImageContent(messages []context.Message) []context
for _, msg := range messages {
processedMsg := msg
if contentParts, ok := msg.Content.([]context.ContentPart); ok {
filtered := make([]context.ContentPart, 0)
for _, part := range contentParts {
if part.Type != context.ContentImageURL {
filtered = append(filtered, part)
}
}
if len(filtered) == 0 {
processedMsg.Content = "[Image content not supported by this model]"
} else {
processedMsg.Content = filtered
}
} else if contentParts, ok := msg.Content.([]map[string]interface{}); ok {
// Handle multimodal content (array of map)
if contentParts, ok := msg.Content.([]map[string]interface{}); ok {
filteredParts := make([]map[string]interface{}, 0)
for _, part := range contentParts {
partType, _ := part["type"].(string)
// Skip image content
if partType != "image_url" && partType != "image" {
filteredParts = append(filteredParts, part)
}
}
// If all parts were filtered out, add placeholder text
if len(filteredParts) == 0 {
processedMsg.Content = "[Image content not supported by this model]"
} else if len(filteredParts) == 1 {

View file

@ -20,21 +20,12 @@ func GetCapabilities(connectorID string) *goullm.Capabilities {
return GetCapabilitiesFromConn(conn)
}
// GetCapabilitiesFromConn get the capabilities from a connector instance.
// Prefers LLMConnector.GetCapabilities() when available, falls back to Setting() parsing.
// GetCapabilitiesFromConn get the capabilities from a connector instance
func GetCapabilitiesFromConn(conn connector.Connector) *goullm.Capabilities {
if conn == nil {
return getDefaultCapabilities()
}
// Prefer typed LLMConnector interface
if lc, ok := conn.(goullm.LLMConnector); ok {
if caps := lc.GetCapabilities(); caps != nil {
return caps
}
}
// Fallback to Setting() parsing for non-LLMConnector or nil capabilities
settings := conn.Setting()
if settings != nil {
if caps, ok := settings["capabilities"]; ok {
@ -44,54 +35,12 @@ func GetCapabilitiesFromConn(conn connector.Connector) *goullm.Capabilities {
if capabilities, ok := caps.(goullm.Capabilities); ok {
return &capabilities
}
if capsMap, ok := caps.(map[string]interface{}); ok {
return capabilitiesFromMap(capsMap)
}
}
}
return getDefaultCapabilities()
}
// capabilitiesFromMap converts a JSON-deserialized map into goullm.Capabilities.
func capabilitiesFromMap(m map[string]interface{}) *goullm.Capabilities {
caps := getDefaultCapabilities()
if v, ok := m["streaming"].(bool); ok {
caps.Streaming = v
}
if v, ok := m["tool_calls"].(bool); ok {
caps.ToolCalls = v
}
if v, ok := m["vision"]; ok {
caps.Vision = v
}
if v, ok := m["audio"].(bool); ok {
caps.Audio = v
}
if v, ok := m["stt"].(bool); ok {
caps.STT = v
}
if v, ok := m["reasoning"].(bool); ok {
caps.Reasoning = v
}
if v, ok := m["json"].(bool); ok {
caps.JSON = v
}
if v, ok := m["multimodal"].(bool); ok {
caps.Multimodal = v
}
if v, ok := m["temperature_adjustable"].(bool); ok {
caps.TemperatureAdjustable = v
}
if v, ok := m["embedding"].(bool); ok {
caps.Embedding = v
}
if v, ok := m["image_generation"].(bool); ok {
caps.ImageGeneration = v
}
return caps
}
// getDefaultCapabilities returns minimal default capabilities
func getDefaultCapabilities() *goullm.Capabilities {
return &goullm.Capabilities{
@ -116,8 +65,26 @@ func GetCapabilitiesMap(connectorID string) map[string]interface{} {
return ToMap(caps)
}
// ToMap converts Capabilities to map[string]interface{}.
// Delegates to the canonical Capabilities.ToMap() method in gou/llm.
// ToMap converts Capabilities to map[string]interface{}
func ToMap(caps *goullm.Capabilities) map[string]interface{} {
return caps.ToMap()
if caps == nil {
return nil
}
result := make(map[string]interface{})
if caps.Vision != nil {
result["vision"] = caps.Vision
}
result["audio"] = caps.Audio
result["stt"] = caps.STT
result["tool_calls"] = caps.ToolCalls
result["reasoning"] = caps.Reasoning
result["streaming"] = caps.Streaming
result["json"] = caps.JSON
result["multimodal"] = caps.Multimodal
result["temperature_adjustable"] = caps.TemperatureAdjustable
return result
}

View file

@ -1,11 +0,0 @@
package llm
import (
_ "embed"
"github.com/yaoapp/gou/doc"
)
//go:embed doc.yml
var docYAML []byte
func init() { doc.LoadYAML(docYAML) }

View file

@ -1,44 +0,0 @@
group: llm
type: process
entries:
- name: ChatCompletions
desc: Universal LLM chat completions that auto-detects connector type and routes accordingly
args:
- name: connector
type: string
required: true
desc: Connector ID (supports any type, e.g. openai, anthropic)
- name: messages
type: array
required: true
desc: "Message array in OpenAI format: each element is an object with role, content (string or multimodal array), and optional name, tool_call_id, tool_calls"
- name: opts
type: object
required: false
desc: "Completion options: temperature, max_tokens, and other model parameters"
- name: callback
type: function
required: false
desc: "Streaming callback function that receives data chunks; signature: func(data []byte) int"
return:
type: object
desc: "OpenAI-compatible response: { id, object, created, model, choices: [{ index, message: { role, content, tool_calls? }, finish_reason }], usage? }"
- name: ImageGeneration
desc: Generate an image from a text prompt using an image generation model
args:
- name: connector
type: string
required: true
desc: Connector ID for an image generation model (e.g. dall-e-3)
- name: prompt
type: string
required: true
desc: Text description of the image to generate
- name: opts
type: object
required: false
desc: "Generation options: size (1024x1024), quality, style, n, etc."
return:
type: object
desc: "Image generation result: { image (base64), format (png) }"

View file

@ -1,183 +0,0 @@
package llm
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/yaoapp/gou/connector"
gouhttp "github.com/yaoapp/gou/http"
goullm "github.com/yaoapp/gou/llm"
)
// ImageGenResponse holds the result of an image generation call.
// Image is always base64 encoded; if the provider returns a URL, it is downloaded and converted.
type ImageGenResponse struct {
Image string `json:"image"` // base64 encoded image data
Format string `json:"format"` // image format, e.g. "png", "jpeg"
}
// GenerateImage calls the /images/generations endpoint through the connector.
// options may include: size, n, quality, style, model, etc.
func GenerateImage(conn connector.Connector, prompt string, options map[string]interface{}) (*ImageGenResponse, error) {
host, key, authMode := resolveConnSettings(conn)
if host == "" {
return nil, fmt.Errorf("no host found in connector settings")
}
if key == "" {
return nil, fmt.Errorf("API key is not set")
}
if options == nil {
options = map[string]interface{}{}
}
options["prompt"] = prompt
if _, ok := options["model"]; !ok {
if lc, ok := conn.(goullm.LLMConnector); ok {
if m := lc.GetModel(); m != "" {
options["model"] = m
}
}
}
url := connector.BuildAPIURL(host, "/images/generations")
req := gouhttp.New(url)
req.SetHeader("Content-Type", "application/json")
setImageAuthHeaders(req, authMode, key)
resp := req.Post(options)
if resp.Status != 200 {
errMsg := extractAPIError(resp.Data)
return nil, fmt.Errorf("image generation failed (status %d, url %s): %s", resp.Status, url, errMsg)
}
return extractImageFromResponse(resp.Data)
}
func resolveConnSettings(conn connector.Connector) (host, key string, authMode goullm.AuthMode) {
authMode = goullm.AuthBearer
if lc, ok := conn.(goullm.LLMConnector); ok {
host = lc.GetURL()
key = lc.GetKey()
authMode = lc.GetAuthMode()
}
if host == "" || key == "" {
setting := conn.Setting()
if host == "" {
host, _ = setting["host"].(string)
}
if key == "" {
key, _ = setting["key"].(string)
}
}
return
}
func setImageAuthHeaders(req *gouhttp.Request, authMode goullm.AuthMode, key string) {
switch authMode {
case goullm.AuthAPIKey:
req.SetHeader("api-key", key)
case goullm.AuthXAPIKey:
req.SetHeader("x-api-key", key)
default:
req.SetHeader("Authorization", fmt.Sprintf("Bearer %s", key))
}
}
func extractImageFromResponse(data interface{}) (*ImageGenResponse, error) {
raw, err := json.Marshal(data)
if err != nil {
return nil, fmt.Errorf("marshal response: %w", err)
}
var parsed struct {
Data []struct {
B64JSON *string `json:"b64_json"`
URL *string `json:"url"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return nil, fmt.Errorf("unmarshal response: %w", err)
}
if len(parsed.Data) == 0 {
return nil, fmt.Errorf("provider returned empty data array, no image was generated")
}
item := parsed.Data[0]
if item.B64JSON != nil && *item.B64JSON != "" {
return &ImageGenResponse{Image: *item.B64JSON, Format: "png"}, nil
}
if item.URL != nil && *item.URL != "" {
b64, format, err := downloadImageAsBase64(*item.URL)
if err != nil {
return nil, fmt.Errorf("provider returned url but download failed: %w", err)
}
return &ImageGenResponse{Image: b64, Format: format}, nil
}
return nil, fmt.Errorf("provider returned data but neither b64_json nor url field is present, the model may not support image generation")
}
func downloadImageAsBase64(imageURL string) (b64 string, format string, err error) {
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(imageURL)
if err != nil {
return "", "", fmt.Errorf("http get: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", "", fmt.Errorf("download returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", "", fmt.Errorf("read body: %w", err)
}
if len(body) == 0 {
return "", "", fmt.Errorf("downloaded image is empty")
}
format = "png"
ct := resp.Header.Get("Content-Type")
switch {
case strings.Contains(ct, "jpeg") || strings.Contains(ct, "jpg"):
format = "jpeg"
case strings.Contains(ct, "webp"):
format = "webp"
case strings.Contains(ct, "gif"):
format = "gif"
default:
if strings.Contains(imageURL, ".jpeg") || strings.Contains(imageURL, ".jpg") {
format = "jpeg"
} else if strings.Contains(imageURL, ".webp") {
format = "webp"
}
}
b64 = base64.StdEncoding.EncodeToString(body)
return b64, format, nil
}
func extractAPIError(data interface{}) string {
raw, err := json.Marshal(data)
if err != nil {
return fmt.Sprintf("%v", data)
}
var parsed struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(raw, &parsed); err == nil && parsed.Error.Message != "" {
return parsed.Error.Message
}
return string(raw)
}

View file

@ -1,197 +0,0 @@
package llm
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestExtractImageFromResponse_B64(t *testing.T) {
data := map[string]interface{}{
"data": []interface{}{
map[string]interface{}{
"b64_json": "iVBORw0KGgoAAAANS...",
},
},
}
resp, err := extractImageFromResponse(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Image != "iVBORw0KGgoAAAANS..." {
t.Errorf("got Image=%q, want %q", resp.Image, "iVBORw0KGgoAAAANS...")
}
if resp.Format != "png" {
t.Errorf("got Format=%q, want %q", resp.Format, "png")
}
}
func TestExtractImageFromResponse_URL(t *testing.T) {
fakeImage := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10} // fake JPEG header bytes
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/jpeg")
w.Write(fakeImage)
}))
defer srv.Close()
data := map[string]interface{}{
"data": []interface{}{
map[string]interface{}{
"b64_json": nil,
"url": srv.URL + "/image_0.jpeg",
},
},
}
resp, err := extractImageFromResponse(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expected := base64.StdEncoding.EncodeToString(fakeImage)
if resp.Image != expected {
t.Errorf("got Image=%q, want %q", resp.Image, expected)
}
if resp.Format != "jpeg" {
t.Errorf("got Format=%q, want %q", resp.Format, "jpeg")
}
}
func TestExtractImageFromResponse_URLPng(t *testing.T) {
fakeImage := []byte{0x89, 0x50, 0x4E, 0x47} // PNG magic bytes
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.Write(fakeImage)
}))
defer srv.Close()
data := map[string]interface{}{
"data": []interface{}{
map[string]interface{}{
"url": srv.URL + "/output.png",
},
},
}
resp, err := extractImageFromResponse(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.Format != "png" {
t.Errorf("got Format=%q, want %q", resp.Format, "png")
}
if resp.Image == "" {
t.Error("expected non-empty base64 Image")
}
}
func TestExtractImageFromResponse_URLDownloadFail(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
data := map[string]interface{}{
"data": []interface{}{
map[string]interface{}{
"url": srv.URL + "/missing.png",
},
},
}
_, err := extractImageFromResponse(data)
if err == nil {
t.Error("expected error for failed download")
}
}
func TestExtractImageFromResponse_Empty(t *testing.T) {
data := map[string]interface{}{
"data": []interface{}{},
}
_, err := extractImageFromResponse(data)
if err == nil {
t.Error("expected error for empty data array")
}
}
func TestExtractImageFromResponse_NoData(t *testing.T) {
data := map[string]interface{}{}
_, err := extractImageFromResponse(data)
if err == nil {
t.Error("expected error for missing data field")
}
}
func TestExtractImageFromResponse_NullBoth(t *testing.T) {
data := map[string]interface{}{
"data": []interface{}{
map[string]interface{}{
"b64_json": nil,
"url": nil,
},
},
}
_, err := extractImageFromResponse(data)
if err == nil {
t.Error("expected error when both b64_json and url are null")
}
}
func TestDownloadImageAsBase64(t *testing.T) {
payload := []byte("fake-png-data")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "image/png")
w.Write(payload)
}))
defer srv.Close()
b64, format, err := downloadImageAsBase64(srv.URL + "/test.png")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if format != "png" {
t.Errorf("got format=%q, want %q", format, "png")
}
decoded, _ := base64.StdEncoding.DecodeString(b64)
if string(decoded) != string(payload) {
t.Errorf("decoded content mismatch")
}
}
func TestDownloadImageAsBase64_FormatFromURL(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
w.Write([]byte("data"))
}))
defer srv.Close()
_, format, err := downloadImageAsBase64(srv.URL + "/image.webp")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if format != "webp" {
t.Errorf("got format=%q, want %q (from URL fallback)", format, "webp")
}
}
func TestExtractAPIError_WithMessage(t *testing.T) {
data := map[string]interface{}{
"error": map[string]interface{}{
"message": "insufficient quota",
},
}
msg := extractAPIError(data)
if msg != "insufficient quota" {
t.Errorf("got %q, want %q", msg, "insufficient quota")
}
}
func TestExtractAPIError_NoMessage(t *testing.T) {
data := map[string]interface{}{
"something": "else",
}
msg := extractAPIError(data)
raw, _ := json.Marshal(data)
if msg != string(raw) {
t.Errorf("got %q, want raw JSON fallback", msg)
}
}

View file

@ -31,37 +31,6 @@ func SetJSAPIFactory() {
}
}
// GenerateImage implements LlmAPI.GenerateImage - generates an image from a text prompt
func (api *JSAPI) GenerateImage(connectorID string, prompt string, opts map[string]interface{}) interface{} {
result := &ImageGenResult{
Connector: connectorID,
}
conn, err := connector.Select(connectorID)
if err != nil {
result.Error = fmt.Sprintf("failed to select connector %s: %v", connectorID, err)
return result
}
resp, err := GenerateImage(conn, prompt, opts)
if err != nil {
result.Error = fmt.Sprintf("image generation failed: %v", err)
return result
}
result.Image = resp.Image
result.Format = resp.Format
return result
}
// ImageGenResult is the return type for GenerateImage JSAPI
type ImageGenResult struct {
Connector string `json:"connector"`
Image string `json:"image,omitempty"`
Format string `json:"format,omitempty"`
Error string `json:"error,omitempty"`
}
// Stream implements LlmAPI.Stream - calls LLM with streaming output to ctx.Writer
func (api *JSAPI) Stream(connectorID string, messages []interface{}, opts map[string]interface{}) interface{} {
return api.StreamWithHandler(connectorID, messages, opts, nil)

View file

@ -16,7 +16,6 @@ import (
func init() {
process.Register("llm.ChatCompletions", ProcessChatCompletions)
process.Register("llm.ImageGeneration", ProcessImageGeneration)
}
// ProcessChatCompletions implements the llm.ChatCompletions Process.
@ -156,55 +155,6 @@ func ProcessChatCompletions(p *process.Process) interface{} {
return toOpenAIFormat(response)
}
// ProcessImageGeneration implements the llm.ImageGeneration Process.
//
// Usage:
//
// Process("llm.ImageGeneration", connectorID, prompt)
// Process("llm.ImageGeneration", connectorID, prompt, opts)
//
// Args:
// - connectorID (string): Connector ID for an image generation model
// - prompt (string): Text description of the image to generate
// - opts (map): Optional. size, quality, style, n, etc.
//
// Returns: { image (base64), format (png) }
func ProcessImageGeneration(p *process.Process) interface{} {
p.ValidateArgNums(2)
connectorID := p.ArgsString(0)
if connectorID == "" {
return newErrorResponse("llm.ImageGeneration: connector is required")
}
prompt := p.ArgsString(1)
if prompt == "" {
return newErrorResponse("llm.ImageGeneration: prompt is required")
}
var opts map[string]interface{}
if p.NumOfArgs() > 2 && p.Args[2] != nil {
if o, ok := p.Args[2].(map[string]interface{}); ok {
opts = o
}
}
conn, _, err := selectWithCapabilities(connectorID)
if err != nil {
return newErrorResponse(fmt.Sprintf("llm.ImageGeneration: connector %s not found: %v", connectorID, err))
}
resp, err := GenerateImage(conn, prompt, opts)
if err != nil {
return newErrorResponse(fmt.Sprintf("llm.ImageGeneration: %v", err))
}
return map[string]interface{}{
"image": resp.Image,
"format": resp.Format,
}
}
// toOpenAIFormat converts CompletionResponse to OpenAI chat.completions format
// for backward compatibility with code that consumed openai.chat.Completions.
func toOpenAIFormat(resp *agentContext.CompletionResponse) map[string]interface{} {

View file

@ -17,7 +17,6 @@ import (
"github.com/yaoapp/yao/agent/llm/adapters"
"github.com/yaoapp/yao/agent/llm/providers/base"
"github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/share"
)
// Provider Anthropic Messages API provider
@ -45,11 +44,11 @@ func buildAdapters(cap *goullm.Capabilities) []adapters.CapabilityAdapter {
// Tool call adapter
result = append(result, adapters.NewToolCallAdapter(cap.ToolCalls))
// Vision adapter (always registered to strip unsupported image content)
// Vision adapter
visionSupport, visionFormat := context.GetVisionSupport(cap)
if visionSupport {
result = append(result, adapters.NewVisionAdapter(true, visionFormat))
} else {
} else if cap.Vision != nil {
result = append(result, adapters.NewVisionAdapter(false, context.VisionFormatNone))
}
@ -202,10 +201,21 @@ func (p *Provider) streamWithRetry(ctx *context.Context, messages []context.Mess
return nil, fmt.Errorf("failed to build request body: %w", err)
}
// Get connector settings via LLMConnector or fallback
host, key, version, err := p.resolveHostKeyVersion()
if err != nil {
return nil, err
// Get connector settings
setting := p.Connector.Setting()
host, ok := setting["host"].(string)
if !ok || host == "" {
return nil, fmt.Errorf("no host found in connector settings")
}
key, ok := setting["key"].(string)
if !ok || key == "" {
return nil, fmt.Errorf("API key is not set")
}
version := "2023-06-01"
if v, ok := setting["version"].(string); ok && v != "" {
version = v
}
// Build URL: host/v1/messages
@ -217,13 +227,13 @@ func (p *Provider) streamWithRetry(ctx *context.Context, messages []context.Mess
})
}
// Create HTTP request with auth headers
// Create HTTP request with Anthropic auth headers
req := http.New(url).
SetHeader("Content-Type", "application/json").
SetHeader("x-api-key", key).
SetHeader("anthropic-version", version).
SetHeader("Accept", "text/event-stream").
SetHeader("User-Agent", "YaoEngine/"+share.VERSION)
setAnthropicAuthHeaders(req, p.Connector, key)
SetHeader("User-Agent", "YaoAgent/1.0 (+https://yaoagents.com)")
// Accumulate response data
accumulator := &streamAccumulator{
@ -668,20 +678,31 @@ func (p *Provider) postWithRetry(ctx *context.Context, messages []context.Messag
return nil, fmt.Errorf("failed to build request body: %w", err)
}
// Get connector settings via LLMConnector or fallback
host, key, version, err := p.resolveHostKeyVersion()
if err != nil {
return nil, err
// Get connector settings
setting := p.Connector.Setting()
host, ok := setting["host"].(string)
if !ok || host == "" {
return nil, fmt.Errorf("no host found in connector settings")
}
key, ok := setting["key"].(string)
if !ok || key == "" {
return nil, fmt.Errorf("API key is not set")
}
version := "2023-06-01"
if v, ok := setting["version"].(string); ok && v != "" {
version = v
}
url := buildAPIURL(host, "/messages")
// Create HTTP request with auth headers
// Create HTTP request
req := http.New(url).
SetHeader("Content-Type", "application/json").
SetHeader("x-api-key", key).
SetHeader("anthropic-version", version).
SetHeader("User-Agent", "YaoEngine/"+share.VERSION)
setAnthropicAuthHeaders(req, p.Connector, key)
SetHeader("User-Agent", "YaoAgent/1.0 (+https://yaoagents.com)")
resp := req.Post(requestBody)
if resp.Code != 200 {
@ -894,11 +915,6 @@ func (p *Provider) buildRequestBody(messages []context.Message, options *context
} else if mt, ok := setting["max_tokens"].(int); ok && mt > 0 {
maxTokens = mt
}
if lc, ok := p.Connector.(goullm.LLMConnector); ok {
if caps := lc.GetCapabilities(); caps != nil && caps.MaxOutputTokens > 0 && maxTokens > caps.MaxOutputTokens {
maxTokens = caps.MaxOutputTokens
}
}
body["max_tokens"] = maxTokens
// Temperature
@ -926,13 +942,9 @@ func (p *Provider) buildRequestBody(messages []context.Message, options *context
body["tool_choice"] = convertToolChoice(options.ToolChoice)
}
// Merge connector-level body params (thinking, etc.)
// filtered through the SupportedParams / default whitelist.
connParams := connector.FilterRequestBodyParams(setting, p.Connector)
for k, v := range connParams {
if _, exists := body[k]; !exists {
body[k] = v
}
// Thinking configuration from connector settings
if thinking, exists := setting["thinking"]; exists && thinking != nil {
body["thinking"] = thinking
}
return body, nil
@ -1164,47 +1176,3 @@ func isRetryableError(err error) bool {
return false
}
// resolveHostKeyVersion extracts host, key, and version via LLMConnector or Setting().
// Setting() is called at most once, and only when needed.
func (p *Provider) resolveHostKeyVersion() (host, key, version string, err error) {
setting := p.Connector.Setting()
if lc, ok := p.Connector.(goullm.LLMConnector); ok {
host = lc.GetURL()
key = lc.GetKey()
} else {
host, _ = setting["host"].(string)
key, _ = setting["key"].(string)
}
// Version is Anthropic-specific, not on LLMConnector interface
version = "2023-06-01"
if v, ok := setting["version"].(string); ok && v != "" {
version = v
}
if host == "" {
return "", "", "", fmt.Errorf("no host found in connector settings")
}
if key == "" {
return "", "", "", fmt.Errorf("API key is not set")
}
return host, key, version, nil
}
// setAnthropicAuthHeaders sets auth headers based on LLMConnector.GetAuthMode().
func setAnthropicAuthHeaders(req *http.Request, conn connector.Connector, key string) {
if lc, ok := conn.(goullm.LLMConnector); ok {
switch lc.GetAuthMode() {
case goullm.AuthAPIKey:
req.SetHeader("api-key", key)
return
case goullm.AuthBearer:
req.SetHeader("Authorization", fmt.Sprintf("Bearer %s", key))
return
}
}
// Default for Anthropic: x-api-key
req.SetHeader("x-api-key", key)
}

View file

@ -16,7 +16,7 @@ import (
"github.com/yaoapp/yao/test"
)
// testConnectorID uses the cheapest model (Claude Haiku 4.5) to save tokens
// testConnectorID uses the cheapest model (Claude Haiku 3) to save tokens
const testConnectorID = "claude.haiku-3_0"
// TestAnthropicStreamBasic tests basic streaming completion with Anthropic API
@ -214,7 +214,7 @@ func TestAnthropicStreamRetry(t *testing.T) {
connDSL := `{
"type": "anthropic",
"options": {
"model": "claude-haiku-4-5-20251001",
"model": "claude-3-haiku-20240307",
"key": "sk-ant-invalid-key-should-fail"
}
}`

View file

@ -140,30 +140,15 @@ func (p *Provider) GetConnectorStringSetting(key string) (string, error) {
// GetModel gets the model name from connector settings
func (p *Provider) GetModel() (string, error) {
if lc, ok := p.Connector.(llm.LLMConnector); ok {
if m := lc.GetModel(); m != "" {
return m, nil
}
}
return p.GetConnectorStringSetting("model")
}
// GetAPIKey gets the API key from connector settings
func (p *Provider) GetAPIKey() (string, error) {
if lc, ok := p.Connector.(llm.LLMConnector); ok {
if k := lc.GetKey(); k != "" {
return k, nil
}
}
return p.GetConnectorStringSetting("key")
}
// GetHost gets the host URL from connector settings
func (p *Provider) GetHost() (string, error) {
if lc, ok := p.Connector.(llm.LLMConnector); ok {
if u := lc.GetURL(); u != "" {
return u, nil
}
}
return p.GetConnectorStringSetting("host")
}

View file

@ -4,7 +4,6 @@ import (
"fmt"
"github.com/yaoapp/gou/connector"
goullm "github.com/yaoapp/gou/llm"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/llm/providers/anthropic"
"github.com/yaoapp/yao/agent/llm/providers/openai"
@ -62,23 +61,16 @@ func DetectAPIFormat(conn connector.Connector) string {
return "openai"
}
// Try LLMConnector for typed URL access, fall back to Setting() map
var host string
if lc, ok := conn.(goullm.LLMConnector); ok {
host = lc.GetURL()
}
if host == "" {
if settings := conn.Setting(); settings != nil {
host, _ = settings["host"].(string)
}
}
if host != "" {
if contains(host, "anthropic.com") || contains(host, "api.kimi.com/coding") {
return "anthropic"
}
if contains(host, "deepseek.com") {
return "openai"
// Check connector settings for host URL patterns as fallback
settings := conn.Setting()
if settings != nil {
if host, ok := settings["host"].(string); ok {
if contains(host, "anthropic.com") || contains(host, "api.kimi.com/coding") {
return "anthropic"
}
if contains(host, "deepseek.com") {
return "openai"
}
}
}

View file

@ -16,7 +16,6 @@ import (
"github.com/yaoapp/yao/agent/llm/adapters"
"github.com/yaoapp/yao/agent/llm/providers/base"
"github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/share"
"github.com/yaoapp/yao/utils/jsonschema"
)
@ -156,11 +155,12 @@ func buildAdapters(cap *goullm.Capabilities) []adapters.CapabilityAdapter {
// Tool call adapter
result = append(result, adapters.NewToolCallAdapter(cap.ToolCalls))
// Vision adapter (always registered to strip unsupported image content)
// Vision adapter
visionSupport, visionFormat := context.GetVisionSupport(cap)
if visionSupport {
result = append(result, adapters.NewVisionAdapter(true, visionFormat))
} else {
} else if cap.Vision != nil {
// Vision explicitly disabled, add adapter to remove image content
result = append(result, adapters.NewVisionAdapter(false, context.VisionFormatNone))
}
@ -385,10 +385,16 @@ func (p *Provider) streamWithRetry(ctx *context.Context, messages []context.Mess
return nil, fmt.Errorf("failed to build request body: %w", err)
}
// Get connector settings via LLMConnector or fallback
host, key, err := p.resolveHostKey()
if err != nil {
return nil, err
// Get connector settings
setting := p.Connector.Setting()
host, ok := setting["host"].(string)
if !ok || host == "" {
return nil, fmt.Errorf("no host found in connector settings")
}
key, ok := setting["key"].(string)
if !ok || key == "" {
return nil, fmt.Errorf("API key is not set")
}
// Build URL
@ -403,9 +409,9 @@ func (p *Provider) streamWithRetry(ctx *context.Context, messages []context.Mess
// Create HTTP request with proxy support
req := http.New(url).
SetHeader("Content-Type", "application/json").
SetHeader("Authorization", fmt.Sprintf("Bearer %s", key)).
SetHeader("Accept", "text/event-stream").
SetHeader("User-Agent", "YaoEngine/"+share.VERSION)
setAuthHeaders(req, p.Connector, key)
SetHeader("User-Agent", "YaoAgent/1.0 (+https://yaoagents.com)")
// Accumulate response data
accumulator := &streamAccumulator{
@ -492,18 +498,16 @@ func (p *Provider) streamWithRetry(ctx *context.Context, messages []context.Mess
accumulator.role = delta.Role
}
reasoningText := delta.ReasoningContent
if reasoningText == "" {
reasoningText = delta.Reasoning
}
if reasoningText != "" {
// Handle reasoning content (DeepSeek R1)
if delta.ReasoningContent != "" {
// Start thinking message if not active
if !messageTracker.active || messageTracker.messageType != message.ChunkThinking {
messageTracker.startMessage(message.ChunkThinking, handler)
}
accumulator.reasoningContent += reasoningText
accumulator.reasoningContent += delta.ReasoningContent
if handler != nil {
handler(message.ChunkThinking, []byte(reasoningText))
handler(message.ChunkThinking, []byte(delta.ReasoningContent))
messageTracker.incrementChunk()
}
}
@ -918,10 +922,16 @@ func (p *Provider) postWithRetry(ctx *context.Context, messages []context.Messag
return nil, fmt.Errorf("failed to build request body: %w", err)
}
// Get connector settings via LLMConnector or fallback
host, key, err := p.resolveHostKey()
if err != nil {
return nil, err
// Get connector settings
setting := p.Connector.Setting()
host, ok := setting["host"].(string)
if !ok || host == "" {
return nil, fmt.Errorf("no host found in connector settings")
}
key, ok := setting["key"].(string)
if !ok || key == "" {
return nil, fmt.Errorf("API key is not set")
}
// Build URL
@ -930,8 +940,8 @@ func (p *Provider) postWithRetry(ctx *context.Context, messages []context.Messag
// Create HTTP request with proxy support
req := http.New(url).
SetHeader("Content-Type", "application/json").
SetHeader("User-Agent", "YaoEngine/"+share.VERSION)
setAuthHeaders(req, p.Connector, key)
SetHeader("Authorization", fmt.Sprintf("Bearer %s", key)).
SetHeader("User-Agent", "YaoAgent/1.0 (+https://yaoagents.com)")
// Make request
resp := req.Post(requestBody)
@ -996,7 +1006,7 @@ func (p *Provider) postWithRetry(ctx *context.Context, messages []context.Messag
Model: fullResp.Model,
Role: string(choice.Message.Role),
Content: content,
ReasoningContent: reasoningOrFallback(choice.Message.ReasoningContent, choice.Message.Reasoning),
ReasoningContent: choice.Message.ReasoningContent,
ToolCalls: choice.Message.ToolCalls,
FinishReason: choice.FinishReason,
Usage: fullResp.Usage,
@ -1030,6 +1040,12 @@ func (p *Provider) buildRequestBody(messages []context.Message, options *context
return nil, fmt.Errorf("model is not set in connector")
}
// Get thinking setting from connector (for models that support reasoning/thinking mode)
var thinkingSetting interface{}
if thinking, exists := setting["thinking"]; exists {
thinkingSetting = thinking
}
// Convert messages to API format
apiMessages := make([]map[string]interface{}, 0, len(messages))
for _, msg := range messages {
@ -1083,10 +1099,6 @@ func (p *Provider) buildRequestBody(messages []context.Message, options *context
apiMsg["tool_calls"] = msg.ToolCalls
}
if msg.ReasoningContent != "" {
apiMsg["reasoning_content"] = msg.ReasoningContent
}
if msg.Refusal != nil {
apiMsg["refusal"] = *msg.Refusal
}
@ -1108,19 +1120,11 @@ func (p *Provider) buildRequestBody(messages []context.Message, options *context
// Use max_completion_tokens (modern API parameter for GPT-5+)
// GPT-5 models only support max_completion_tokens (not max_tokens)
if options.MaxCompletionTokens != nil || options.MaxTokens != nil {
maxTokens := 0
if options.MaxCompletionTokens != nil {
maxTokens = *options.MaxCompletionTokens
} else {
maxTokens = *options.MaxTokens
}
if lc, ok := p.Connector.(goullm.LLMConnector); ok {
if caps := lc.GetCapabilities(); caps != nil && caps.MaxOutputTokens > 0 && maxTokens > caps.MaxOutputTokens {
maxTokens = caps.MaxOutputTokens
}
}
body["max_completion_tokens"] = maxTokens
if options.MaxCompletionTokens != nil {
body["max_completion_tokens"] = *options.MaxCompletionTokens
} else if options.MaxTokens != nil {
// Fallback: convert MaxTokens to max_completion_tokens for compatibility
body["max_completion_tokens"] = *options.MaxTokens
}
if options.TopP != nil {
@ -1198,14 +1202,9 @@ func (p *Provider) buildRequestBody(messages []context.Message, options *context
body["audio"] = options.Audio
}
// Merge connector-level body params (thinking, reasoning, enable_thinking, etc.)
// filtered through the SupportedParams / default whitelist.
// CompletionOptions (per-call) take precedence over connector defaults.
connParams := connector.FilterRequestBodyParams(setting, p.Connector)
for k, v := range connParams {
if _, exists := body[k]; !exists {
body[k] = v
}
// Add thinking parameter for models that support reasoning/thinking mode
if thinkingSetting != nil {
body["thinking"] = thinkingSetting
}
return body, nil
@ -1290,44 +1289,3 @@ func isRetryableError(err error) bool {
return false
}
// resolveHostKey extracts host and key via LLMConnector or Setting() fallback.
func (p *Provider) resolveHostKey() (host, key string, err error) {
if lc, ok := p.Connector.(goullm.LLMConnector); ok {
host = lc.GetURL()
key = lc.GetKey()
} else {
setting := p.Connector.Setting()
host, _ = setting["host"].(string)
key, _ = setting["key"].(string)
}
if host == "" {
return "", "", fmt.Errorf("no host found in connector settings")
}
if key == "" {
return "", "", fmt.Errorf("API key is not set")
}
return host, key, nil
}
// setAuthHeaders sets authentication headers based on LLMConnector.GetAuthMode().
func setAuthHeaders(req *http.Request, conn connector.Connector, key string) {
if lc, ok := conn.(goullm.LLMConnector); ok {
switch lc.GetAuthMode() {
case goullm.AuthAPIKey:
req.SetHeader("api-key", key)
return
case goullm.AuthXAPIKey:
req.SetHeader("x-api-key", key)
return
}
}
req.SetHeader("Authorization", fmt.Sprintf("Bearer %s", key))
}
func reasoningOrFallback(primary, fallback string) string {
if primary != "" {
return primary
}
return fallback
}

View file

@ -30,8 +30,7 @@ type Delta struct {
type DeltaContent struct {
Role string `json:"role,omitempty"`
Content string `json:"content,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"` // DeepSeek direct API
Reasoning string `json:"reasoning,omitempty"` // OpenRouter
ReasoningContent string `json:"reasoning_content,omitempty"` // DeepSeek R1 reasoning
ToolCalls []ToolCallDelta `json:"tool_calls,omitempty"`
Refusal string `json:"refusal,omitempty"`
}
@ -61,8 +60,7 @@ type CompletionResponseFull struct {
Message struct {
Role context.MessageRole `json:"role"`
Content interface{} `json:"content,omitempty"` // string or array
ReasoningContent string `json:"reasoning_content,omitempty"` // DeepSeek direct API
Reasoning string `json:"reasoning,omitempty"` // OpenRouter
ReasoningContent string `json:"reasoning_content,omitempty"` // DeepSeek R1 reasoning
ToolCalls []context.ToolCall `json:"tool_calls,omitempty"`
Refusal *string `json:"refusal,omitempty"`
} `json:"message"`

View file

@ -1,94 +0,0 @@
package llm
import (
"fmt"
"strings"
"github.com/yaoapp/gou/connector"
goullm "github.com/yaoapp/gou/llm"
"github.com/yaoapp/yao/llmprovider"
)
// RolePrefix marks a Connector field value as a role reference (e.g. "use::light").
const RolePrefix = "use::"
// ResolveConnector resolves an LLM connector using a unified priority chain.
//
// connectorID may be:
// - explicit connector ID (e.g. "openai.gpt-4o") — resolved directly
// - role reference with prefix (e.g. "use::light") — resolved via llmprovider roles
// - empty string — falls back to the "default" role
//
// Priority for role-based resolution:
// 1. GetRoleBy(role, identity) — user/team scoped setting
// 2. GetRole(role) — system-level default for that role
// 3. GetRoleBy("default", identity) — fallback to "default" role (user/team)
// 4. GetRole("default") — fallback to "default" role (system)
// 5. error — caller decides whether to apply legacy fallback
func ResolveConnector(connectorID string, identity llmprovider.Identity) (connector.Connector, *goullm.Capabilities, error) {
// Parse use:: prefix to extract role
role := ""
if strings.HasPrefix(connectorID, RolePrefix) {
role = strings.TrimPrefix(connectorID, RolePrefix)
connectorID = ""
}
// Explicit connector ID takes highest priority
if connectorID != "" {
return selectWithCapabilities(connectorID)
}
// Empty connector with no role → treat as "default"
if role == "" {
role = "default"
}
if llmprovider.Global == nil {
return nil, nil, fmt.Errorf("llmprovider not initialized and no explicit connector specified")
}
// Resolve by the specified role (e.g. "light", "vision")
if role != "default" {
if identity != nil {
if cid, err := llmprovider.Global.GetRoleBy(role, identity); err == nil && cid != "" {
if conn, caps, err := selectWithCapabilities(cid); err == nil {
return conn, caps, nil
}
}
}
if cid, err := llmprovider.Global.GetRole(role); err == nil && cid != "" {
if conn, caps, err := selectWithCapabilities(cid); err == nil {
return conn, caps, nil
}
}
}
// Fallback to "default" role
if identity != nil {
if cid, err := llmprovider.Global.GetRoleBy("default", identity); err == nil && cid != "" {
if conn, caps, err := selectWithCapabilities(cid); err == nil {
return conn, caps, nil
}
}
}
if cid, err := llmprovider.Global.GetRole("default"); err == nil && cid != "" {
if conn, caps, err := selectWithCapabilities(cid); err == nil {
return conn, caps, nil
}
}
return nil, nil, fmt.Errorf("no connector resolved for role %q", role)
}
func selectWithCapabilities(connectorID string) (connector.Connector, *goullm.Capabilities, error) {
conn, err := connector.Select(connectorID)
if err != nil && llmprovider.Global != nil {
conn, err = llmprovider.Global.GetModel(connectorID)
}
if err != nil {
return nil, nil, err
}
caps := GetCapabilitiesFromConn(conn)
return conn, caps, nil
}

View file

@ -1,171 +0,0 @@
package llm_test
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/gou/connector"
"github.com/yaoapp/gou/store"
"github.com/yaoapp/yao/agent/llm"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/llmprovider"
"github.com/yaoapp/yao/setting"
"github.com/yaoapp/yao/test"
)
func TestMain(m *testing.M) {
test.Prepare(nil, config.Conf)
defer test.Clean()
os.Exit(m.Run())
}
type mockIdentity struct {
UserID string
TeamID string
}
func (m *mockIdentity) GetUserID() string { return m.UserID }
func (m *mockIdentity) GetTeamID() string { return m.TeamID }
func setupResolveTest(t *testing.T) string {
t.Helper()
test.Prepare(t, config.Conf)
err := setting.Init()
require.NoError(t, err)
err = llmprovider.Init()
require.NoError(t, err)
connIDs := connector.AIConnectors
if len(connIDs) == 0 {
t.Skip("no AI connectors available in test env")
}
cid := connIDs[0].Value
t.Cleanup(func() {
s, _ := store.Get("__yao.store")
if s != nil {
s.Del("llmprovider:*")
}
c, _ := store.Get("__yao.cache")
if c != nil {
c.Del("llmprovider:*")
}
test.Clean()
})
return cid
}
// --- use:: prefix tests ---
func TestResolveConnector_UseLight(t *testing.T) {
cid := setupResolveTest(t)
err := llmprovider.Global.SetDefaults(map[string]string{
"default": cid,
"light": cid,
})
require.NoError(t, err)
conn, caps, err := llm.ResolveConnector("use::light", nil)
require.NoError(t, err)
assert.NotNil(t, conn)
assert.NotNil(t, caps)
}
func TestResolveConnector_UseDefault(t *testing.T) {
cid := setupResolveTest(t)
err := llmprovider.Global.SetDefaults(map[string]string{
"default": cid,
})
require.NoError(t, err)
conn, caps, err := llm.ResolveConnector("use::default", nil)
require.NoError(t, err)
assert.NotNil(t, conn)
assert.NotNil(t, caps)
}
func TestResolveConnector_UseLightWithIdentity(t *testing.T) {
cid := setupResolveTest(t)
err := llmprovider.Global.SetDefaults(map[string]string{
"default": cid,
"light": cid,
})
require.NoError(t, err)
conn, caps, err := llm.ResolveConnector("use::light", &mockIdentity{UserID: "u1", TeamID: "t1"})
require.NoError(t, err)
assert.NotNil(t, conn)
assert.NotNil(t, caps)
}
func TestResolveConnector_UseLightNoProvider(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
saved := llmprovider.Global
llmprovider.Global = nil
defer func() { llmprovider.Global = saved }()
_, _, err := llm.ResolveConnector("use::light", nil)
assert.Error(t, err)
}
// --- Explicit connector tests ---
func TestResolveConnector_ExplicitID(t *testing.T) {
cid := setupResolveTest(t)
conn, caps, err := llm.ResolveConnector(cid, nil)
require.NoError(t, err)
assert.NotNil(t, conn)
assert.NotNil(t, caps)
}
func TestResolveConnector_ExplicitIDPriority(t *testing.T) {
cid := setupResolveTest(t)
err := llmprovider.Global.SetDefaults(map[string]string{
"default": cid,
"light": cid,
})
require.NoError(t, err)
// Explicit connector ID is NOT a use:: prefix, so it takes priority
conn, caps, err := llm.ResolveConnector(cid, &mockIdentity{UserID: "u1"})
require.NoError(t, err)
assert.NotNil(t, conn)
assert.NotNil(t, caps)
}
func TestResolveConnector_InvalidID(t *testing.T) {
setupResolveTest(t)
_, _, err := llm.ResolveConnector("nonexistent-connector-xyz", nil)
assert.Error(t, err)
}
// --- Empty connector fallback ---
func TestResolveConnector_EmptyFallbackDefault(t *testing.T) {
cid := setupResolveTest(t)
err := llmprovider.Global.SetDefaults(map[string]string{
"default": cid,
})
require.NoError(t, err)
// Empty string → treated as use::default
conn, caps, err := llm.ResolveConnector("", nil)
require.NoError(t, err)
assert.NotNil(t, conn)
assert.NotNil(t, caps)
}

View file

@ -10,7 +10,6 @@ import (
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/i18n"
robottypes "github.com/yaoapp/yao/agent/robot/types"
searchDefaults "github.com/yaoapp/yao/agent/search/defaults"
searchTypes "github.com/yaoapp/yao/agent/search/types"
storeMongo "github.com/yaoapp/yao/agent/store/mongo"
@ -19,7 +18,6 @@ import (
"github.com/yaoapp/yao/agent/store/xun"
"github.com/yaoapp/yao/agent/types"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/llmprovider"
)
var agentDSL *types.DSL
@ -74,15 +72,6 @@ func Load(cfg config.Config) error {
agentDSL = &setting
// Register global phase agent resolver for robot pipeline.
// Robot executor falls back to this when no per-robot override is configured.
robottypes.GlobalPhaseAgentResolver = func(phase robottypes.Phase) string {
if agentDSL == nil || agentDSL.Uses == nil {
return ""
}
return agentDSL.Uses.GetPhaseAgent(string(phase))
}
// Store Setting
err = initStore()
if err != nil {
@ -227,18 +216,13 @@ func initAssistant() error {
// Set system agents configuration
if agentDSL.System != nil {
assistant.SetSystemConfig(&assistant.SystemConfig{
Default: agentDSL.System.Default,
Light: agentDSL.System.Light,
Vision: agentDSL.System.Vision,
Audio: agentDSL.System.Audio,
Heavy: agentDSL.System.Heavy,
Keyword: agentDSL.System.Keyword,
QueryDSL: agentDSL.System.QueryDSL,
Title: agentDSL.System.Title,
Prompt: agentDSL.System.Prompt,
RobotPrompt: agentDSL.System.RobotPrompt,
NeedSearch: agentDSL.System.NeedSearch,
Entity: agentDSL.System.Entity,
Default: agentDSL.System.Default,
Keyword: agentDSL.System.Keyword,
QueryDSL: agentDSL.System.QueryDSL,
Title: agentDSL.System.Title,
Prompt: agentDSL.System.Prompt,
NeedSearch: agentDSL.System.NeedSearch,
Entity: agentDSL.System.Entity,
})
}
@ -457,22 +441,6 @@ func GetSearchConfig() *searchTypes.Config {
return agentDSL.Search
}
// SyncLLMDefaults writes the agent.yml system role defaults into setting.Global.
// Must be called after both llmprovider.Init() and setting.Init() have completed.
func SyncLLMDefaults() error {
if agentDSL == nil || agentDSL.System == nil {
return nil
}
if llmprovider.Global == nil {
return fmt.Errorf("llmprovider.Global not initialized")
}
roles := buildSystemRoles(agentDSL.System)
if len(roles) == 0 {
return nil
}
return llmprovider.Global.SetDefaults(roles)
}
// defaultAssistant get the default assistant
func defaultAssistant() (*assistant.Assistant, error) {
if agentDSL.Uses == nil || agentDSL.Uses.Default == "" {
@ -481,34 +449,12 @@ func defaultAssistant() (*assistant.Assistant, error) {
return assistant.Get(agentDSL.Uses.Default)
}
// buildSystemRoles converts the System config block into a role→connectorID map
// for llmprovider.SetDefaults. Only role-level keys are written here; per-agent
// overrides (keyword, title, querydsl, etc.) are consumed by resolveSystemConnector.
func buildSystemRoles(sys *types.System) map[string]string {
roles := make(map[string]string)
add := func(role, cid string) {
if cid != "" {
roles[role] = cid
}
}
add("default", sys.Default)
add("light", sys.Light)
add("vision", sys.Vision)
add("audio", sys.Audio)
add("heavy", sys.Heavy)
return roles
}
// resolveEnvStrings resolves $ENV.XXX references in agent.yml string fields.
// agent.yml is parsed via yaml.Unmarshal which does not handle $ENV substitution,
// unlike connector files which call helper.EnvString explicitly during Register.
func resolveEnvStrings(setting *types.DSL) {
if setting.System != nil {
setting.System.Default = helper.EnvString(setting.System.Default)
setting.System.Light = helper.EnvString(setting.System.Light)
setting.System.Vision = helper.EnvString(setting.System.Vision)
setting.System.Audio = helper.EnvString(setting.System.Audio)
setting.System.Heavy = helper.EnvString(setting.System.Heavy)
setting.System.Keyword = helper.EnvString(setting.System.Keyword)
setting.System.QueryDSL = helper.EnvString(setting.System.QueryDSL)
setting.System.Title = helper.EnvString(setting.System.Title)
@ -531,13 +477,6 @@ func resolveEnvStrings(setting *types.DSL) {
setting.Uses.Keyword = helper.EnvString(setting.Uses.Keyword)
setting.Uses.QueryDSL = helper.EnvString(setting.Uses.QueryDSL)
setting.Uses.Rerank = helper.EnvString(setting.Uses.Rerank)
setting.Uses.Inspiration = helper.EnvString(setting.Uses.Inspiration)
setting.Uses.Goals = helper.EnvString(setting.Uses.Goals)
setting.Uses.Tasks = helper.EnvString(setting.Uses.Tasks)
setting.Uses.Delivery = helper.EnvString(setting.Uses.Delivery)
setting.Uses.Learning = helper.EnvString(setting.Uses.Learning)
setting.Uses.Host = helper.EnvString(setting.Uses.Host)
setting.Uses.Validation = helper.EnvString(setting.Uses.Validation)
}
setting.Cache = helper.EnvString(setting.Cache)

View file

@ -228,8 +228,6 @@ func TestResolveEnvStrings(t *testing.T) {
RobotPrompt: "$ENV.TEST_CONNECTOR",
NeedSearch: "$ENV.TEST_CONNECTOR",
Entity: "$ENV.TEST_CONNECTOR",
Vision: "$ENV.TEST_CONNECTOR",
Audio: "$ENV.TEST_CONNECTOR",
},
}
resolveEnvStrings(setting)
@ -242,25 +240,6 @@ func TestResolveEnvStrings(t *testing.T) {
assert.Equal(t, "openai.gpt-5", setting.System.RobotPrompt)
assert.Equal(t, "openai.gpt-5", setting.System.NeedSearch)
assert.Equal(t, "openai.gpt-5", setting.System.Entity)
assert.Equal(t, "openai.gpt-5", setting.System.Vision)
assert.Equal(t, "openai.gpt-5", setting.System.Audio)
})
t.Run("SystemVisionAudioSeparateEnv", func(t *testing.T) {
t.Setenv("TEST_VISION_CONN", "openai.gpt-4o")
t.Setenv("TEST_AUDIO_CONN", "whisper-1")
setting := &types.DSL{
System: &types.System{
Default: "$ENV.TEST_CONNECTOR",
Vision: "$ENV.TEST_VISION_CONN",
Audio: "$ENV.TEST_AUDIO_CONN",
},
}
resolveEnvStrings(setting)
assert.Equal(t, "openai.gpt-5", setting.System.Default)
assert.Equal(t, "openai.gpt-4o", setting.System.Vision)
assert.Equal(t, "whisper-1", setting.System.Audio)
})
t.Run("UsesFields", func(t *testing.T) {

View file

@ -66,27 +66,6 @@ func NewToolCallMessage(id, name, arguments string) *message.Message {
}
}
// NewExecuteMessage creates an execute observation message for sandbox CLI agent actions.
//
// tool: the tool name (e.g., "Bash", "Read", "Write")
// toolID: the agent-side tool call ID
// input: the tool input (structured, may be nil for status-only updates)
// status: "running" | "completed" | "error"
func NewExecuteMessage(tool, toolID string, input interface{}, status string) *message.Message {
props := map[string]interface{}{
"tool": tool,
"tool_id": toolID,
"status": status,
}
if input != nil {
props["input"] = input
}
return &message.Message{
Type: message.TypeExecute,
Props: props,
}
}
// NewErrorMessage creates an error message
func NewErrorMessage(msg, code string) *message.Message {
return &message.Message{
@ -156,7 +135,7 @@ func NewVideoMessage(url string) *message.Message {
// IsBuiltinType checks if a message type is a built-in type
func IsBuiltinType(msgType string) bool {
switch msgType {
case message.TypeUserInput, message.TypeText, message.TypeThinking, message.TypeLoading, message.TypeToolCall, message.TypeExecute, message.TypeError, message.TypeImage, message.TypeAudio, message.TypeVideo, message.TypeAction, message.TypeEvent:
case message.TypeUserInput, message.TypeText, message.TypeThinking, message.TypeLoading, message.TypeToolCall, message.TypeError, message.TypeImage, message.TypeAudio, message.TypeVideo, message.TypeAction, message.TypeEvent:
return true
default:
return false

View file

@ -75,9 +75,6 @@ const (
TypeAudio = "audio" // Audio content
TypeVideo = "video" // Video content
// Agent execution observation types
TypeExecute = "execute" // Agent tool execution observation (sandbox CLI agent actions, not LLM tool_call requests)
// System types (not visible in standard chat clients)
TypeAction = "action" // System action (open panel, navigate, etc.) - silent in OpenAI clients
TypeEvent = "event" // Lifecycle event (stream_start, stream_end, etc.) - CUI only, silent in OpenAI clients
@ -208,34 +205,6 @@ type VideoProps struct {
Loop bool `json:"loop,omitempty"` // Whether to loop
}
// ExecuteProps defines the standard structure for execute messages.
// Type: "execute"
//
// Represents an autonomous action taken by an external Agent (e.g., Claude CLI
// in a sandbox container, Codex CLI, or any future CLI-based Agent). Unlike
// tool_call (which is a call request that Yao dispatches), execute is an
// observation of an action that already happened inside the Agent's runtime.
//
// Lifecycle (via delta merge on the same MessageID):
//
// 1. message_start {type: "execute"}
// 2. ChunkExecute {tool, tool_id, input, status:"running"} — merge
// 3. ChunkExecute {tool_id, output, status:"completed"} — merge
// 4. message_end
type ExecuteProps struct {
Tool string `json:"tool"` // Tool name (e.g., "Bash", "Read", "Write", "mcp__github__search")
ToolID string `json:"tool_id"` // Agent-side tool call ID (e.g., "toolu_abc123")
Input interface{} `json:"input,omitempty"` // Tool input (structured, e.g., {"command":"ls -la"})
Output interface{} `json:"output,omitempty"` // Tool execution result (via delta merge)
Status string `json:"status"` // "running" | "completed" | "error"
IsError bool `json:"is_error,omitempty"` // Whether the tool execution failed
ExitCode *int `json:"exit_code,omitempty"` // Process exit code (Bash-type tools)
Runner string `json:"runner,omitempty"` // Runner identifier (e.g., "claude-cli", "codex-cli")
Metadata map[string]interface{} `json:"metadata,omitempty"` // Extensible metadata per runner
}
// Delta action constants for incremental updates
const (
DeltaAppend = "append" // Append (for arrays, strings)
@ -255,7 +224,6 @@ const (
ChunkToolCall StreamChunkType = "tool_call" // Tool/function call
ChunkRefusal StreamChunkType = "refusal" // Model refusal
ChunkMetadata StreamChunkType = "metadata" // Metadata (usage, finish_reason, etc.)
ChunkExecute StreamChunkType = "execute" // Agent execution observation (sandbox CLI agent tool use)
ChunkError StreamChunkType = "error" // Error chunk
ChunkUnknown StreamChunkType = "unknown" // Unknown/unrecognized chunk type

View file

@ -78,7 +78,7 @@ func TestAPIFullLifecycle(t *testing.T) {
assert.Equal(t, 5, status.MaxRunning)
// 5. List robots
listResult, err := api.ListAllRobots(ctx, &api.ListQuery{
listResult, err := api.ListRobots(ctx, &api.ListQuery{
TeamID: "team_api_001",
Page: 1,
PageSize: 10,
@ -145,8 +145,8 @@ func TestAPIRobotQueryWithData(t *testing.T) {
assert.Equal(t, types.RobotIdle, robot.Status)
})
t.Run("ListAllRobots filters by team", func(t *testing.T) {
result, err := api.ListAllRobots(ctx, &api.ListQuery{
t.Run("ListRobots filters by team", func(t *testing.T) {
result, err := api.ListRobots(ctx, &api.ListQuery{
TeamID: "team_api_query",
Page: 1,
PageSize: 10,
@ -165,9 +165,9 @@ func TestAPIRobotQueryWithData(t *testing.T) {
}
})
t.Run("ListAllRobots pagination works", func(t *testing.T) {
t.Run("ListRobots pagination works", func(t *testing.T) {
// Page 1 with size 1
result1, err := api.ListAllRobots(ctx, &api.ListQuery{
result1, err := api.ListRobots(ctx, &api.ListQuery{
TeamID: "team_api_query",
Page: 1,
PageSize: 1,
@ -176,7 +176,7 @@ func TestAPIRobotQueryWithData(t *testing.T) {
require.GreaterOrEqual(t, len(result1.Data), 1, "Should have at least 1 robot on page 1")
// Page 2 with size 1
result2, err := api.ListAllRobots(ctx, &api.ListQuery{
result2, err := api.ListRobots(ctx, &api.ListQuery{
TeamID: "team_api_query",
Page: 2,
PageSize: 1,
@ -188,8 +188,8 @@ func TestAPIRobotQueryWithData(t *testing.T) {
assert.NotEqual(t, result1.Data[0].MemberID, result2.Data[0].MemberID)
})
t.Run("ListAllRobots filters by keywords", func(t *testing.T) {
result, err := api.ListAllRobots(ctx, &api.ListQuery{
t.Run("ListRobots filters by keywords", func(t *testing.T) {
result, err := api.ListRobots(ctx, &api.ListQuery{
Keywords: "robot_api_query_001",
Page: 1,
PageSize: 10,
@ -205,8 +205,8 @@ func TestAPIRobotQueryWithData(t *testing.T) {
})
}
// TestListAllRobotsAutonomousModeFilter tests the autonomous_mode filter
func TestListAllRobotsAutonomousModeFilter(t *testing.T) {
// TestListRobotsAutonomousModeFilter tests the autonomous_mode filter
func TestListRobotsAutonomousModeFilter(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
@ -224,8 +224,8 @@ func TestListAllRobotsAutonomousModeFilter(t *testing.T) {
ctx := types.NewContext(context.Background(), nil)
t.Run("ListAllRobots returns all robots when autonomous_mode is nil", func(t *testing.T) {
result, err := api.ListAllRobots(ctx, &api.ListQuery{
t.Run("ListRobots returns all robots when autonomous_mode is nil", func(t *testing.T) {
result, err := api.ListRobots(ctx, &api.ListQuery{
TeamID: "team_api_mode",
Page: 1,
PageSize: 10,
@ -237,9 +237,9 @@ func TestListAllRobotsAutonomousModeFilter(t *testing.T) {
assert.Equal(t, 3, result.Total)
})
t.Run("ListAllRobots filters by autonomous_mode=true", func(t *testing.T) {
t.Run("ListRobots filters by autonomous_mode=true", func(t *testing.T) {
autonomousMode := true
result, err := api.ListAllRobots(ctx, &api.ListQuery{
result, err := api.ListRobots(ctx, &api.ListQuery{
TeamID: "team_api_mode",
AutonomousMode: &autonomousMode,
Page: 1,
@ -255,9 +255,9 @@ func TestListAllRobotsAutonomousModeFilter(t *testing.T) {
}
})
t.Run("ListAllRobots filters by autonomous_mode=false", func(t *testing.T) {
t.Run("ListRobots filters by autonomous_mode=false", func(t *testing.T) {
autonomousMode := false
result, err := api.ListAllRobots(ctx, &api.ListQuery{
result, err := api.ListRobots(ctx, &api.ListQuery{
TeamID: "team_api_mode",
AutonomousMode: &autonomousMode,
Page: 1,

View file

@ -94,7 +94,7 @@ func TestE2ENormalExecutionNoSuspend(t *testing.T) {
result := triggerSuspendRobot(t, ctx, memberID, "Write a one-sentence greeting")
exec := waitForStatus(t, result.ExecutionID,
[]types.ExecStatus{types.ExecCompleted, types.ExecFailed}, 120*time.Second)
[]types.ExecStatus{types.ExecCompleted, types.ExecFailed}, 60*time.Second)
require.NotNil(t, exec, "Execution should exist and reach terminal state")
if exec.Status == types.ExecFailed {
@ -137,7 +137,7 @@ func TestE2ESuspendResumeFlow(t *testing.T) {
// Step 2: Wait for the execution to reach waiting status
exec := waitForStatus(t, execID,
[]types.ExecStatus{types.ExecWaiting, types.ExecCompleted, types.ExecFailed}, 120*time.Second)
[]types.ExecStatus{types.ExecWaiting, types.ExecCompleted, types.ExecFailed}, 60*time.Second)
require.NotNil(t, exec, "Execution should exist")
require.Equal(t, types.ExecWaiting, exec.Status, "Execution should be in waiting status")
@ -157,10 +157,9 @@ func TestE2ESuspendResumeFlow(t *testing.T) {
require.NoError(t, err)
require.NotNil(t, interactResult)
// The Host Agent may return a structured action (→ "waiting"/"resumed") or
// a conversational reply (→ "waiting_for_more") depending on LLM behaviour.
assert.Contains(t, []string{"waiting", "resumed", "waiting_for_more"}, interactResult.Status,
"Expected waiting, resumed, or waiting_for_more; got %s", interactResult.Status)
// Since robot-need-input always signals need_input, the resumed execution
// will re-suspend. The Interact API returns "waiting" status in this case.
assert.Equal(t, "waiting", interactResult.Status, "Should re-suspend since assistant always signals need_input")
t.Logf("Interact result: status=%s message=%s", interactResult.Status, interactResult.Message)
// Step 4: Verify the execution is in waiting status again (re-suspended)
@ -193,7 +192,7 @@ func TestE2EReplyShortcut(t *testing.T) {
result := triggerSuspendRobot(t, ctx, memberID, "Check inventory levels")
exec := waitForStatus(t, result.ExecutionID,
[]types.ExecStatus{types.ExecWaiting}, 120*time.Second)
[]types.ExecStatus{types.ExecWaiting}, 60*time.Second)
require.NotNil(t, exec, "Execution should reach waiting status")
require.Equal(t, types.ExecWaiting, exec.Status)
@ -201,7 +200,7 @@ func TestE2EReplyShortcut(t *testing.T) {
replyResult, err := api.Reply(ctx, memberID, result.ExecutionID, exec.WaitingTaskID, "Use warehouse A data")
require.NoError(t, err)
require.NotNil(t, replyResult)
assert.Contains(t, []string{"waiting", "resumed", "waiting_for_more"}, replyResult.Status)
assert.Contains(t, []string{"waiting", "resumed"}, replyResult.Status)
t.Logf("Reply result: status=%s", replyResult.Status)
}
@ -229,7 +228,7 @@ func TestE2EResumeContextPersistence(t *testing.T) {
result := triggerSuspendRobot(t, ctx, memberID, "Analyze user behavior")
exec := waitForStatus(t, result.ExecutionID,
[]types.ExecStatus{types.ExecWaiting, types.ExecCompleted, types.ExecFailed}, 120*time.Second)
[]types.ExecStatus{types.ExecWaiting, types.ExecCompleted, types.ExecFailed}, 60*time.Second)
require.NotNil(t, exec)
if exec.Status != types.ExecWaiting {
@ -298,7 +297,7 @@ func TestE2EInteractWithNonWaitingExecution(t *testing.T) {
// Wait for completion
exec := waitForStatus(t, result.ExecutionID,
[]types.ExecStatus{types.ExecCompleted, types.ExecFailed}, 120*time.Second)
[]types.ExecStatus{types.ExecCompleted, types.ExecFailed}, 60*time.Second)
require.NotNil(t, exec, "Execution should reach terminal state")
// Try to interact with the completed execution

View file

@ -11,7 +11,6 @@ import (
dcadapter "github.com/yaoapp/yao/agent/robot/events/integrations/discord"
fsadapter "github.com/yaoapp/yao/agent/robot/events/integrations/feishu"
"github.com/yaoapp/yao/agent/robot/events/integrations/telegram"
weixinadapter "github.com/yaoapp/yao/agent/robot/events/integrations/weixin"
"github.com/yaoapp/yao/agent/robot/logger"
"github.com/yaoapp/yao/agent/robot/manager"
"github.com/yaoapp/yao/agent/robot/types"
@ -66,7 +65,6 @@ func Start() error {
"feishu": fsadapter.NewAdapter(),
"dingtalk": dtadapter.NewAdapter(),
"discord": dcadapter.NewAdapter(),
"weixin": weixinadapter.NewAdapter(),
}
globalDispatcher = integrations.NewDispatcher(globalManager.Cache(), adapters)
if err := globalDispatcher.Start(context.Background()); err != nil {
@ -137,16 +135,6 @@ func getManager() (*manager.Manager, error) {
return globalManager, nil
}
// GetManager returns the global manager instance, or nil if not started.
func GetManager() *manager.Manager {
managerMu.RLock()
defer managerMu.RUnlock()
if globalManager == nil || !globalManager.IsStarted() {
return nil
}
return globalManager
}
// SetManager sets the global manager instance (for testing)
func SetManager(m *manager.Manager) {
managerMu.Lock()

View file

@ -3,7 +3,6 @@ package api
import (
"context"
"fmt"
"strings"
"time"
gonanoid "github.com/matoous/go-nanoid/v2"
@ -55,9 +54,8 @@ func GetRobot(ctx *types.Context, memberID string) (*types.Robot, error) {
return robot, nil
}
// ListAllRobots returns robots with pagination and filtering.
// Cache-first with in-memory filtering and pagination; falls back to DB when Manager is not started.
func ListAllRobots(ctx *types.Context, query *ListQuery) (*ListResult, error) {
// ListRobots returns robots with pagination and filtering
func ListRobots(ctx *types.Context, query *ListQuery) (*ListResult, error) {
if query == nil {
query = &ListQuery{}
}
@ -65,49 +63,21 @@ func ListAllRobots(ctx *types.Context, query *ListQuery) (*ListResult, error) {
mgr, err := getManager()
if err != nil {
return ListRobotsFromDB(query)
// Manager not started, load directly from database
return listRobotsFromDB(query)
}
var all []*types.Robot
if query.TeamID != "" {
all = mgr.Cache().List(query.TeamID)
} else {
all = mgr.Cache().ListAll()
// If only teamID specified AND explicitly filtering for autonomous_mode=true, use cache
// Cache only contains autonomous_mode=true robots
// When autonomous_mode is not specified or false, must query database to include all robots
if query.TeamID != "" && query.Status == "" && query.Keywords == "" && query.ClockMode == "" &&
query.AutonomousMode != nil && *query.AutonomousMode == true {
robots := mgr.Cache().List(query.TeamID)
return paginateRobots(robots, query), nil
}
filtered := make([]*types.Robot, 0, len(all))
for _, r := range all {
if matchQuery(r, query) {
filtered = append(filtered, r)
}
}
return paginateRobots(filtered, query), nil
}
// matchQuery checks whether a robot matches the given query filters.
// TeamID filtering is handled upstream (cache.List / cache.ListAll).
func matchQuery(r *types.Robot, q *ListQuery) bool {
if q.Status != "" && r.Status != q.Status {
return false
}
if q.AutonomousMode != nil && r.AutonomousMode != *q.AutonomousMode {
return false
}
if q.ClockMode != "" {
if r.Config == nil || r.Config.Clock == nil || r.Config.Clock.Mode != q.ClockMode {
return false
}
}
if q.Keywords != "" {
kw := strings.ToLower(q.Keywords)
if !strings.Contains(strings.ToLower(r.DisplayName), kw) &&
!strings.Contains(strings.ToLower(r.Bio), kw) &&
!strings.Contains(strings.ToLower(r.MemberID), kw) {
return false
}
}
return true
// For complex queries, load from database
return listRobotsFromDB(query)
}
// GetRobotStatus returns the runtime status of a robot
@ -199,7 +169,7 @@ func loadRobotFromDB(memberID string) (*types.Robot, error) {
"id", "member_id", "team_id", "display_name", "bio",
"system_prompt", "robot_status", "autonomous_mode",
"robot_config", "robot_email", "agents", "mcp_servers",
"manager_id", "language_model", "workspace",
"manager_id", "language_model",
},
Wheres: []model.QueryWhere{
{Column: "member_id", Value: memberID},
@ -218,14 +188,8 @@ func loadRobotFromDB(memberID string) (*types.Robot, error) {
return types.NewRobotFromMap(map[string]interface{}(records[0]))
}
// ListRobotsFromDB loads robots from database with filtering.
// Exported as a fallback for callers that explicitly need DB queries.
func ListRobotsFromDB(query *ListQuery) (*ListResult, error) {
if query == nil {
query = &ListQuery{}
}
query.applyDefaults()
// listRobotsFromDB loads robots from database with filtering
func listRobotsFromDB(query *ListQuery) (*ListResult, error) {
m := model.Select(memberModel)
if m == nil {
return nil, fmt.Errorf("model %s not found", memberModel)
@ -268,7 +232,7 @@ func ListRobotsFromDB(query *ListQuery) (*ListResult, error) {
"id", "member_id", "team_id", "display_name", "bio",
"system_prompt", "robot_status", "autonomous_mode",
"robot_config", "robot_email", "agents", "mcp_servers",
"language_model", "workspace",
"language_model",
},
Wheres: wheres,
Orders: orders,
@ -342,26 +306,6 @@ func paginateRobots(robots []*types.Robot, query *ListQuery) *ListResult {
}
}
// ListAutonomousRobots returns autonomous robots from cache.
// When teamID is empty, returns all autonomous robots across all teams.
func ListAutonomousRobots(teamID string) []*types.Robot {
mgr, err := getManager()
if err != nil {
return nil
}
if teamID == "" {
return mgr.Cache().ListAutonomous()
}
all := mgr.Cache().List(teamID)
robots := make([]*types.Robot, 0, len(all))
for _, r := range all {
if r.AutonomousMode {
robots = append(robots, r)
}
}
return robots
}
// ==================== Robot CRUD API ====================
// These functions create, update, and delete robots
// They call store layer for persistence and manage cache
@ -444,7 +388,6 @@ func CreateRobot(ctx *types.Context, req *CreateRobotRequest) (*RobotResponse, e
Agents: req.Agents,
MCPServers: req.MCPServers,
LanguageModel: req.LanguageModel,
Workspace: req.Workspace,
// Limits
CostLimit: req.CostLimit,
@ -471,6 +414,9 @@ func CreateRobot(ctx *types.Context, req *CreateRobotRequest) (*RobotResponse, e
}
// Refresh cache if manager is running
// Use Refresh() which handles autonomous_mode correctly:
// - If autonomous_mode=true: adds to cache for scheduling
// - If autonomous_mode=false: does not add to cache
mgr, err := getManager()
if err == nil && mgr != nil {
_ = mgr.Cache().Refresh(ctx, req.MemberID)
@ -560,9 +506,6 @@ func UpdateRobot(ctx *types.Context, memberID string, req *UpdateRobotRequest) (
if req.LanguageModel != nil {
existing.LanguageModel = *req.LanguageModel
}
if req.Workspace != nil {
existing.Workspace = *req.Workspace
}
// Limits
if req.CostLimit != nil {
@ -589,9 +532,12 @@ func UpdateRobot(ctx *types.Context, memberID string, req *UpdateRobotRequest) (
}
// Refresh cache if manager is running
// Use Refresh() which handles autonomous_mode correctly:
// - If autonomous_mode=true: adds to cache for scheduling
// - If autonomous_mode=false: removes from cache
mgr, err := getManager()
if err == nil && mgr != nil {
_ = mgr.Cache().Refresh(ctx, memberID)
_ = mgr.Cache().Refresh(ctx, memberID) // Ignore error, database is already saved
}
// Notify integrations of updated robot config
@ -688,7 +634,6 @@ func recordToResponse(record *store.RobotRecord) *RobotResponse {
Agents: record.Agents,
MCPServers: record.MCPServers,
LanguageModel: record.LanguageModel,
Workspace: record.Workspace,
CostLimit: record.CostLimit,
InvitedBy: record.InvitedBy,

View file

@ -36,8 +36,8 @@ func TestGetRobotValidation(t *testing.T) {
})
}
// TestListAllRobotsValidation tests parameter validation for ListAllRobots
func TestListAllRobotsValidation(t *testing.T) {
// TestListRobotsValidation tests parameter validation for ListRobots
func TestListRobotsValidation(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
@ -48,7 +48,7 @@ func TestListAllRobotsValidation(t *testing.T) {
ctx := types.NewContext(context.Background(), nil)
t.Run("applies default pagination when query is nil", func(t *testing.T) {
result, err := api.ListAllRobots(ctx, nil)
result, err := api.ListRobots(ctx, nil)
assert.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, 1, result.Page)
@ -56,7 +56,7 @@ func TestListAllRobotsValidation(t *testing.T) {
})
t.Run("applies default pagination when values are zero", func(t *testing.T) {
result, err := api.ListAllRobots(ctx, &api.ListQuery{
result, err := api.ListRobots(ctx, &api.ListQuery{
Page: 0,
PageSize: 0,
})
@ -67,7 +67,7 @@ func TestListAllRobotsValidation(t *testing.T) {
})
t.Run("caps pagesize at 100", func(t *testing.T) {
result, err := api.ListAllRobots(ctx, &api.ListQuery{
result, err := api.ListRobots(ctx, &api.ListQuery{
Page: 1,
PageSize: 500,
})

View file

@ -111,7 +111,6 @@ func triggerHuman(ctx *types.Context, mgr managerInterface, memberID string, req
Messages: req.Messages,
PlanTime: req.PlanAt,
ExecutorMode: req.ExecutorMode,
Locale: req.Locale,
}
// Call manager's Intervene

View file

@ -144,7 +144,7 @@ type CreateRobotRequest struct {
AutonomousMode *bool `json:"autonomous_mode,omitempty"` // Whether autonomous mode is enabled
// Communication
RobotEmail string `json:"robot_email,omitempty"` // Deprecated: Robot email address
RobotEmail string `json:"robot_email,omitempty"` // Robot email address
AuthorizedSenders interface{} `json:"authorized_senders,omitempty"` // Email whitelist (JSON array)
EmailFilterRules interface{} `json:"email_filter_rules,omitempty"` // Email filter rules (JSON array)
@ -153,7 +153,6 @@ type CreateRobotRequest struct {
Agents interface{} `json:"agents,omitempty"` // Accessible agents (JSON array)
MCPServers interface{} `json:"mcp_servers,omitempty"` // MCP servers (JSON array)
LanguageModel string `json:"language_model,omitempty"` // Language model name
Workspace string `json:"workspace,omitempty"` // Workspace ID bound to this robot
// Limits
CostLimit float64 `json:"cost_limit,omitempty"` // Monthly cost limit USD
@ -180,7 +179,7 @@ type UpdateRobotRequest struct {
AutonomousMode *bool `json:"autonomous_mode,omitempty"` // Autonomous mode
// Communication
RobotEmail *string `json:"robot_email,omitempty"` // Deprecated: Robot email address
RobotEmail *string `json:"robot_email,omitempty"` // Robot email address
AuthorizedSenders interface{} `json:"authorized_senders,omitempty"` // Email whitelist
EmailFilterRules interface{} `json:"email_filter_rules,omitempty"` // Email filter rules
@ -189,7 +188,6 @@ type UpdateRobotRequest struct {
Agents interface{} `json:"agents,omitempty"` // Accessible agents
MCPServers interface{} `json:"mcp_servers,omitempty"` // MCP servers
LanguageModel *string `json:"language_model,omitempty"` // Language model name
Workspace *string `json:"workspace,omitempty"` // Workspace ID (nil=no change, ""=unbind)
// Limits
CostLimit *float64 `json:"cost_limit,omitempty"` // Monthly cost limit USD
@ -228,7 +226,6 @@ type RobotResponse struct {
Agents interface{} `json:"agents,omitempty"`
MCPServers interface{} `json:"mcp_servers,omitempty"`
LanguageModel string `json:"language_model,omitempty"`
Workspace string `json:"workspace,omitempty"`
// Limits
CostLimit float64 `json:"cost_limit,omitempty"`

View file

@ -1,103 +0,0 @@
package api
import (
"context"
"fmt"
"sync"
"time"
"github.com/google/uuid"
weixinapi "github.com/yaoapp/yao/integrations/weixin"
)
const (
qrSessionTTL = 5 * time.Minute
maxQRRefreshCount = 3
)
type qrSession struct {
qrcode string
apiHost string
startedAt time.Time
refreshes int
}
var (
qrSessions = make(map[string]*qrSession)
qrSessionsMu sync.Mutex
)
// WeixinQRCodeCreate creates a new QR code session for WeChat login.
// Returns the session key and QR code URL.
func WeixinQRCodeCreate(apiHost string) (sessionKey, qrcodeURL, qrcodeImg string, err error) {
qrcode, qrcodeImgContent, err := weixinapi.GetQRCode(context.Background(), apiHost)
if err != nil {
return "", "", "", fmt.Errorf("get QR code: %w", err)
}
sessionKey = uuid.New().String()
qrSessionsMu.Lock()
qrSessions[sessionKey] = &qrSession{
qrcode: qrcode,
apiHost: apiHost,
startedAt: time.Now(),
}
qrSessionsMu.Unlock()
return sessionKey, qrcode, qrcodeImgContent, nil
}
// WeixinQRCodePoll polls the QR code status for a given session.
func WeixinQRCodePoll(sessionKey string) (status, botToken, accountID, baseURL, userID string, err error) {
qrSessionsMu.Lock()
session, ok := qrSessions[sessionKey]
if !ok {
qrSessionsMu.Unlock()
return "", "", "", "", "", fmt.Errorf("session not found: %s", sessionKey)
}
if time.Since(session.startedAt) > qrSessionTTL {
if session.refreshes < maxQRRefreshCount {
session.refreshes++
session.startedAt = time.Now()
apiHost := session.apiHost
qrSessionsMu.Unlock()
newQR, _, refreshErr := weixinapi.GetQRCode(context.Background(), apiHost)
if refreshErr != nil {
qrSessionsMu.Lock()
delete(qrSessions, sessionKey)
qrSessionsMu.Unlock()
return "expired", "", "", "", "", nil
}
qrSessionsMu.Lock()
if s, ok := qrSessions[sessionKey]; ok {
s.qrcode = newQR
}
qrSessionsMu.Unlock()
return "refreshed", "", "", "", "", nil
}
delete(qrSessions, sessionKey)
qrSessionsMu.Unlock()
return "expired", "", "", "", "", nil
}
qrcode := session.qrcode
apiHost := session.apiHost
qrSessionsMu.Unlock()
resp, err := weixinapi.PollQRStatus(context.Background(), apiHost, qrcode)
if err != nil {
return "wait", "", "", "", "", nil
}
if resp.Status == "confirmed" {
qrSessionsMu.Lock()
delete(qrSessions, sessionKey)
qrSessionsMu.Unlock()
return resp.Status, resp.BotToken, resp.IlinkBotID, resp.BaseURL, resp.UserID, nil
}
return resp.Status, "", "", "", "", nil
}

View file

@ -39,7 +39,7 @@ func TestCacheLoad(t *testing.T) {
// Count should be at least 2 (may have other robots in DB)
count := c.Count()
assert.GreaterOrEqual(t, count, 2, "Should load at least 2 active robots")
assert.GreaterOrEqual(t, count, 2, "Should load at least 2 active autonomous robots")
// Verify first robot
robot1 := c.Get("robot_test_sales_001")

View file

@ -28,7 +28,6 @@ var memberFields = []interface{}{
"mcp_servers",
"manager_id",
"language_model",
"workspace",
}
// SetMemberModel sets the member model name
@ -40,7 +39,7 @@ func SetMemberModel(model string) {
}
// Load loads all active robots from database with pagination
// Query: member_type='robot' AND status='active'
// Query: member_type='robot' AND autonomous_mode=true AND status='active'
func (c *Cache) Load(ctx *types.Context) error {
m := model.Select(memberModel)
@ -61,6 +60,7 @@ func (c *Cache) Load(ctx *types.Context) error {
Select: memberFields,
Wheres: []model.QueryWhere{
{Column: "member_type", Value: "robot"},
{Column: "autonomous_mode", Value: true},
{Column: "status", Value: "active"},
},
}, page, pageSize)

View file

@ -32,7 +32,7 @@ var refresher = &refreshState{}
func (c *Cache) Refresh(ctx *types.Context, memberID string) error {
robot, err := c.LoadByID(ctx, memberID)
if err != nil {
// If robot not found, remove from cache
// If robot not found or no longer autonomous, remove from cache
if err == types.ErrRobotNotFound {
c.Remove(memberID)
return nil
@ -40,6 +40,12 @@ func (c *Cache) Refresh(ctx *types.Context, memberID string) error {
return err
}
// Check if robot is still active and autonomous
if !robot.AutonomousMode {
c.Remove(memberID)
return nil
}
// Update cache
c.Add(robot)
return nil
@ -111,20 +117,6 @@ func (c *Cache) ListAll() []*types.Robot {
return robots
}
// ListAutonomous returns all cached robots with AutonomousMode=true.
func (c *Cache) ListAutonomous() []*types.Robot {
c.mu.RLock()
defer c.mu.RUnlock()
robots := make([]*types.Robot, 0, len(c.robots)/2)
for _, r := range c.robots {
if r.AutonomousMode {
robots = append(robots, r)
}
}
return robots
}
// GetByStatus returns robots with the specified status
func (c *Cache) GetByStatus(status types.RobotStatus) []*types.Robot {
c.mu.RLock()

View file

@ -1,11 +0,0 @@
package robot
import (
_ "embed"
"github.com/yaoapp/gou/doc"
)
//go:embed doc.yml
var docYAML []byte
func init() { doc.LoadYAML(docYAML) }

View file

@ -1,80 +0,0 @@
group: robot
type: process
entries:
- name: get
desc: Get a robot's details by member ID
args:
- name: memberID
type: string
required: true
desc: The member ID of the robot to retrieve
return:
type: object
desc: Robot detail object
- name: list
desc: List all robots with optional filtering and pagination
args:
- name: filter
type: object
required: false
desc: "Filter options: page (number), pagesize (number), status (string), search (string, keywords), team_id (string)"
return:
type: object
desc: Paginated list of robots
- name: status
desc: Get the current status of a robot by member ID
args:
- name: memberID
type: string
required: true
desc: The member ID of the robot
return:
type: object
desc: Robot status object
- name: executions
desc: List executions for a robot with optional filtering and pagination
args:
- name: memberID
type: string
required: true
desc: The member ID of the robot
- name: filter
type: object
required: false
desc: "Filter options: page (number), pagesize (number), status (string, execution status), trigger (string, trigger type)"
return:
type: object
desc: Paginated list of execution records
- name: execution
desc: Get a specific execution record by member ID and execution ID
args:
- name: memberID
type: string
required: true
desc: The member ID of the robot (reserved for permission scoping)
- name: executionID
type: string
required: true
desc: The execution ID to retrieve
return:
type: object
desc: Execution status and details
- name: updateChatTitle
desc: Update the title of a chat session
args:
- name: chatID
type: string
required: true
desc: The chat session ID to update
- name: title
type: string
required: true
desc: The new title for the chat session
return:
type: "null"
desc: Returns null on success

View file

@ -13,8 +13,6 @@ import (
"strings"
"time"
"path/filepath"
"github.com/yaoapp/gou/process"
"github.com/yaoapp/gou/text"
agentcontext "github.com/yaoapp/yao/agent/context"
@ -23,7 +21,6 @@ import (
eventtypes "github.com/yaoapp/yao/event/types"
"github.com/yaoapp/yao/messenger"
messengerTypes "github.com/yaoapp/yao/messenger/types"
"github.com/yaoapp/yao/workspace"
)
// handleDelivery routes delivery content to configured channels (email, webhook, process).
@ -55,10 +52,6 @@ func (h *robotHandler) handleDelivery(ctx context.Context, ev *eventtypes.Event,
return
}
if ev.Auth != nil {
ctx = context.WithValue(ctx, "identity", ev.Auth)
}
deliveryCtx := &robottypes.DeliveryContext{
MemberID: payload.MemberID,
ExecutionID: payload.ExecutionID,
@ -111,14 +104,10 @@ func (h *robotHandler) handleDelivery(ctx context.Context, ev *eventtypes.Event,
for k, v := range payload.Extra {
extra[k] = v
}
senderID, _ := payload.Extra["sender_id"].(string)
appID, _ := payload.Extra["app_id"].(string)
metadata := &MessageMetadata{
Channel: channel,
ChatID: chatID,
SenderID: senderID,
AppID: appID,
Extra: extra,
Channel: channel,
ChatID: chatID,
Extra: extra,
}
if err := reply(ctx, msg, metadata); err != nil {
log.Error("delivery handler: integration reply failed channel=%s execution=%s: %v", channel, payload.ExecutionID, err)
@ -435,15 +424,6 @@ func convertAttachments(ctx context.Context, attachments []robottypes.DeliveryAt
result := make([]messengerTypes.Attachment, 0, len(attachments))
for _, att := range attachments {
// Handle workspace:// URIs — read file content from workspace FS
if strings.HasPrefix(att.File, "workspace://") {
wsAtt := convertWorkspaceAttachment(ctx, att)
if wsAtt != nil {
result = append(result, *wsAtt)
}
continue
}
uploader, fileID, isWrapper := attachment.Parse(att.File)
if !isWrapper {
log.Warn("convertAttachments: skipping non-wrapper file value=%q title=%q", att.File, att.Title)
@ -493,87 +473,6 @@ func convertAttachments(ctx context.Context, attachments []robottypes.DeliveryAt
return result
}
// convertWorkspaceAttachment reads a file from workspace:// URI and returns a messenger attachment.
// URI format: workspace://<wsID>/<path>
func convertWorkspaceAttachment(ctx context.Context, att robottypes.DeliveryAttachment) *messengerTypes.Attachment {
uri := att.File
// Strip "workspace://" prefix
rest := strings.TrimPrefix(uri, "workspace://")
slashIdx := strings.Index(rest, "/")
if slashIdx < 0 {
log.Warn("convertWorkspaceAttachment: invalid URI %q — no path after wsID", uri)
return nil
}
wsID := rest[:slashIdx]
filePath := rest[slashIdx+1:]
if wsID == "" || filePath == "" {
log.Warn("convertWorkspaceAttachment: empty wsID or path in URI %q", uri)
return nil
}
wsm := workspace.M()
if wsm == nil {
log.Warn("convertWorkspaceAttachment: workspace manager not available for URI %q", uri)
return nil
}
wsFS, err := wsm.FS(ctx, wsID)
if err != nil {
log.Warn("convertWorkspaceAttachment: cannot get FS for workspace %q: %v", wsID, err)
return nil
}
content, err := wsFS.ReadFile(filePath)
if err != nil {
log.Warn("convertWorkspaceAttachment: failed to read %q from workspace %q: %v", filePath, wsID, err)
return nil
}
filename := filepath.Base(filePath)
if att.Title != "" {
filename = att.Title
}
contentType := mimeFromExtDelivery(filepath.Ext(filename))
log.Info("convertWorkspaceAttachment: added workspace attachment filename=%q contentType=%q size=%d uri=%q",
filename, contentType, len(content), uri)
return &messengerTypes.Attachment{
Filename: filename,
ContentType: contentType,
Content: content,
}
}
func mimeFromExtDelivery(ext string) string {
switch strings.ToLower(ext) {
case ".pdf":
return "application/pdf"
case ".html", ".htm":
return "text/html"
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".gif":
return "image/gif"
case ".csv":
return "text/csv"
case ".json":
return "application/json"
case ".md":
return "text/markdown"
case ".txt":
return "text/plain"
case ".xlsx":
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
case ".pptx":
return "application/vnd.openxmlformats-officedocument.presentationml.presentation"
default:
return "application/octet-stream"
}
}
func attachmentManagerKeys() []string {
keys := make([]string, 0, len(attachment.Managers))
for k := range attachment.Managers {

View file

@ -67,7 +67,6 @@ const (
ExecCompleted = "robot.exec.completed"
ExecFailed = "robot.exec.failed"
ExecCancelled = "robot.exec.cancelled"
ExecRecovered = "robot.exec.recovered"
Delivery = "robot.delivery"
Message = "robot.message"
)

View file

@ -26,11 +26,10 @@ type Adapter struct {
// botEntry holds the state for one robot's DingTalk integration.
type botEntry struct {
robotID string
clientID string
clientSecret string
bot *dtapi.Bot
cancelFn context.CancelFunc
robotID string
clientID string
bot *dtapi.Bot
cancelFn context.CancelFunc
}
// NewAdapter creates a new DingTalk adapter.
@ -59,8 +58,7 @@ func (a *Adapter) Apply(ctx context.Context, robot *robottypes.Robot) {
defer a.mu.Unlock()
if existing, ok := a.bots[robot.MemberID]; ok {
if existing.clientID == dtConf.ClientID &&
existing.clientSecret == dtConf.ClientSecret {
if existing.clientID == dtConf.ClientID {
return
}
a.removeBotLocked(robot.MemberID)
@ -70,11 +68,10 @@ func (a *Adapter) Apply(ctx context.Context, robot *robottypes.Robot) {
streamCtx, streamCancel := context.WithCancel(context.Background())
entry := &botEntry{
robotID: robot.MemberID,
clientID: dtConf.ClientID,
clientSecret: dtConf.ClientSecret,
bot: bot,
cancelFn: streamCancel,
robotID: robot.MemberID,
clientID: dtConf.ClientID,
bot: bot,
cancelFn: streamCancel,
}
a.bots[robot.MemberID] = entry
a.appIdx[dtConf.ClientID] = robot.MemberID

View file

@ -62,8 +62,6 @@ func (a *Adapter) handleMessages(ctx context.Context, entry *botEntry, cms []*dt
"session_webhook": lastCM.SessionWebhook,
"conversation_type": lastCM.ConversationType,
"dt_message_id": lastCM.MessageID,
"sender_id": lastCM.SenderID,
"app_id": entry.clientID,
},
},
}
@ -91,23 +89,12 @@ func buildContentParts(cm *dtapi.ConvertedMessage) []interface{} {
if url == "" {
url = mi.URL
}
if strings.HasPrefix(mi.MimeType, "image/") {
parts = append(parts, map[string]interface{}{
"type": "image_url",
"image_url": map[string]interface{}{
"url": url,
"detail": "auto",
},
})
} else {
parts = append(parts, map[string]interface{}{
"type": "file",
"file": map[string]interface{}{
"url": url,
"filename": mi.FileName,
},
})
}
parts = append(parts, map[string]interface{}{
"type": "file",
"file_url": url,
"mime_type": mi.MimeType,
"file_name": mi.FileName,
})
}
return parts

View file

@ -58,8 +58,7 @@ func (a *Adapter) Apply(ctx context.Context, robot *robottypes.Robot) {
defer a.mu.Unlock()
if existing, ok := a.bots[robot.MemberID]; ok {
if existing.bot.Token() == dcConf.BotToken &&
existing.appID == dcConf.AppID {
if existing.bot.Token() == dcConf.BotToken {
return
}
a.removeBotLocked(robot.MemberID)

View file

@ -67,8 +67,6 @@ func (a *Adapter) handleMessages(ctx context.Context, entry *botEntry, cms []*dc
"discord_message_id": lastCM.MessageID,
"guild_id": lastCM.GuildID,
"is_dm": lastCM.IsDM,
"sender_id": lastCM.AuthorID,
"app_id": entry.appID,
},
},
}
@ -96,23 +94,12 @@ func buildContentParts(cm *dcapi.ConvertedMessage) []interface{} {
if url == "" {
continue
}
if strings.HasPrefix(mi.ContentType, "image/") {
parts = append(parts, map[string]interface{}{
"type": "image_url",
"image_url": map[string]interface{}{
"url": url,
"detail": "auto",
},
})
} else {
parts = append(parts, map[string]interface{}{
"type": "file",
"file": map[string]interface{}{
"url": url,
"filename": mi.FileName,
},
})
}
parts = append(parts, map[string]interface{}{
"type": "file",
"file_url": url,
"mime_type": mi.ContentType,
"file_name": mi.FileName,
})
}
return parts

View file

@ -4,6 +4,8 @@ import (
"context"
"fmt"
"github.com/yaoapp/gou/model"
"github.com/yaoapp/kun/maps"
agentcontext "github.com/yaoapp/yao/agent/context"
robotcache "github.com/yaoapp/yao/agent/robot/cache"
events "github.com/yaoapp/yao/agent/robot/events"
@ -20,7 +22,6 @@ type Adapter interface {
Apply(ctx context.Context, robot *robottypes.Robot)
Remove(ctx context.Context, robotID string)
Reply(ctx context.Context, msg *agentcontext.Message, metadata *events.MessageMetadata) error
Shutdown()
}
// Dispatcher distributes Robot integration configs to platform adapters.
@ -47,7 +48,7 @@ func (d *Dispatcher) Start(ctx context.Context) error {
events.RegisterReplyFunc(d.reply)
ch := make(chan *eventtypes.Event, 256)
ch := make(chan *eventtypes.Event, 64)
d.subID = event.Subscribe("robot.config.*", ch)
go d.watch(ctx, ch)
@ -80,29 +81,76 @@ func (d *Dispatcher) reply(ctx context.Context, msg *agentcontext.Message, metad
return lastErr
}
// Stop unsubscribes from events and shuts down all adapters.
// Stop unsubscribes from events.
func (d *Dispatcher) Stop() {
close(d.stopCh)
if d.subID != "" {
event.Unsubscribe(d.subID)
}
for name, adapter := range d.adapters {
adapter.Shutdown()
log.Info("integration dispatcher: adapter %s shutdown", name)
}
log.Info("integration dispatcher: stopped")
}
func (d *Dispatcher) loadAll(ctx context.Context) {
robots := d.robotCache.ListAll()
count := 0
robots := d.loadIntegrationRobots()
for _, robot := range robots {
if robot.Config != nil && robot.Config.Integrations != nil && len(parseIntegrations(robot.Config.Integrations)) > 0 {
d.apply(ctx, robot)
count++
}
d.robotCache.Add(robot)
d.apply(ctx, robot)
}
log.Info("integration dispatcher: initial load complete, %d robots with integrations", count)
log.Info("integration dispatcher: initial load complete, %d robots with integrations", len(robots))
}
// loadIntegrationRobots queries all active robots that have a non-null
// robot_config (which may contain integrations). This is independent of
// autonomous_mode so non-autonomous robots with Telegram etc. are included.
func (d *Dispatcher) loadIntegrationRobots() []*robottypes.Robot {
m := model.Select("__yao.member")
fields := []interface{}{
"id", "member_id", "team_id", "display_name", "bio",
"system_prompt", "robot_status", "autonomous_mode",
"robot_config", "robot_email", "agents", "mcp_servers",
"manager_id", "language_model",
}
page := 1
pageSize := 100
var result []*robottypes.Robot
for {
res, err := m.Paginate(model.QueryParam{
Select: fields,
Wheres: []model.QueryWhere{
{Column: "member_type", Value: "robot"},
{Column: "status", Value: "active"},
},
}, page, pageSize)
if err != nil {
log.Error("loadIntegrationRobots: query failed page=%d: %v", page, err)
break
}
data, ok := res.Get("data").([]maps.MapStr)
if !ok || len(data) == 0 {
break
}
for _, record := range data {
robot, err := robottypes.NewRobotFromMap(map[string]interface{}(record))
if err != nil {
continue
}
if robot.Config != nil && robot.Config.Integrations != nil && len(parseIntegrations(robot.Config.Integrations)) > 0 {
result = append(result, robot)
}
}
total, _ := res.Get("total").(int)
if page*pageSize >= total {
break
}
page++
}
return result
}
// apply parses which integrations the robot has configured,
@ -139,9 +187,6 @@ func parseIntegrations(intg *robottypes.Integrations) []string {
if intg.Discord != nil {
keys = append(keys, "discord")
}
if intg.Weixin != nil {
keys = append(keys, "weixin")
}
return keys
}

View file

@ -12,7 +12,6 @@ import (
robotcache "github.com/yaoapp/yao/agent/robot/cache"
events "github.com/yaoapp/yao/agent/robot/events"
robottypes "github.com/yaoapp/yao/agent/robot/types"
"github.com/yaoapp/yao/agent/testutils"
"github.com/yaoapp/yao/event"
eventtypes "github.com/yaoapp/yao/event/types"
)
@ -40,8 +39,6 @@ func (m *mockAdapter) Reply(ctx context.Context, msg *agentcontext.Message, meta
return nil
}
func (m *mockAdapter) Shutdown() {}
func (m *mockAdapter) getApplied() []*robottypes.Robot {
m.mu.Lock()
defer m.mu.Unlock()
@ -242,13 +239,6 @@ func TestConfigDeleted_TriggersRemove(t *testing.T) {
}
func TestConfigCreated_RobotNotInCache(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
setupEventBus(t)
cache := robotcache.New()
@ -258,7 +248,7 @@ func TestConfigCreated_RobotNotInCache(t *testing.T) {
require.NoError(t, d.Start(context.Background()))
defer d.Stop()
// Push event but don't add robot to cache — triggers LoadByID DB fallback
// Push event but don't add robot to cache
event.Push(context.Background(), events.RobotConfigCreated, events.RobotConfigPayload{
MemberID: "r-ghost", TeamID: "team1",
})

View file

@ -26,11 +26,10 @@ type Adapter struct {
// botEntry holds the state for one robot's Feishu integration.
type botEntry struct {
robotID string
appID string
appSecret string
bot *fsapi.Bot
cancelFn context.CancelFunc // cancels the event subscription goroutine
robotID string
appID string
bot *fsapi.Bot
cancelFn context.CancelFunc // cancels the event subscription goroutine
}
// NewAdapter creates a new Feishu adapter.
@ -59,8 +58,7 @@ func (a *Adapter) Apply(ctx context.Context, robot *robottypes.Robot) {
defer a.mu.Unlock()
if existing, ok := a.bots[robot.MemberID]; ok {
if existing.appID == fsConf.AppID &&
existing.appSecret == fsConf.AppSecret {
if existing.appID == fsConf.AppID {
return
}
a.removeBotLocked(robot.MemberID)
@ -70,11 +68,10 @@ func (a *Adapter) Apply(ctx context.Context, robot *robottypes.Robot) {
streamCtx, streamCancel := context.WithCancel(context.Background())
entry := &botEntry{
robotID: robot.MemberID,
appID: fsConf.AppID,
appSecret: fsConf.AppSecret,
bot: bot,
cancelFn: streamCancel,
robotID: robot.MemberID,
appID: fsConf.AppID,
bot: bot,
cancelFn: streamCancel,
}
a.bots[robot.MemberID] = entry
a.appIdx[fsConf.AppID] = robot.MemberID

View file

@ -60,8 +60,6 @@ func (a *Adapter) handleMessages(ctx context.Context, entry *botEntry, cms []*fs
Locale: events.NormalizeLocale(lastCM.LanguageCode),
Extra: map[string]any{
"feishu_message_id": lastCM.MessageID,
"sender_id": lastCM.SenderID,
"app_id": entry.appID,
},
},
}
@ -85,23 +83,12 @@ func buildContentParts(cm *fsapi.ConvertedMessage) []interface{} {
if mi.Wrapper == "" {
continue
}
if strings.HasPrefix(mi.MimeType, "image/") {
parts = append(parts, map[string]interface{}{
"type": "image_url",
"image_url": map[string]interface{}{
"url": mi.Wrapper,
"detail": "auto",
},
})
} else {
parts = append(parts, map[string]interface{}{
"type": "file",
"file": map[string]interface{}{
"url": mi.Wrapper,
"filename": mi.FileName,
},
})
}
parts = append(parts, map[string]interface{}{
"type": "file",
"file_url": mi.Wrapper,
"mime_type": mi.MimeType,
"file_name": mi.FileName,
})
}
return parts

View file

@ -30,10 +30,6 @@ func (a *Adapter) Reply(ctx context.Context, msg *agentcontext.Message, metadata
}
}
if err := entry.bot.SendTyping(ctx, metadata.ChatID); err != nil {
log.Debug("feishu reply: send typing failed: %v", err)
}
return a.sendContent(ctx, entry, metadata.ChatID, replyToMsgID, msg.Content)
}

View file

@ -67,8 +67,6 @@ func (a *Adapter) handleMessages(ctx context.Context, entry *botEntry, cms []*tg
Locale: events.NormalizeLocale(lastCM.LanguageCode),
Extra: map[string]any{
"tg_message_id": lastCM.MessageID,
"sender_id": strconv.FormatInt(lastCM.SenderID, 10),
"app_id": entry.appID,
},
},
}
@ -93,23 +91,12 @@ func buildContentParts(cm *tgapi.ConvertedMessage) []interface{} {
if mi.Wrapper == "" {
continue
}
if strings.HasPrefix(mi.MimeType, "image/") {
parts = append(parts, map[string]interface{}{
"type": "image_url",
"image_url": map[string]interface{}{
"url": mi.Wrapper,
"detail": "auto",
},
})
} else {
parts = append(parts, map[string]interface{}{
"type": "file",
"file": map[string]interface{}{
"url": mi.Wrapper,
"filename": mi.FileName,
},
})
}
parts = append(parts, map[string]interface{}{
"type": "file",
"file_url": mi.Wrapper,
"mime_type": mi.MimeType,
"file_name": mi.FileName,
})
}
return parts

View file

@ -4,7 +4,6 @@ import (
"context"
"time"
kunlog "github.com/yaoapp/kun/log"
tgapi "github.com/yaoapp/yao/integrations/telegram"
)
@ -35,7 +34,7 @@ func (a *Adapter) pollLoop() {
func (a *Adapter) pollAll() {
entries := a.snapshot()
kunlog.Trace("[robot:telegram] pollAll bots=%d", len(entries))
log.Debug("pollAll bots=%d", len(entries))
if len(entries) == 0 {
return
}
@ -50,7 +49,7 @@ func (a *Adapter) pollAll() {
default:
}
kunlog.Trace("[robot:telegram] polling robot=%s offset=%d", entry.robotID, entry.offset)
log.Debug("polling robot=%s offset=%d", entry.robotID, entry.offset)
groups := []string{"telegram", entry.robotID}
msgs, err := entry.bot.GetUpdates(ctx, entry.offset, pollTimeout, groups)
if err != nil {

View file

@ -41,10 +41,6 @@ func (a *Adapter) Reply(ctx context.Context, msg *agentcontext.Message, metadata
return fmt.Errorf("no bot registered for channel metadata (appID=%s)", metadata.AppID)
}
if err := entry.bot.SendTyping(ctx, chatID); err != nil {
log.Debug("telegram reply: send typing failed: %v", err)
}
return a.sendContent(ctx, entry.bot, chatID, replyTo, msg.Content)
}

View file

@ -30,7 +30,6 @@ type Adapter struct {
type botEntry struct {
robotID string
appID string
host string
bot *tgapi.Bot // bound to this robot's token
offset int64 // polling offset
}
@ -66,9 +65,7 @@ func (a *Adapter) Apply(ctx context.Context, robot *robottypes.Robot) {
defer a.mu.Unlock()
if existing, ok := a.bots[robot.MemberID]; ok {
if existing.bot.Token() == tgConf.BotToken &&
existing.appID == tgConf.AppID &&
existing.host == tgConf.Host {
if existing.bot.Token() == tgConf.BotToken {
return
}
a.removeBotLocked(robot.MemberID)
@ -81,7 +78,6 @@ func (a *Adapter) Apply(ctx context.Context, robot *robottypes.Robot) {
entry := &botEntry{
robotID: robot.MemberID,
appID: tgConf.AppID,
host: tgConf.Host,
bot: tgapi.NewBot(tgConf.BotToken, tgConf.WebhookSecret, opts...),
}
a.bots[robot.MemberID] = entry

View file

@ -1,44 +0,0 @@
package weixin
import (
"sync"
"time"
)
const (
dedupTTL = 24 * time.Hour
dedupCleanInterval = time.Hour
)
type dedupStore struct {
m sync.Map
}
func newDedupStore() *dedupStore {
return &dedupStore{}
}
func (d *dedupStore) markSeen(key string) bool {
now := time.Now().Unix()
_, loaded := d.m.LoadOrStore(key, now)
return !loaded
}
func (d *dedupStore) cleaner(stopCh <-chan struct{}) {
ticker := time.NewTicker(dedupCleanInterval)
defer ticker.Stop()
for {
select {
case <-stopCh:
return
case <-ticker.C:
cutoff := time.Now().Add(-dedupTTL).Unix()
d.m.Range(func(key, value any) bool {
if ts, ok := value.(int64); ok && ts < cutoff {
d.m.Delete(key)
}
return true
})
}
}
}

View file

@ -1,179 +0,0 @@
package weixin
import (
"bytes"
"context"
"encoding/hex"
"fmt"
"mime/multipart"
"net/textproto"
"strings"
"github.com/yaoapp/yao/attachment"
weixinapi "github.com/yaoapp/yao/integrations/weixin"
)
type resolvedMedia struct {
Wrapper string
MimeType string
FileName string
}
func convertMessage(ctx context.Context, bot *weixinapi.Bot, items []weixinapi.MsgItem, groups []string) (string, []resolvedMedia) {
var textBuf strings.Builder
var media []resolvedMedia
for _, item := range items {
switch item.Type {
case weixinapi.ItemTypeText:
if item.TextItem != nil && item.TextItem.Text != "" {
text := item.TextItem.Text
if item.RefMsg != nil {
text = formatRefMessage(item.RefMsg, text)
}
textBuf.WriteString(text)
}
case weixinapi.ItemTypeVoice:
if item.VoiceItem != nil {
if item.VoiceItem.Text != "" {
textBuf.WriteString(item.VoiceItem.Text)
} else if item.VoiceItem.Media != nil && item.VoiceItem.Media.EncryptQueryParam != "" {
m := resolveVoice(ctx, bot, item.VoiceItem, groups)
if m != nil {
media = append(media, *m)
}
}
}
case weixinapi.ItemTypeImage:
if item.ImageItem != nil {
m := resolveImage(ctx, bot, item.ImageItem, groups)
if m != nil {
media = append(media, *m)
}
}
case weixinapi.ItemTypeFile:
if item.FileItem != nil && item.FileItem.Media != nil && item.FileItem.Media.EncryptQueryParam != "" {
m := resolveFile(ctx, bot, item.FileItem, groups)
if m != nil {
media = append(media, *m)
}
}
case weixinapi.ItemTypeVideo:
if item.VideoItem != nil && item.VideoItem.Media != nil && item.VideoItem.Media.EncryptQueryParam != "" {
m := resolveVideo(ctx, bot, item.VideoItem, groups)
if m != nil {
media = append(media, *m)
}
}
}
}
return textBuf.String(), media
}
func formatRefMessage(ref *weixinapi.RefMessage, text string) string {
if ref == nil || ref.MessageItem == nil {
return text
}
var refBody string
if ref.MessageItem.TextItem != nil {
refBody = ref.MessageItem.TextItem.Text
}
title := ref.Title
if title == "" && refBody == "" {
return text
}
return fmt.Sprintf("[引用: %s | %s]\n%s", title, refBody, text)
}
func resolveImage(ctx context.Context, bot *weixinapi.Bot, img *weixinapi.ImageItem, groups []string) *resolvedMedia {
if img.AesKey != "" && img.Media != nil && img.Media.EncryptQueryParam != "" {
rawKey, err := hex.DecodeString(img.AesKey)
if err == nil {
data, err := weixinapi.DecryptFromRaw(bot.CDNBaseURL(), img.Media.EncryptQueryParam, rawKey)
if err == nil {
return storeMedia(ctx, data, "image/jpeg", "image.jpg", groups)
}
}
}
if img.Media != nil && img.Media.EncryptQueryParam != "" && img.Media.AesKey != "" {
data, err := weixinapi.DownloadAndDecrypt(bot.CDNBaseURL(), img.Media.EncryptQueryParam, img.Media.AesKey)
if err == nil {
return storeMedia(ctx, data, "image/jpeg", "image.jpg", groups)
}
}
return nil
}
func resolveVoice(ctx context.Context, bot *weixinapi.Bot, voice *weixinapi.VoiceItem, groups []string) *resolvedMedia {
data, err := weixinapi.DownloadAndDecrypt(bot.CDNBaseURL(), voice.Media.EncryptQueryParam, voice.Media.AesKey)
if err != nil {
log.Error("weixin: voice decrypt failed: %v", err)
return nil
}
mime := "audio/mpeg"
ext := "mp3"
if voice.EncodeType == 6 {
mime = "audio/silk"
ext = "silk"
}
return storeMedia(ctx, data, mime, "voice."+ext, groups)
}
func resolveFile(ctx context.Context, bot *weixinapi.Bot, file *weixinapi.FileItem, groups []string) *resolvedMedia {
data, err := weixinapi.DownloadAndDecrypt(bot.CDNBaseURL(), file.Media.EncryptQueryParam, file.Media.AesKey)
if err != nil {
log.Error("weixin: file decrypt failed: %v", err)
return nil
}
filename := file.FileName
if filename == "" {
filename = "file.bin"
}
mime := weixinapi.MimeFromFilename(filename)
return storeMedia(ctx, data, mime, filename, groups)
}
func resolveVideo(ctx context.Context, bot *weixinapi.Bot, video *weixinapi.VideoItem, groups []string) *resolvedMedia {
data, err := weixinapi.DownloadAndDecrypt(bot.CDNBaseURL(), video.Media.EncryptQueryParam, video.Media.AesKey)
if err != nil {
log.Error("weixin: video decrypt failed: %v", err)
return nil
}
return storeMedia(ctx, data, "video/mp4", "video.mp4", groups)
}
func storeMedia(ctx context.Context, data []byte, mimeType, filename string, groups []string) *resolvedMedia {
manager, exists := attachment.Managers["__yao.attachment"]
if !exists {
log.Error("weixin: __yao.attachment manager not found")
return nil
}
fh := makeFileHeader(filename, mimeType, int64(len(data)))
reader := bytes.NewReader(data)
file, err := manager.Upload(ctx, fh, reader, attachment.UploadOption{Groups: groups})
if err != nil {
log.Error("weixin: attachment upload failed: %v", err)
return nil
}
return &resolvedMedia{
Wrapper: fmt.Sprintf("__yao.attachment://%s", file.ID),
MimeType: mimeType,
FileName: filename,
}
}
func makeFileHeader(filename, contentType string, size int64) *attachment.FileHeader {
hdr := make(textproto.MIMEHeader)
hdr.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, filename))
hdr.Set("Content-Type", contentType)
return &attachment.FileHeader{
FileHeader: &multipart.FileHeader{
Filename: filename,
Header: hdr,
Size: size,
},
}
}

View file

@ -1,173 +0,0 @@
package weixin
import (
"context"
"fmt"
"strings"
"time"
agentcontext "github.com/yaoapp/yao/agent/context"
events "github.com/yaoapp/yao/agent/robot/events"
"github.com/yaoapp/yao/event"
weixinapi "github.com/yaoapp/yao/integrations/weixin"
)
const (
maxConsecutiveFailures = 3
backoffDuration = 30 * time.Second
retryDuration = 2 * time.Second
sessionPauseDuration = 30 * time.Minute
defaultTimeoutMs = 35_000
)
func (a *Adapter) pollLoop(ctx context.Context, entry *botEntry) {
syncBuf := loadSyncBuf(entry.accountID)
nextTimeoutMs := defaultTimeoutMs
failures := 0
for {
select {
case <-ctx.Done():
return
default:
}
resp, err := entry.bot.GetUpdates(ctx, syncBuf, nextTimeoutMs)
if err != nil {
if ctx.Err() != nil {
return
}
failures++
if failures >= maxConsecutiveFailures {
failures = 0
sleep(ctx, backoffDuration)
} else {
sleep(ctx, retryDuration)
}
continue
}
if resp.ErrCode == weixinapi.SessionExpiredErrCode || resp.Ret == weixinapi.SessionExpiredErrCode {
log.Warn("weixin session expired, pausing %s robot=%s", sessionPauseDuration, entry.robotID)
failures = 0
sleep(ctx, sessionPauseDuration)
continue
}
isApiError := (resp.Ret != 0) || (resp.ErrCode != 0)
if isApiError {
failures++
if failures >= maxConsecutiveFailures {
failures = 0
sleep(ctx, backoffDuration)
} else {
sleep(ctx, retryDuration)
}
continue
}
failures = 0
if resp.LongPollingTimeoutMs > 0 {
nextTimeoutMs = resp.LongPollingTimeoutMs
}
if resp.GetUpdatesBuf != "" && resp.GetUpdatesBuf != syncBuf {
syncBuf = resp.GetUpdatesBuf
saveSyncBuf(entry.accountID, syncBuf)
}
for i := range resp.Msgs {
a.handleMessage(ctx, entry, &resp.Msgs[i])
}
}
}
func (a *Adapter) handleMessage(ctx context.Context, entry *botEntry, msg *weixinapi.WeixinMessage) {
var dedupKey string
switch {
case msg.MessageID != 0:
dedupKey = fmt.Sprintf("wx:%s:mid:%d", entry.robotID, msg.MessageID)
case msg.Seq != 0:
dedupKey = fmt.Sprintf("wx:%s:seq:%d", entry.robotID, msg.Seq)
default:
dedupKey = fmt.Sprintf("wx:%s:%s:%d", entry.robotID, msg.FromUserID, msg.CreateTimeMs)
}
if !a.dedup.markSeen(dedupKey) {
return
}
log.Info("incoming msg from=%s context_token=%s", msg.FromUserID, msg.ContextToken)
groups := []string{"weixin", entry.accountID}
content, mediaItems := convertMessage(ctx, entry.bot, msg.ItemList, groups)
if content == "" && len(mediaItems) == 0 {
return
}
var msgContent interface{}
if len(mediaItems) == 0 {
msgContent = content
} else {
parts := make([]interface{}, 0, 1+len(mediaItems))
if content != "" {
parts = append(parts, map[string]interface{}{"type": "text", "text": content})
}
for _, m := range mediaItems {
if strings.HasPrefix(m.MimeType, "image/") {
parts = append(parts, map[string]interface{}{
"type": "image_url",
"image_url": map[string]interface{}{
"url": m.Wrapper,
"detail": "auto",
},
})
} else {
parts = append(parts, map[string]interface{}{
"type": "file",
"file": map[string]interface{}{
"url": m.Wrapper,
"filename": m.FileName,
},
})
}
}
msgContent = parts
}
messageID := ""
if msg.MessageID != 0 {
messageID = fmt.Sprintf("%d", msg.MessageID)
}
payload := events.MessagePayload{
RobotID: entry.robotID,
Messages: []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: msgContent},
},
Metadata: &events.MessageMetadata{
Channel: "weixin",
MessageID: messageID,
AppID: entry.accountID,
ChatID: msg.FromUserID,
SenderID: msg.FromUserID,
Locale: "zh-cn",
Extra: map[string]any{
"context_token": msg.ContextToken,
"sender_id": msg.FromUserID,
"app_id": entry.accountID,
},
},
}
if _, err := event.Push(ctx, events.Message, payload); err != nil {
log.Error("weixin adapter: event.Push failed robot=%s: %v", entry.robotID, err)
}
}
func sleep(ctx context.Context, d time.Duration) {
select {
case <-ctx.Done():
case <-time.After(d):
}
}

View file

@ -1,368 +0,0 @@
package weixin
import (
"context"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
agentcontext "github.com/yaoapp/yao/agent/context"
events "github.com/yaoapp/yao/agent/robot/events"
"github.com/yaoapp/yao/attachment"
weixinapi "github.com/yaoapp/yao/integrations/weixin"
"github.com/yaoapp/yao/workspace"
)
func (a *Adapter) Reply(ctx context.Context, msg *agentcontext.Message, metadata *events.MessageMetadata) error {
if msg == nil || metadata == nil {
return fmt.Errorf("weixin Reply: nil message or metadata")
}
entry := a.resolveByAccountID(metadata.AppID)
if entry == nil {
a.mu.RLock()
for _, e := range a.bots {
entry = e
break
}
a.mu.RUnlock()
}
if entry == nil {
return fmt.Errorf("weixin Reply: no bot registered (appID=%s)", metadata.AppID)
}
contextToken, _ := metadata.Extra["context_token"].(string)
toUserID := metadata.SenderID
if toUserID == "" {
toUserID = metadata.ChatID
}
ticket := entry.ticketCache.Get(toUserID)
if ticket == "" {
if t, err := entry.bot.GetConfig(ctx, toUserID, contextToken); err == nil && t != "" {
ticket = t
entry.ticketCache.Set(toUserID, ticket)
}
}
if ticket != "" {
_ = entry.bot.SendTyping(ctx, toUserID, ticket, 1)
}
return a.sendContent(ctx, entry, toUserID, contextToken, msg.Content)
}
func (a *Adapter) sendContent(ctx context.Context, entry *botEntry, toUserID, contextToken string, content interface{}) error {
switch c := content.(type) {
case string:
if strings.TrimSpace(c) == "" {
return nil
}
return entry.bot.SendMessage(ctx, toUserID, contextToken, weixinapi.FormatWeixinText(c))
case []interface{}:
return a.sendParts(ctx, entry, toUserID, contextToken, c)
default:
parts, ok := toContentParts(content)
if ok {
return a.sendPartsTyped(ctx, entry, toUserID, contextToken, parts)
}
return entry.bot.SendMessage(ctx, toUserID, contextToken, weixinapi.FormatWeixinText(fmt.Sprintf("%v", content)))
}
}
func (a *Adapter) sendParts(ctx context.Context, entry *botEntry, toUserID, contextToken string, parts []interface{}) error {
var textBuf strings.Builder
for _, part := range parts {
m, ok := part.(map[string]interface{})
if !ok {
continue
}
partType, _ := m["type"].(string)
switch partType {
case "text":
if text, ok := m["text"].(string); ok {
textBuf.WriteString(text)
}
case "image_url":
if err := a.flushText(ctx, entry, toUserID, contextToken, &textBuf); err != nil {
return err
}
if imgMap, ok := m["image_url"].(map[string]interface{}); ok {
if url, ok := imgMap["url"].(string); ok {
if err := a.sendMediaFromURL(ctx, entry, toUserID, contextToken, url, "", "image"); err != nil {
log.Error("weixin reply: send image: %v", err)
}
}
}
case "file":
if err := a.flushText(ctx, entry, toUserID, contextToken, &textBuf); err != nil {
return err
}
fileURL, _ := m["file_url"].(string)
fileName, _ := m["file_name"].(string)
mimeType, _ := m["mime_type"].(string)
if fileURL == "" {
if fileMap, ok := m["file"].(map[string]interface{}); ok {
fileURL, _ = fileMap["url"].(string)
if fileName == "" {
fileName, _ = fileMap["filename"].(string)
}
}
}
if fileURL != "" {
mediaHint := detectMediaHint(mimeType, fileName)
if err := a.sendMediaFromURL(ctx, entry, toUserID, contextToken, fileURL, fileName, mediaHint); err != nil {
log.Error("weixin reply: send file: %v", err)
}
}
}
}
return a.flushText(ctx, entry, toUserID, contextToken, &textBuf)
}
func (a *Adapter) sendPartsTyped(ctx context.Context, entry *botEntry, toUserID, contextToken string, parts []agentcontext.ContentPart) error {
var textBuf strings.Builder
for _, part := range parts {
switch part.Type {
case agentcontext.ContentText:
textBuf.WriteString(part.Text)
case agentcontext.ContentImageURL:
if err := a.flushText(ctx, entry, toUserID, contextToken, &textBuf); err != nil {
return err
}
if part.ImageURL != nil {
if err := a.sendMediaFromURL(ctx, entry, toUserID, contextToken, part.ImageURL.URL, "", "image"); err != nil {
log.Error("weixin reply: send image: %v", err)
}
}
case agentcontext.ContentFile:
if err := a.flushText(ctx, entry, toUserID, contextToken, &textBuf); err != nil {
return err
}
if part.File != nil {
mediaHint := detectMediaHint("", part.File.Filename)
if err := a.sendMediaFromURL(ctx, entry, toUserID, contextToken, part.File.URL, part.File.Filename, mediaHint); err != nil {
log.Error("weixin reply: send file: %v", err)
}
}
}
}
return a.flushText(ctx, entry, toUserID, contextToken, &textBuf)
}
func (a *Adapter) flushText(ctx context.Context, entry *botEntry, toUserID, contextToken string, buf *strings.Builder) error {
if buf.Len() == 0 {
return nil
}
text := weixinapi.FormatWeixinText(buf.String())
buf.Reset()
return entry.bot.SendMessage(ctx, toUserID, contextToken, text)
}
func (a *Adapter) sendMediaFromURL(ctx context.Context, entry *botEntry, toUserID, contextToken, fileURL, fileName, mediaHint string) error {
log.Info("weixin sendMedia: to=%s url=%s fileName=%q hint=%s contextToken_len=%d",
toUserID, fileURL, fileName, mediaHint, len(contextToken))
var plaintext []byte
var contentType string
if strings.HasPrefix(fileURL, "workspace://") {
rest := strings.TrimPrefix(fileURL, "workspace://")
slashIdx := strings.Index(rest, "/")
if slashIdx < 0 {
return fmt.Errorf("invalid workspace URL: %s", fileURL)
}
wsID := rest[:slashIdx]
filePath := rest[slashIdx+1:]
wsm := workspace.M()
if wsm == nil {
return fmt.Errorf("workspace manager not initialized")
}
wsFS, err := wsm.FS(ctx, wsID)
if err != nil {
return fmt.Errorf("open workspace %s: %w", wsID, err)
}
defer wsFS.Close()
plaintext, err = wsFS.ReadFile(filePath)
if err != nil {
return fmt.Errorf("read workspace file %s/%s: %w", wsID, filePath, err)
}
contentType = mimeFromExt(filepath.Ext(filePath))
if fileName == "" {
fileName = filepath.Base(filePath)
}
log.Info("weixin sendMedia: workspace read bytes=%d contentType=%q fileName=%q", len(plaintext), contentType, fileName)
} else if isWrapper(fileURL) {
managerName, fileID, err := parseWrapper(fileURL)
if err != nil {
return err
}
log.Info("weixin sendMedia: wrapper manager=%s fileID=%s", managerName, fileID)
manager, exists := attachment.Managers[managerName]
if !exists {
return fmt.Errorf("attachment manager %s not found", managerName)
}
resp, err := manager.Download(ctx, fileID)
if err != nil {
return fmt.Errorf("attachment download %s: %w", fileID, err)
}
defer resp.Reader.Close()
plaintext, err = io.ReadAll(resp.Reader)
if err != nil {
return fmt.Errorf("read attachment %s: %w", fileID, err)
}
contentType = resp.ContentType
if fileName == "" {
fileName = fileID + resp.Extension
}
log.Info("weixin sendMedia: attachment downloaded bytes=%d contentType=%q fileName=%q", len(plaintext), contentType, fileName)
} else if strings.HasPrefix(fileURL, "http") {
resp, err := http.Get(fileURL) //nolint:gosec
if err != nil {
return fmt.Errorf("download %s: %w", fileURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download %s: HTTP %d", fileURL, resp.StatusCode)
}
plaintext, err = io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read %s: %w", fileURL, err)
}
contentType = resp.Header.Get("Content-Type")
log.Info("weixin sendMedia: http downloaded bytes=%d contentType=%q", len(plaintext), contentType)
} else {
return fmt.Errorf("unsupported URL scheme: %s", fileURL)
}
if mediaHint == "" {
mediaHint = detectMediaHint(contentType, fileName)
}
var mediaType int
switch mediaHint {
case "image":
mediaType = weixinapi.UploadMediaImage
case "video":
mediaType = weixinapi.UploadMediaVideo
default:
mediaType = weixinapi.UploadMediaFile
}
log.Info("weixin sendMedia: uploading media_type=%d mediaHint=%s bytes=%d to=%s", mediaType, mediaHint, len(plaintext), toUserID)
uploaded, err := entry.bot.UploadMedia(ctx, plaintext, toUserID, mediaType)
if err != nil {
log.Error("weixin UploadMedia failed: media_type=%d mediaHint=%s bytes=%d to=%s err=%v", mediaType, mediaHint, len(plaintext), toUserID, err)
fallbackText := fileURL
if fileName != "" {
fallbackText = fileName + "\n" + fileURL
}
return entry.bot.SendMessage(ctx, toUserID, contextToken, fallbackText)
}
switch mediaHint {
case "image":
return entry.bot.SendImageMessage(ctx, toUserID, contextToken, uploaded)
case "video":
return entry.bot.SendVideoMessage(ctx, toUserID, contextToken, uploaded)
default:
if fileName == "" {
fileName = "file.bin"
}
return entry.bot.SendFileMessage(ctx, toUserID, contextToken, fileName, uploaded)
}
}
func detectMediaHint(mimeType, fileName string) string {
lower := strings.ToLower(mimeType)
if strings.HasPrefix(lower, "image/") {
return "image"
}
if strings.HasPrefix(lower, "video/") {
return "video"
}
// TODO(weixin-voice): audio/* detected as "file" because iLink Bot voice
// playback is not yet functional. Switch to "voice" once supported.
if fileName != "" {
ext := strings.ToLower(fileName)
if strings.HasSuffix(ext, ".jpg") || strings.HasSuffix(ext, ".jpeg") ||
strings.HasSuffix(ext, ".png") || strings.HasSuffix(ext, ".gif") ||
strings.HasSuffix(ext, ".webp") || strings.HasSuffix(ext, ".bmp") {
return "image"
}
if strings.HasSuffix(ext, ".mp4") || strings.HasSuffix(ext, ".mov") ||
strings.HasSuffix(ext, ".avi") || strings.HasSuffix(ext, ".webm") {
return "video"
}
}
return "file"
}
func isWrapper(url string) bool {
return strings.Contains(url, "://") && !strings.HasPrefix(url, "http")
}
func parseWrapper(wrapper string) (managerName, fileID string, err error) {
idx := strings.Index(wrapper, "://")
if idx < 0 {
return "", "", fmt.Errorf("invalid wrapper: %s", wrapper)
}
return wrapper[:idx], wrapper[idx+3:], nil
}
func toContentParts(content interface{}) ([]agentcontext.ContentPart, bool) {
parts, ok := content.([]agentcontext.ContentPart)
return parts, ok
}
func mimeFromExt(ext string) string {
switch strings.ToLower(ext) {
case ".pdf":
return "application/pdf"
case ".html", ".htm":
return "text/html"
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".gif":
return "image/gif"
case ".webp":
return "image/webp"
case ".md":
return "text/markdown"
case ".txt":
return "text/plain"
case ".csv":
return "text/csv"
case ".json":
return "application/json"
case ".xlsx":
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
case ".pptx":
return "application/vnd.openxmlformats-officedocument.presentationml.presentation"
default:
return "application/octet-stream"
}
}
func (a *Adapter) resolveByAccountID(accountID string) *botEntry {
if accountID == "" {
return nil
}
a.mu.RLock()
defer a.mu.RUnlock()
robotID, ok := a.accountIdx[accountID]
if !ok {
return nil
}
return a.bots[robotID]
}

View file

@ -1,44 +0,0 @@
package weixin
import (
"encoding/json"
"os"
"path/filepath"
"github.com/yaoapp/gou/application"
)
type syncBufData struct {
GetUpdatesBuf string `json:"get_updates_buf"`
}
func syncBufPath(accountID string) string {
root := application.App.Root()
return filepath.Join(root, "data", "weixin", accountID+".sync.json")
}
func loadSyncBuf(accountID string) string {
p := syncBufPath(accountID)
data, err := os.ReadFile(p)
if err != nil {
return ""
}
var buf syncBufData
if err := json.Unmarshal(data, &buf); err != nil {
return ""
}
return buf.GetUpdatesBuf
}
func saveSyncBuf(accountID, syncBuf string) {
p := syncBufPath(accountID)
dir := filepath.Dir(p)
if err := os.MkdirAll(dir, 0755); err != nil {
log.Error("weixin: mkdir for syncbuf: %v", err)
return
}
data, _ := json.Marshal(syncBufData{GetUpdatesBuf: syncBuf})
if err := os.WriteFile(p, data, 0644); err != nil {
log.Error("weixin: write syncbuf: %v", err)
}
}

View file

@ -1,43 +0,0 @@
package weixin
import (
"sync"
"time"
)
const ticketTTL = 20 * time.Hour
type ticketEntry struct {
ticket string
expiresAt time.Time
}
type typingTicketCache struct {
mu sync.RWMutex
items map[string]*ticketEntry
}
func newTypingTicketCache() *typingTicketCache {
return &typingTicketCache{
items: make(map[string]*ticketEntry),
}
}
func (c *typingTicketCache) Get(userID string) string {
c.mu.RLock()
defer c.mu.RUnlock()
entry, ok := c.items[userID]
if !ok || time.Now().After(entry.expiresAt) {
return ""
}
return entry.ticket
}
func (c *typingTicketCache) Set(userID, ticket string) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[userID] = &ticketEntry{
ticket: ticket,
expiresAt: time.Now().Add(ticketTTL),
}
}

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