2 Commits
Author SHA1 Message Date
slhaf 67499390b2 重构拆分原‘记忆图谱’以适应后续扩展
- 拆分原`MemoryGraph`为 MemoryCore, CacheCore, GraphCore, PerceiveCore几个部分.
- MemoryCore中将不再包含操作逻辑, 由MemoryManager统一处理, 序列化逻辑仍交给MemoryCore。
- 更新README
2025-06-06 19:25:12 +08:00
slhaf 8f0e62d6db 发布版本: 第二阶段 0.5.0 2025-06-06 10:36:41 +08:00
417 changed files with 4847 additions and 37659 deletions
-16
View File
@@ -1,16 +0,0 @@
# CodeGraph data files
# These are local to each machine and should not be committed
# Database
*.db
*.db-wal
*.db-shm
# Cache
cache/
# Logs
*.log
# Hook markers
.dirty
-137
View File
@@ -1,137 +0,0 @@
name: Release Partner Core
on:
workflow_dispatch:
inputs:
tag:
description: "Core release tag, for example release-core/0.5.0"
required: true
type: string
push:
tags:
- "release-core/*"
permissions:
contents: write
jobs:
release-core:
runs-on: ubuntu-latest
steps:
- name: Resolve release metadata
id: release
shell: bash
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
TAG="${{ inputs.tag }}"
else
TAG="${GITHUB_REF_NAME}"
fi
VERSION="${TAG#release-core/}"
VERSION="${VERSION#v}"
ENCODED_TAG="${TAG//\//%2F}"
ASSET_NAME="partner-core-${VERSION}.jar"
ASSET_URL="https://github.com/slhaf/Partner/releases/download/${ENCODED_TAG}/${ASSET_NAME}"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "asset_name=${ASSET_NAME}" >> "$GITHUB_OUTPUT"
echo "asset_url=${ASSET_URL}" >> "$GITHUB_OUTPUT"
echo "Core release tag: ${TAG}"
echo "Core release version: ${VERSION}"
echo "Core release asset: ${ASSET_NAME}"
- name: Checkout release source
uses: actions/checkout@v4
with:
ref: ${{ steps.release.outputs.tag }}
- name: Set up Java 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Build Partner Core
run: |
mvn -B -DskipTests=true -pl Partner-Core -am package
- name: Prepare release artifact
shell: bash
run: |
mkdir -p dist
SOURCE_JAR="Partner-Core/target/partner-core-${{ steps.release.outputs.version }}.jar"
if [ ! -f "$SOURCE_JAR" ]; then
echo "Expected artifact not found: $SOURCE_JAR"
echo "Available Partner-Core target files:"
find Partner-Core/target -maxdepth 1 -type f -name "*.jar" -print
exit 1
fi
cp "$SOURCE_JAR" "dist/${{ steps.release.outputs.asset_name }}"
ls -lh dist
- name: Create GitHub Release
shell: bash
run: |
if gh release view "${{ steps.release.outputs.tag }}" >/dev/null 2>&1; then
echo "Release ${{ steps.release.outputs.tag }} already exists."
exit 1
fi
gh release create "${{ steps.release.outputs.tag }}" \
"dist/${{ steps.release.outputs.asset_name }}" \
--title "Partner Core ${{ steps.release.outputs.version }}" \
--notes "Partner Core ${{ steps.release.outputs.version }}"
env:
GH_TOKEN: ${{ github.token }}
- name: Checkout master for registry update
uses: actions/checkout@v4
with:
ref: master
path: registry-worktree
- name: Update latestRelease
working-directory: registry-worktree
run: |
python3 - <<'PY'
import json
from pathlib import Path
version = "${{ steps.release.outputs.version }}"
url = "${{ steps.release.outputs.asset_url }}"
index_path = Path("registry/index.json")
index = json.loads(index_path.read_text(encoding="utf-8"))
index["partner"]["latestRelease"] = {
"url": url,
"version": version
}
index_path.write_text(
json.dumps(index, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8"
)
PY
- name: Commit registry update
working-directory: registry-worktree
run: |
if git diff --quiet registry/index.json; then
echo "No latestRelease changes."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add registry/index.json
git commit -m "chore(registry): update latest core release to ${{ steps.release.outputs.tag }}"
git push origin HEAD:master
-161
View File
@@ -1,161 +0,0 @@
name: Release PartnerCtl
on:
workflow_dispatch:
inputs:
tag:
description: "Ctl release tag, for example release-ctl/0.5.0"
required: true
type: string
push:
tags:
- "release-ctl/*"
permissions:
contents: write
jobs:
build:
name: Build ${{ matrix.platform }}
strategy:
fail-fast: false
matrix:
include:
- platform: linux-x64
runner: ubuntu-latest
binaryPath: PartnerCtl/target/partnerctl
assetSuffix: linux-x64
- platform: linux-arm64
runner: ubuntu-24.04-arm
binaryPath: PartnerCtl/target/partnerctl
assetSuffix: linux-arm64
- platform: windows-x64
runner: windows-latest
binaryPath: PartnerCtl/target/partnerctl.exe
assetSuffix: windows-x64.exe
runs-on: ${{ matrix.runner }}
steps:
- name: Resolve release metadata
id: release
shell: bash
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
TAG="${{ inputs.tag }}"
else
TAG="${GITHUB_REF_NAME}"
fi
VERSION="${TAG#release-ctl/}"
VERSION="${VERSION#v}"
ASSET_NAME="partnerctl-${VERSION}-${{ matrix.assetSuffix }}"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "asset_name=${ASSET_NAME}" >> "$GITHUB_OUTPUT"
echo "Ctl release tag: ${TAG}"
echo "Ctl release version: ${VERSION}"
echo "Ctl release asset: ${ASSET_NAME}"
- name: Checkout release source
uses: actions/checkout@v4
with:
ref: ${{ steps.release.outputs.tag }}
- name: Set up GraalVM 21
uses: graalvm/setup-graalvm@v1
with:
distribution: graalvm-community
java-version: "21"
github-token: ${{ github.token }}
- name: Build partnerctl native image
run: mvn -B -DskipTests=true -pl PartnerCtl -am package native:compile
- name: Prepare release artifact
shell: bash
run: |
mkdir -p dist
BINARY_PATH="${{ matrix.binaryPath }}"
ASSET_PATH="dist/${{ steps.release.outputs.asset_name }}"
if [ ! -f "$BINARY_PATH" ]; then
echo "Expected native binary not found: $BINARY_PATH"
echo "Available PartnerCtl target files:"
find PartnerCtl/target -maxdepth 2 -type f -print
exit 1
fi
cp "$BINARY_PATH" "$ASSET_PATH"
if [[ "${{ matrix.platform }}" == linux-* ]]; then
chmod +x "$ASSET_PATH"
fi
ls -lh dist
- name: Upload release artifact
uses: actions/upload-artifact@v4
with:
name: ${{ steps.release.outputs.asset_name }}
path: dist/${{ steps.release.outputs.asset_name }}
if-no-files-found: error
retention-days: 1
release:
name: Create GitHub Release
needs: build
runs-on: ubuntu-latest
steps:
- name: Resolve release metadata
id: release
shell: bash
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
TAG="${{ inputs.tag }}"
else
TAG="${GITHUB_REF_NAME}"
fi
VERSION="${TAG#release-ctl/}"
VERSION="${VERSION#v}"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "Ctl release tag: ${TAG}"
echo "Ctl release version: ${VERSION}"
- name: Checkout release source
uses: actions/checkout@v4
with:
ref: ${{ steps.release.outputs.tag }}
- name: Download release artifacts
uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: Show release artifacts
run: |
ls -lh dist
- name: Create GitHub Release
shell: bash
run: |
if gh release view "${{ steps.release.outputs.tag }}" >/dev/null 2>&1; then
echo "Release ${{ steps.release.outputs.tag }} already exists."
exit 1
fi
gh release create "${{ steps.release.outputs.tag }}" \
dist/* \
--title "PartnerCtl ${{ steps.release.outputs.version }}" \
--notes "PartnerCtl ${{ steps.release.outputs.version }}"
env:
GH_TOKEN: ${{ github.token }}
+36
View File
@@ -0,0 +1,36 @@
name: Sync from Gitea
# 1. 给 GITHUB_TOKEN 开写权限
permissions:
contents: write
on:
schedule:
- cron: '*/30 * * * *'
workflow_dispatch:
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: 配置 Git 用户
run: |
git config --global user.name "Gitea Sync Bot"
git config --global user.email "slhafzjw@slhaf.work"
- name: 关闭全局 SSL 校验
run: git config --global http.sslVerify false
- name: Clone from Gitea (mirror)
run: |
git clone --mirror \
https://${{ secrets.GITEA_USER }}:${{ secrets.GITEA_TOKEN }}@${{ secrets.GITEA_URL }} \
gitea-mirror
- name: Push to GitHub
run: |
cd gitea-mirror
# 明确推到名为 "github" 的 remote
git remote add github \
https://${{ github.repository_owner }}:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git
git push --mirror github
@@ -1,137 +0,0 @@
name: Test PartnerCtl Native Build
on:
workflow_dispatch:
inputs:
platform:
description: "Target platform to test"
required: true
default: "windows-x64"
type: choice
options:
- windows-x64
- linux-x64
- linux-arm64
permissions:
contents: read
jobs:
test-linux-x64:
name: Test linux-x64
if: ${{ inputs.platform == 'linux-x64' }}
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up GraalVM 21
uses: graalvm/setup-graalvm@v1
with:
distribution: graalvm-community
java-version: "21"
github-token: ${{ github.token }}
- name: Build partnerctl native image
run: mvn -B -DskipTests=true -pl PartnerCtl -am package native:compile
- name: Check Linux output
shell: bash
run: |
if [ ! -f "PartnerCtl/target/partnerctl" ]; then
echo "Expected native binary not found: PartnerCtl/target/partnerctl"
echo "Available PartnerCtl target files:"
find PartnerCtl/target -maxdepth 2 -type f -print
exit 1
fi
chmod +x PartnerCtl/target/partnerctl
./PartnerCtl/target/partnerctl --help
- name: Upload test artifact
uses: actions/upload-artifact@v4
with:
name: partnerctl-linux-x64-test
path: PartnerCtl/target/partnerctl
if-no-files-found: error
retention-days: 1
test-linux-arm64:
name: Test linux-arm64
if: ${{ inputs.platform == 'linux-arm64' }}
runs-on: ubuntu-24.04-arm
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up GraalVM 21
uses: graalvm/setup-graalvm@v1
with:
distribution: graalvm-community
java-version: "21"
github-token: ${{ github.token }}
- name: Build partnerctl native image
run: mvn -B -DskipTests=true -pl PartnerCtl -am package native:compile
- name: Check Linux output
shell: bash
run: |
if [ ! -f "PartnerCtl/target/partnerctl" ]; then
echo "Expected native binary not found: PartnerCtl/target/partnerctl"
echo "Available PartnerCtl target files:"
find PartnerCtl/target -maxdepth 2 -type f -print
exit 1
fi
chmod +x PartnerCtl/target/partnerctl
./PartnerCtl/target/partnerctl --help
- name: Upload test artifact
uses: actions/upload-artifact@v4
with:
name: partnerctl-linux-arm64-test
path: PartnerCtl/target/partnerctl
if-no-files-found: error
retention-days: 1
test-windows-x64:
name: Test windows-x64
if: ${{ inputs.platform == 'windows-x64' }}
runs-on: windows-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up GraalVM 21
uses: graalvm/setup-graalvm@v1
with:
distribution: graalvm-community
java-version: "21"
github-token: ${{ github.token }}
- name: Build partnerctl native image
run: mvn -B -DskipTests=true -pl PartnerCtl -am package native:compile
- name: Check Windows output
shell: pwsh
run: |
if (!(Test-Path "PartnerCtl/target/partnerctl.exe")) {
Write-Host "Expected native binary not found: PartnerCtl/target/partnerctl.exe"
Write-Host "Available PartnerCtl target files:"
Get-ChildItem -Recurse PartnerCtl/target | Select-Object FullName
throw "partnerctl.exe not found"
}
.\PartnerCtl\target\partnerctl.exe --help
- name: Upload test artifact
uses: actions/upload-artifact@v4
with:
name: partnerctl-windows-x64-test
path: PartnerCtl/target/partnerctl.exe
if-no-files-found: error
retention-days: 1
@@ -1,70 +0,0 @@
name: Update Latest Buildable
on:
workflow_dispatch:
inputs:
ref:
description: "Buildable ref, for example buildable/v0.5.0"
required: true
type: string
push:
tags:
- "buildable/*"
permissions:
contents: write
jobs:
update-latest-buildable:
runs-on: ubuntu-latest
steps:
- name: Resolve buildable ref
id: buildable
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "ref=${{ inputs.ref }}" >> "$GITHUB_OUTPUT"
else
echo "ref=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
fi
- name: Checkout master
uses: actions/checkout@v4
with:
ref: master
- name: Update latestBuildable
run: |
python3 - <<'PY'
import json
from pathlib import Path
ref = "${{ steps.buildable.outputs.ref }}"
index_path = Path("registry/index.json")
index = json.loads(index_path.read_text(encoding="utf-8"))
index["partner"]["latestBuildable"] = {
"url": "https://github.com/slhaf/Partner.git",
"ref": ref
}
index_path.write_text(
json.dumps(index, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8"
)
PY
- name: Commit registry update
run: |
if git diff --quiet registry/index.json; then
echo "No latestBuildable changes."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add registry/index.json
git commit -m "chore(registry): update latest buildable to ${{ steps.buildable.outputs.ref }}"
git push
-38
View File
@@ -1,38 +0,0 @@
name: Update Module Index
on:
push:
branches:
- master
paths:
- "registry/modules/*.json"
- "registry/index.json"
- "scripts/update_module_index.py"
- "update-module-index.yml"
permissions:
contents: write
jobs:
update-registry-index:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Update module index
run: python3 scripts/update_module_index.py
- name: Commit updated index
run: |
if git diff --quiet registry/index.json; then
echo "No module index changes"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add registry/index.json
git commit -m "chore: update registry index"
git push
+16 -29
View File
@@ -4,7 +4,10 @@ target/
!**/src/test/**/target/
### IntelliJ IDEA ###
.idea/
.idea/modules.xml
.idea/jarRepositories.xml
.idea/compiler.xml
.idea/libraries/
*.iws
*.iml
*.ipr
@@ -33,32 +36,16 @@ build/
### Mac OS ###
.DS_Store
/backup/data/
/backup/config/
/Partner-Core/src/main/java/src/test/java/memory/test.json
/Partner-Core/src/main/java/src/test/java/memory/result/input1.json
/Partner-Core/src/main/java/src/test/java/memory/result/input2.json
/Partner-Core/src/main/java/src/test/java/memory/result/output1.json
/Partner-Core/src/main/java/src/test/java/memory/result/output2.json
/Partner-Core/src/main/java/src/test/java/memory/result/total_input.json
/Partner-Core/src/main/java/src/test/java/memory/result/input3.json
/Partner-Core/src/main/java/src/test/java/memory/result/input4.json
/Partner-Core/src/main/java/src/test/java/memory/result/primary_input.json
/Partner-Core/src/main/java/src/main/resources/prompt/module/memory/topic_extractor.json.bak
/backup/
/Partner-Main/src/test/java/text/test.json
/CLAUDE.md
/config/
/data/
/generated-classes/
/.idea/copilot.data.migration.ask2agent.xml
/Partner-Main/data/
/AGENTS.md
/.serena/
/Partner-Core/data/
/.ai/mcp/mcp.json
/.codex
# Maven / build outputs
dependency-reduced-pom.xml
/.backup/
/config/
/src/test/java/memory/test.json
/src/test/java/memory/result/input1.json
/src/test/java/memory/result/input2.json
/src/test/java/memory/result/output1.json
/src/test/java/memory/result/output2.json
/src/test/java/memory/result/total_input.json
/src/test/java/memory/result/input3.json
/src/test/java/memory/result/input4.json
/src/test/java/memory/result/primary_input.json
/src/main/resources/prompt/module/memory/topic_extractor.json.bak
/backup/
-1
View File
@@ -6,4 +6,3 @@
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
/inspectionProfiles/Project_Default.xml
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AskMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EditMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>
-11
View File
@@ -1,11 +0,0 @@
<component name="ProjectDictionaryState">
<dictionary name="project">
<words>
<w>Onebot</w>
<w>onebot</w>
<w>openai</w>
<w>partnerctl</w>
<w>zuper</w>
</words>
</dictionary>
</component>
-14
View File
@@ -1,20 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/Partner-Core/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/Partner-Core/src/main/java/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/Partner-Core/src/main/java/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/Partner-Core/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/Partner-External-Modules/Partner-Onebot-Adapter/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/Partner-External-Modules/Partner-Onebot-Adapter/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/Partner-External-Modules/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/Partner-External-Modules/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/Partner-Framework/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/Partner-Framework/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/Partner-Interaction-Api/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/Partner-Interaction-Api/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/PartnerCtl/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/PartnerCtl/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
</component>
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="2.2.0" />
</component>
</project>
-41
View File
@@ -1,53 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EntryPointsManager">
<list size="22">
<item index="0" class="java.lang.String" itemvalue="lombok.Data" />
<item index="1" class="java.lang.String" itemvalue="net.bytebuddy.implementation.bind.annotation.RuntimeType" />
<item index="2" class="java.lang.String" itemvalue="picocli.CommandLine.Command" />
<item index="3" class="java.lang.String" itemvalue="picocli.CommandLine.Mixin" />
<item index="4" class="java.lang.String" itemvalue="picocli.CommandLine.Option" />
<item index="5" class="java.lang.String" itemvalue="work.slhaf.partner.api.agent.factory.capability.annotation.Capability" />
<item index="6" class="java.lang.String" itemvalue="work.slhaf.partner.api.agent.factory.capability.annotation.CapabilityCore" />
<item index="7" class="java.lang.String" itemvalue="work.slhaf.partner.api.agent.factory.capability.annotation.CapabilityMethod" />
<item index="8" class="java.lang.String" itemvalue="work.slhaf.partner.api.agent.factory.capability.annotation.CoordinateManager" />
<item index="9" class="java.lang.String" itemvalue="work.slhaf.partner.api.agent.factory.capability.annotation.Coordinated" />
<item index="10" class="java.lang.String" itemvalue="work.slhaf.partner.api.agent.factory.component.annotation.Init" />
<item index="11" class="java.lang.String" itemvalue="work.slhaf.partner.api.agent.factory.module.annotation.AfterExecute" />
<item index="12" class="java.lang.String" itemvalue="work.slhaf.partner.api.agent.factory.module.annotation.AgentRunningModule" />
<item index="13" class="java.lang.String" itemvalue="work.slhaf.partner.api.agent.factory.module.annotation.AgentSubModule" />
<item index="14" class="java.lang.String" itemvalue="work.slhaf.partner.api.agent.factory.module.annotation.BeforeExecute" />
<item index="15" class="java.lang.String" itemvalue="work.slhaf.partner.api.agent.factory.module.annotation.Init" />
<item index="16" class="java.lang.String" itemvalue="work.slhaf.partner.api.capability.annotation.CapabilityMethod" />
<item index="17" class="java.lang.String" itemvalue="work.slhaf.partner.api.capability.annotation.CoordinateManager" />
<item index="18" class="java.lang.String" itemvalue="work.slhaf.partner.api.register.capability.annotation.Capability" />
<item index="19" class="java.lang.String" itemvalue="work.slhaf.partner.framework.agent.factory.capability.annotation.CapabilityCore" />
<item index="20" class="java.lang.String" itemvalue="work.slhaf.partner.framework.agent.factory.capability.annotation.CapabilityMethod" />
<item index="21" class="java.lang.String" itemvalue="work.slhaf.partner.framework.agent.factory.component.annotation.AgentComponent" />
</list>
<writeAnnotations>
<writeAnnotation name="work.slhaf.partner.api.agent.factory.capability.annotation.InjectCapability" />
<writeAnnotation name="work.slhaf.partner.api.agent.factory.component.annotation.InjectModule" />
<writeAnnotation name="work.slhaf.partner.api.agent.factory.module.annotation.InjectModule" />
<writeAnnotation name="work.slhaf.partner.framework.agent.factory.capability.annotation.InjectCapability" />
<writeAnnotation name="work.slhaf.partner.framework.agent.factory.component.annotation.InjectModule" />
</writeAnnotations>
</component>
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="MavenProjectsManager">
<option name="originalFiles">
<list>
<option value="$PROJECT_DIR$/pom.xml" />
<option value="$PROJECT_DIR$/PartnerExecutor/pom.xml" />
</list>
</option>
<option name="ignoredFiles">
<set>
<option value="$PROJECT_DIR$/Partner-Common/pom.xml" />
<option value="$PROJECT_DIR$/Partner-SandboxRunner/pom.xml" />
<option value="$PROJECT_DIR$/Partner-Test-Demo/pom.xml" />
</set>
</option>
</component>
<component name="PWA">
<option name="enabled" value="true" />
-79
View File
@@ -1,79 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<parent>
<artifactId>partner</artifactId>
<groupId>work.slhaf.partner</groupId>
<version>0.5.0</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>partner-core</artifactId>
<build>
<plugins>
<plugin>
<artifactId>maven-shade-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<transformers>
<transformer>
<mainClass>work.slhaf.partner.Main</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<version>${kotlin.version}</version>
<executions>
<execution>
<id>compile</id>
<phase>process-sources</phase>
<goals>
<goal>compile</goal>
</goals>
<configuration>
<sourceDirs>
<sourceDir>${project.basedir}/src/main/java</sourceDir>
</sourceDirs>
</configuration>
</execution>
<execution>
<id>test-compile</id>
<phase>test-compile</phase>
<goals>
<goal>test-compile</goal>
</goals>
<configuration>
<sourceDirs>
<sourceDir>${project.basedir}/src/test/java</sourceDir>
</sourceDirs>
</configuration>
</execution>
</executions>
<configuration>
<jvmTarget>${maven.compiler.target}</jvmTarget>
</configuration>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-test</artifactId>
<version>2.2.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<properties>
<maven.compiler.target>21</maven.compiler.target>
<maven.compiler.source>21</maven.compiler.source>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
</project>
-140
View File
@@ -1,140 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>work.slhaf.partner</groupId>
<artifactId>partner</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>partner-core</artifactId>
<version>0.9.0-preview</version>
<dependencies>
<dependency>
<groupId>org.java-websocket</groupId>
<artifactId>Java-WebSocket</artifactId>
<version>1.6.0</version>
</dependency>
<dependency>
<groupId>work.slhaf.partner</groupId>
<artifactId>partner-framework</artifactId>
<version>${partner.runtime.version}</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.nd4j/nd4j-api -->
<dependency>
<groupId>org.nd4j</groupId>
<artifactId>nd4j-api</artifactId>
<version>1.0.0-M2.1</version>
</dependency>
<dependency>
<groupId>com.microsoft.onnxruntime</groupId>
<artifactId>onnxruntime</artifactId>
<version>1.23.1</version>
</dependency>
<dependency>
<groupId>ai.djl.huggingface</groupId>
<artifactId>tokenizers</artifactId>
<version>0.34.0</version>
</dependency>
<!-- https://mvnrepository.com/artifact/io.modelcontextprotocol.sdk/mcp -->
<dependency>
<groupId>io.modelcontextprotocol.sdk</groupId>
<artifactId>mcp</artifactId>
<version>0.17.0</version>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.20.0</version>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.20.0</version>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-inline</artifactId>
<version>5.2.0</version>
</dependency>
<dependency>
<groupId>com.cronutils</groupId>
<artifactId>cron-utils</artifactId>
<version>9.2.1</version>
</dependency>
<dependency>
<groupId>com.huaban</groupId>
<artifactId>jieba-analysis</artifactId>
<version>1.0.2</version>
</dependency>
</dependencies>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<transformers>
<transformer
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>work.slhaf.partner.Main</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<version>${kotlin.version}</version>
<executions>
<execution>
<id>compile</id>
<phase>process-sources</phase>
<goals>
<goal>compile</goal>
</goals>
<configuration>
<sourceDirs>
<sourceDir>${project.basedir}/src/main/java</sourceDir>
</sourceDirs>
</configuration>
</execution>
<execution>
<id>test-compile</id>
<phase>test-compile</phase>
<goals>
<goal>test-compile</goal>
</goals>
<configuration>
<sourceDirs>
<sourceDir>${project.basedir}/src/test/java</sourceDir>
</sourceDirs>
</configuration>
</execution>
</executions>
<configuration>
<jvmTarget>${maven.compiler.target}</jvmTarget>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -1,12 +0,0 @@
package work.slhaf.partner;
import work.slhaf.partner.framework.agent.Agent;
public class Main {
public static void main(String[] args) {
boolean launched = Agent.newAgent(Main.class).launch();
if (!launched) {
System.exit(1);
}
}
}
@@ -1,123 +0,0 @@
package work.slhaf.partner.common.base
import org.w3c.dom.Document
import org.w3c.dom.Element
import java.io.StringWriter
import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.transform.OutputKeys
import javax.xml.transform.TransformerFactory
import javax.xml.transform.dom.DOMSource
import javax.xml.transform.stream.StreamResult
abstract class Block(
val blockName: String
) {
fun encodeToXml(): Element {
val document = DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.newDocument()
val root = document.createElement(blockName)
document.appendChild(root)
appendRootAttributes().forEach { attribute, value -> root.setAttribute(attribute, value) }
fillXml(document, root)
return root
}
protected open fun appendRootAttributes(): Map<String, String> {
return emptyMap()
}
fun encodeToXmlString(): String {
val transformer = TransformerFactory.newInstance().newTransformer().apply {
setOutputProperty(OutputKeys.INDENT, "yes")
setOutputProperty(OutputKeys.ENCODING, "UTF-8")
setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2")
}
return StringWriter().use { writer ->
transformer.transform(DOMSource(encodeToXml()), StreamResult(writer))
writer.toString()
}
}
protected abstract fun fillXml(document: Document, root: Element)
protected fun appendTextElement(
document: Document,
parent: Element,
tagName: String,
value: Any?
): Element {
val element = document.createElement(tagName)
element.textContent = value?.toString() ?: ""
parent.appendChild(element)
return element
}
protected fun appendChildElement(
document: Document,
parent: Element,
tagName: String,
block: Element.() -> Unit = {}
): Element {
val element = document.createElement(tagName)
parent.appendChild(element)
element.block()
return element
}
protected fun appendCDataElement(
document: Document,
parent: Element,
tagName: String,
value: String?
): Element {
val element = document.createElement(tagName)
element.appendChild(document.createCDATASection(value ?: ""))
parent.appendChild(element)
return element
}
@JvmOverloads
protected fun <T> appendListElement(
document: Document,
parent: Element,
wrapperTagName: String,
itemTagName: String,
values: Iterable<T>,
block: Element.(T) -> Unit = { value ->
textContent = value?.toString() ?: ""
}
): Element {
val wrapper = document.createElement(wrapperTagName)
parent.appendChild(wrapper)
for (value in values) {
val item = document.createElement(itemTagName)
wrapper.appendChild(item)
item.block(value)
}
return wrapper
}
@JvmOverloads
protected fun <T> appendRepeatedElements(
document: Document,
parent: Element,
itemTagName: String,
values: Iterable<T>,
block: Element.(T) -> Unit = { value ->
textContent = value?.toString() ?: ""
}
) {
for (value in values) {
val item = document.createElement(itemTagName)
parent.appendChild(item)
item.block(value)
}
}
}
@@ -1,59 +0,0 @@
package work.slhaf.partner.common.vector;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import com.alibaba.fastjson2.JSONObject;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import work.slhaf.partner.common.vector.exception.VectorClientExecutionException;
import java.util.Map;
@Slf4j
public class OllamaVectorClient extends VectorClient {
private final String ollamaEmbeddingUrl;
private final String ollamaEmbeddingModel;
protected OllamaVectorClient(String url, String model) {
this.ollamaEmbeddingUrl = url;
this.ollamaEmbeddingModel = model;
compute("test");
}
@Override
protected float[] doCompute(String input) {
Map<String, String> param = Map.of("model", ollamaEmbeddingModel, "input", input);
HttpRequest request = HttpRequest.get(ollamaEmbeddingUrl).body(JSONObject.toJSONString(param));
try (HttpResponse response = request.execute()) {
if (!response.isOk())
throw new VectorClientExecutionException(
"Failed to execute embedding model",
"ollama",
"COMPUTE",
ollamaEmbeddingUrl
);
String resStr = response.body();
EmbeddingModelResponse embeddingResponse = JSONObject.parseObject(resStr, EmbeddingModelResponse.class);
return embeddingResponse.getEmbeddings()[0];
} catch (Exception e) {
throw new VectorClientExecutionException(
"Failed to execute embedding model",
"ollama",
"COMPUTE",
ollamaEmbeddingUrl,
e
);
}
}
@Data
private static class EmbeddingModelResponse {
private String model;
private float[][] embeddings;
private long total_duration;
private long load_duration;
private int prompt_eval_count;
}
}
@@ -1,100 +0,0 @@
package work.slhaf.partner.common.vector;
import ai.djl.huggingface.tokenizers.Encoding;
import ai.djl.huggingface.tokenizers.HuggingFaceTokenizer;
import ai.onnxruntime.OnnxTensor;
import ai.onnxruntime.OrtEnvironment;
import ai.onnxruntime.OrtSession;
import work.slhaf.partner.common.vector.exception.VectorClientExecutionException;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
@SuppressWarnings("FieldMayBeFinal")
public class OnnxVectorClient extends VectorClient {
private String tokenizerPath;
private String modelPath;
private HuggingFaceTokenizer tokenizer;
private OrtSession session;
private OrtEnvironment env;
protected OnnxVectorClient(String tokenizer, String model) {
this.tokenizerPath = tokenizer;
this.modelPath = model;
loadTokenizer();
loadModel();
compute("test");
}
private void loadModel() {
try {
env = OrtEnvironment.getEnvironment();
OrtSession.SessionOptions ops = new OrtSession.SessionOptions();
session = env.createSession(modelPath, ops);
} catch (Exception e) {
throw new VectorClientExecutionException(
"Failed to load ONNX model",
"onnx",
"MODEL_LOAD",
modelPath,
e
);
}
}
private void loadTokenizer() {
try {
tokenizer = HuggingFaceTokenizer.newInstance(Path.of(tokenizerPath));
} catch (Exception e) {
throw new VectorClientExecutionException(
"Failed to load tokenizer",
"onnx",
"TOKENIZER_LOAD",
tokenizerPath,
e
);
}
}
@Override
protected float[] doCompute(String input) {
try {
Encoding encode = tokenizer.encode(input);
long[] ids = encode.getIds();
long[] attentionMask = encode.getAttentionMask();
long[][] inputIdsBatch = {ids};
long[][] attentionMaskBatch = {attentionMask};
long[][] tokenTypeIdsBatch = {new long[ids.length]}; // 初始化全 0
for (int i = 0; i < ids.length; i++)
tokenTypeIdsBatch[0][i] = 0;
OnnxTensor inputTensor = OnnxTensor.createTensor(env, inputIdsBatch);
OnnxTensor maskTensor = OnnxTensor.createTensor(env, attentionMaskBatch);
OnnxTensor tokenTypeTensor = OnnxTensor.createTensor(env, tokenTypeIdsBatch);
Map<String, OnnxTensor> inputs = new HashMap<>();
inputs.put("input_ids", inputTensor);
inputs.put("attention_mask", maskTensor);
inputs.put("token_type_ids", tokenTypeTensor);
OrtSession.Result result = session.run(inputs);
OnnxTensor embeddingTensor = (OnnxTensor) result.get(0);
return embeddingTensor.getFloatBuffer().array();
} catch (Exception e) {
throw new VectorClientExecutionException(
"Failed to execute embedding model",
"onnx",
"COMPUTE",
modelPath,
e
);
}
}
}
@@ -1,107 +0,0 @@
package work.slhaf.partner.common.vector;
import lombok.extern.slf4j.Slf4j;
import org.nd4j.linalg.api.ndarray.INDArray;
import org.nd4j.linalg.factory.Nd4j;
import org.nd4j.linalg.ops.transforms.Transforms;
import work.slhaf.partner.common.vector.exception.VectorClientExecutionException;
import work.slhaf.partner.common.vector.exception.VectorClientStartupException;
@Slf4j
public abstract class VectorClient {
public static boolean status = false;
public static VectorClient INSTANCE;
public static String VECTOR_MODEL_ID;
public static void startClient(VectorConfig config) {
try {
if (config instanceof VectorConfig.Ollama ollama) {
INSTANCE = new OllamaVectorClient(ollama.ollamaEmbeddingUrl, ollama.ollamaEmbeddingModel);
} else if (config instanceof VectorConfig.Onnx onnx) {
INSTANCE = new OnnxVectorClient(onnx.tokenizerPath, onnx.embeddingModelPath);
} else {
return;
}
status = true;
VECTOR_MODEL_ID = config.modelId;
} catch (VectorClientStartupException e) {
throw e;
} catch (VectorClientExecutionException e) {
throw new VectorClientStartupException(
"Vector client startup failed",
e.getClientType(),
"COMPUTE".equals(e.getPhase()) ? "STARTUP_SELF_TEST" : e.getPhase(),
e.getTarget(),
e
);
} catch (Exception e) {
throw new VectorClientStartupException(
"Vector client startup failed",
resolveClientType(config),
"STARTUP",
resolveTarget(config),
e
);
}
}
public float[] compute(String input) {
if (!status) {
return null;
}
return doCompute(input);
}
protected abstract float[] doCompute(String input);
private static String resolveClientType(VectorConfig config) {
if (config instanceof VectorConfig.Onnx) {
return "onnx";
}
if (config instanceof VectorConfig.Ollama) {
return "ollama";
}
return "unknown";
}
private static String resolveTarget(VectorConfig config) {
if (config instanceof VectorConfig.Onnx onnx) {
return onnx.embeddingModelPath;
}
if (config instanceof VectorConfig.Ollama ollama) {
return ollama.ollamaEmbeddingUrl;
}
return null;
}
public double compare(float[] v1, float[] v2) {
if (!status) {
return 0;
}
try (INDArray a1 = Nd4j.create(v1); INDArray a2 = Nd4j.create(v2)) {
return Transforms.cosineSim(a1, a2);
}
}
public float[] weightedAverage(float[] newVector, float[] primaryVector) {
try (INDArray primary = Nd4j.create(primaryVector);
INDArray latest = Nd4j.create(newVector)) {
// 1️⃣ 计算余弦相似度
double similarity = Transforms.cosineSim(primary, latest);
// 2️⃣ 根据相似度决定更新比例 α(差异越大,新输入影响越强)
double alpha = (1.0 - similarity) * 0.5;
alpha = Math.clamp(alpha, 0.05, 0.5);
// 3️⃣ 按比例混合旧向量与新向量
INDArray updated = primary.mul(1 - alpha).add(latest.mul(alpha));
// 4️⃣ 归一化结果(保持方向空间一致)
updated = updated.div(updated.norm2Number());
return updated.toFloatVector();
}
}
}
@@ -1,50 +0,0 @@
package work.slhaf.partner.common.vector;
import com.alibaba.fastjson2.JSONObject;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import work.slhaf.partner.framework.agent.config.Config;
import work.slhaf.partner.framework.agent.config.ConfigRegistration;
import work.slhaf.partner.framework.agent.config.Configurable;
import java.nio.file.Path;
import java.util.Map;
public class VectorClientRegistry implements Configurable, ConfigRegistration<VectorConfig> {
@Override
public void init(@NotNull VectorConfig config, @Nullable JSONObject json) {
if (!config.enabled) {
return;
}
if (config.type == null) {
return;
}
if (json == null) {
return;
}
config = switch (config.type) {
case ONNX -> json.toJavaObject(VectorConfig.Onnx.class);
case OLLAMA -> json.toJavaObject(VectorConfig.Ollama.class);
};
VectorClient.startClient(config);
}
@Override
@NotNull
public Class<VectorConfig> type() {
return VectorConfig.class;
}
@Nullable
@Override
public VectorConfig defaultConfig() {
return new VectorConfig(false, null, null);
}
@Override
public @NotNull Map<Path, ConfigRegistration<? extends Config>> declare() {
return Map.of(Path.of("vector.json"), this);
}
}
@@ -1,48 +0,0 @@
package work.slhaf.partner.common.vector;
import work.slhaf.partner.framework.agent.config.Config;
public sealed class VectorConfig extends Config permits VectorConfig.Ollama, VectorConfig.Onnx {
final boolean enabled;
final Type type;
final String modelId;
public VectorConfig(boolean enabled, Type type, String modelId) {
this.enabled = enabled;
this.type = type;
this.modelId = modelId;
}
protected static String fallbackModelId(String modelId, String fallback) {
return modelId == null || modelId.isBlank() ? fallback : modelId;
}
public enum Type {
ONNX,
OLLAMA
}
static final class Onnx extends VectorConfig {
final String tokenizerPath;
final String embeddingModelPath;
public Onnx(boolean enabled, Type type, String tokenizerPath, String embeddingModelPath, String modelId) {
super(enabled, type, fallbackModelId(modelId, embeddingModelPath));
this.tokenizerPath = tokenizerPath;
this.embeddingModelPath = embeddingModelPath;
}
}
static final class Ollama extends VectorConfig {
final String ollamaEmbeddingUrl;
final String ollamaEmbeddingModel;
public Ollama(boolean enabled, Type type, String ollamaEmbeddingUrl, String ollamaEmbeddingModel, String modelId) {
super(enabled, type, fallbackModelId(modelId, ollamaEmbeddingModel));
this.ollamaEmbeddingUrl = ollamaEmbeddingUrl;
this.ollamaEmbeddingModel = ollamaEmbeddingModel;
}
}
}
@@ -1,49 +0,0 @@
package work.slhaf.partner.common.vector.exception;
import org.jetbrains.annotations.Nullable;
import work.slhaf.partner.framework.agent.exception.AgentRuntimeException;
import work.slhaf.partner.framework.agent.exception.ExceptionReport;
public class VectorClientExecutionException extends AgentRuntimeException {
private final String clientType;
private final String phase;
private final String target;
public VectorClientExecutionException(String message, String clientType, String phase, @Nullable String target) {
super(message);
this.clientType = clientType;
this.phase = phase;
this.target = target;
}
public VectorClientExecutionException(String message, String clientType, String phase, @Nullable String target, Throwable cause) {
super(message, cause);
this.clientType = clientType;
this.phase = phase;
this.target = target;
}
public String getClientType() {
return clientType;
}
public String getPhase() {
return phase;
}
public String getTarget() {
return target;
}
@Override
public ExceptionReport toReport() {
ExceptionReport report = super.toReport();
report.getExtra().put("clientType", clientType);
report.getExtra().put("phase", phase);
if (target != null) {
report.getExtra().put("target", target);
}
return report;
}
}
@@ -1,37 +0,0 @@
package work.slhaf.partner.common.vector.exception;
import org.jetbrains.annotations.Nullable;
import work.slhaf.partner.framework.agent.exception.AgentStartupException;
import work.slhaf.partner.framework.agent.exception.ExceptionReport;
public class VectorClientStartupException extends AgentStartupException {
private final String clientType;
private final String phase;
private final String target;
public VectorClientStartupException(String message, String clientType, String phase, @Nullable String target) {
super(message, "vector-client-registry");
this.clientType = clientType;
this.phase = phase;
this.target = target;
}
public VectorClientStartupException(String message, String clientType, String phase, @Nullable String target, Throwable cause) {
super(message, "vector-client-registry", cause);
this.clientType = clientType;
this.phase = phase;
this.target = target;
}
@Override
public ExceptionReport toReport() {
ExceptionReport report = super.toReport();
report.getExtra().put("clientType", clientType);
report.getExtra().put("phase", phase);
if (target != null) {
report.getExtra().put("target", target);
}
return report;
}
}
@@ -1,41 +0,0 @@
package work.slhaf.partner.core.action;
import lombok.NonNull;
import org.jetbrains.annotations.Nullable;
import work.slhaf.partner.core.action.entity.ExecutableAction;
import work.slhaf.partner.core.action.entity.MetaAction;
import work.slhaf.partner.core.action.entity.MetaActionInfo;
import work.slhaf.partner.core.action.entity.intervention.MetaIntervention;
import work.slhaf.partner.core.action.runner.RunnerClient;
import work.slhaf.partner.framework.agent.factory.capability.annotation.Capability;
import work.slhaf.partner.framework.agent.support.Result;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ExecutorService;
@Capability(value = "action")
public interface ActionCapability {
void putAction(@NonNull ExecutableAction executableAction);
Set<ExecutableAction> listActions(@Nullable ExecutableAction.Status status, @Nullable String source);
ExecutorService getExecutor(ActionCore.ExecutorType type);
Result<MetaAction> loadMetaAction(@NonNull String actionKey);
Result<MetaActionInfo> loadMetaActionInfo(@NonNull String actionKey);
void registerMetaActions(@NonNull Map<String, MetaActionInfo> metaActions);
Map<String, MetaActionInfo> listAvailableMetaActions();
boolean checkExists(String... actionKeys);
RunnerClient runnerClient();
void handleInterventions(List<MetaIntervention> interventions, ExecutableAction data);
}
@@ -1,313 +0,0 @@
package work.slhaf.partner.core.action;
import com.alibaba.fastjson2.JSONObject;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import work.slhaf.partner.core.action.entity.ExecutableAction;
import work.slhaf.partner.core.action.entity.MetaAction;
import work.slhaf.partner.core.action.entity.MetaActionInfo;
import work.slhaf.partner.core.action.entity.intervention.InterventionType;
import work.slhaf.partner.core.action.entity.intervention.MetaIntervention;
import work.slhaf.partner.core.action.exception.ActionLookupException;
import work.slhaf.partner.core.action.runner.LocalRunnerClient;
import work.slhaf.partner.core.action.runner.RunnerClient;
import work.slhaf.partner.framework.agent.config.ConfigCenter;
import work.slhaf.partner.framework.agent.exception.AgentRuntimeException;
import work.slhaf.partner.framework.agent.exception.ExceptionReporterHandler;
import work.slhaf.partner.framework.agent.factory.capability.annotation.CapabilityCore;
import work.slhaf.partner.framework.agent.factory.capability.annotation.CapabilityMethod;
import work.slhaf.partner.framework.agent.factory.context.Shutdown;
import work.slhaf.partner.framework.agent.state.State;
import work.slhaf.partner.framework.agent.state.StateSerializable;
import work.slhaf.partner.framework.agent.state.StateValue;
import work.slhaf.partner.framework.agent.support.Result;
import java.io.IOException;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.*;
import java.util.stream.Collectors;
@SuppressWarnings("FieldMayBeFinal")
@CapabilityCore(value = "action")
@Slf4j
public class ActionCore implements StateSerializable {
public static final String BUILTIN_LOCATION = "builtin";
public static final String ORIGIN_LOCATION = "origin";
// 由于当前的执行器逻辑实现,平台线程池大小不得小于 2,这里规定为最小为 4
private final ExecutorService platformExecutor = Executors.newFixedThreadPool(Math.max(Runtime.getRuntime().availableProcessors(), 4));
private final ExecutorService virtualExecutor = Executors.newVirtualThreadPerTaskExecutor();
/**
* 已存在的行动程序,键格式为‘<MCP-ServerName>::<Tool-Name>’,值为 MCP Server 通过 Resources 相关渠道传递的行动程序元信息
*/
private final ConcurrentHashMap<String, MetaActionInfo> existedMetaActions = new ConcurrentHashMap<>();
/**
* 持久行动池
*/
private CopyOnWriteArraySet<ExecutableAction> actionPool = new CopyOnWriteArraySet<>();
private RunnerClient runnerClient;
public ActionCore() throws IOException, ClassNotFoundException {
String baseActionPath = ConfigCenter.INSTANCE.getPaths().getResourcesDir().resolve("action").normalize().toAbsolutePath().toString();
// TODO 通过 Config 指定采用何种 runnerClient,当前只提供 LocalRunnerClient
runnerClient = new LocalRunnerClient(existedMetaActions, virtualExecutor, baseActionPath);
register();
}
@Shutdown
public void shutdown() {
try {
runnerClient.close();
} catch (Exception e) {
log.warn("runner client close error", e);
}
try {
platformExecutor.shutdown();
virtualExecutor.shutdown();
int count = 0;
if (!platformExecutor.awaitTermination(8, TimeUnit.SECONDS)) {
count += platformExecutor.shutdownNow().size();
}
if (!virtualExecutor.awaitTermination(8, TimeUnit.SECONDS)) {
count += virtualExecutor.shutdownNow().size();
}
if (count != 0) {
log.warn("{} tasks still running", count);
}
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
}
@CapabilityMethod
public void putAction(@NonNull ExecutableAction executableAction) {
actionPool.removeIf(data -> data.getUuid().equals(executableAction.getUuid())); // 用来应对 ScheduledActionData 的重新排列
actionPool.add(executableAction);
}
@CapabilityMethod
public Set<ExecutableAction> listActions(@Nullable ExecutableAction.Status status, @Nullable String source) {
return actionPool.stream()
.filter(actionData -> status == null || actionData.getStatus().equals(status))
.filter(actionData -> source == null || actionData.getSource().equals(source))
.collect(Collectors.toSet());
}
@CapabilityMethod
public ExecutorService getExecutor(ExecutorType type) {
return switch (type) {
case VIRTUAL -> virtualExecutor;
case PLATFORM -> platformExecutor;
};
}
@CapabilityMethod
public void registerMetaActions(@NonNull Map<String, MetaActionInfo> metaActions) {
existedMetaActions.putAll(metaActions);
}
@CapabilityMethod
public Map<String, MetaActionInfo> listAvailableMetaActions() {
return existedMetaActions;
}
@CapabilityMethod
public Result<MetaAction> loadMetaAction(@NonNull String actionKey) {
MetaActionInfo metaActionInfo = existedMetaActions.get(actionKey);
if (metaActionInfo == null) {
return Result.failure(new ActionLookupException(
"Meta action info not found for action key: " + actionKey,
actionKey,
"META_ACTION"
));
}
String[] split = actionKey.split("::", 2);
if (split.length < 2) {
return Result.failure(new ActionLookupException(
"Invalid action key format: " + actionKey,
actionKey,
"META_ACTION"
));
}
MetaAction.Type type = switch (split[0]) {
case BUILTIN_LOCATION -> MetaAction.Type.BUILTIN;
case ORIGIN_LOCATION -> MetaAction.Type.ORIGIN;
default -> MetaAction.Type.MCP;
};
return Result.success(new MetaAction(
split[1],
metaActionInfo.getIo(),
metaActionInfo.getLauncher(),
type,
split[0]
));
}
@CapabilityMethod
public Result<MetaActionInfo> loadMetaActionInfo(@NonNull String actionKey) {
MetaActionInfo info = existedMetaActions.get(actionKey);
if (info == null) {
return Result.failure(new ActionLookupException(
"Meta action description not found for action key: " + actionKey,
actionKey,
"META_ACTION_INFO"
));
}
return Result.success(info);
}
@CapabilityMethod
public boolean checkExists(String... actionKeys) {
return existedMetaActions.keySet().containsAll(Arrays.asList(actionKeys));
}
@CapabilityMethod
public RunnerClient runnerClient() {
return runnerClient;
}
@CapabilityMethod
public void handleInterventions(List<MetaIntervention> interventions, ExecutableAction executableAction) {
// 加载数据
if (executableAction == null) {
return;
}
// 加锁确保同步
synchronized (executableAction.getExecutionLock()) {
applyInterventions(interventions, executableAction);
}
}
private void applyInterventions(List<MetaIntervention> interventions, ExecutableAction executableAction) {
boolean[] rebuildCleanTag = {false};
interventions.sort(Comparator.comparingInt(MetaIntervention::getOrder));
for (MetaIntervention intervention : interventions) {
Result<List<MetaAction>> actionsResult = resolveInterventionActions(intervention);
actionsResult
.onFailure(ExceptionReporterHandler.INSTANCE::report)
.onSuccess(actions -> {
switch (intervention.getType()) {
case InterventionType.APPEND ->
handleAppend(executableAction, intervention.getOrder(), actions);
case InterventionType.INSERT ->
handleInsert(executableAction, intervention.getOrder(), actions);
case InterventionType.DELETE ->
handleDelete(executableAction, intervention.getOrder(), actions);
case InterventionType.CANCEL -> handleCancel(executableAction);
case InterventionType.REBUILD -> {
if (!rebuildCleanTag[0]) {
cleanActionData(executableAction);
rebuildCleanTag[0] = true;
}
handleRebuild(executableAction, intervention.getOrder(), actions);
}
}
});
}
}
private Result<List<MetaAction>> resolveInterventionActions(MetaIntervention intervention) {
List<MetaAction> actions = new ArrayList<>();
for (String actionKey : intervention.getActions()) {
Result<MetaAction> metaActionResult = loadMetaAction(actionKey);
AgentRuntimeException failure = metaActionResult.onSuccess(actions::add).exceptionOrNull();
if (failure != null) {
return Result.failure(failure);
}
}
return Result.success(actions);
}
/**
* 在指定阶段之后追加新的行动单元组
*/
private void handleAppend(ExecutableAction executableAction, int order, List<MetaAction> actions) {
Map<Integer, List<MetaAction>> actionChain = executableAction.getActionChain();
int targetOrder = nextAvailableOrder(actionChain, order + 1);
actionChain.put(targetOrder, actions);
}
private int nextAvailableOrder(Map<Integer, List<MetaAction>> actionChain, int startOrder) {
int order = startOrder;
while (actionChain.containsKey(order)) {
order++;
}
return order;
}
/**
* 在未进入执行阶段和正处于行动阶段的行动单元组插入新的行动
*/
private void handleInsert(ExecutableAction executableAction, int order, List<MetaAction> actions) {
if (order < executableAction.getExecutingStage())
return;
List<MetaAction> stageActions = executableAction.getActionChain().computeIfAbsent(order, k -> new ArrayList<>());
synchronized (stageActions) {
stageActions.addAll(actions);
}
}
private void handleDelete(ExecutableAction executableAction, int order, List<MetaAction> actions) {
if (order <= executableAction.getExecutingStage())
return;
Map<Integer, List<MetaAction>> actionChain = executableAction.getActionChain();
if (actionChain.containsKey(order)) {
actionChain.get(order).removeAll(actions);
if (actionChain.get(order).isEmpty()) {
actionChain.remove(order);
}
}
}
private void handleCancel(ExecutableAction executableAction) {
executableAction.setStatus(ExecutableAction.Status.FAILED);
executableAction.setResult("行动取消");
}
private void handleRebuild(ExecutableAction executableAction, int order, List<MetaAction> actions) {
Map<Integer, List<MetaAction>> actionChain = executableAction.getActionChain();
actionChain.put(order, actions);
}
private void cleanActionData(ExecutableAction executableAction) {
executableAction.getActionChain().clear();
executableAction.setExecutingStage(0);
executableAction.setStatus(ExecutableAction.Status.PREPARE);
executableAction.getHistory().clear();
}
@Override
public @NotNull Path statePath() {
return Path.of("core", "action.json");
}
@Override
public void load(@NotNull JSONObject state) {
actionPool = ActionPoolStateCodec.decode(state.getJSONArray("action_pool"));
}
@Override
public @NotNull State convert() {
State state = new State();
List<StateValue.Obj> actionPoolState = ActionPoolStateCodec.encode(actionPool);
state.append("action_pool", StateValue.arr(actionPoolState));
return state;
}
public enum ExecutorType {
VIRTUAL, PLATFORM
}
}
@@ -1,357 +0,0 @@
package work.slhaf.partner.core.action;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.Nullable;
import work.slhaf.partner.core.action.entity.*;
import work.slhaf.partner.framework.agent.state.StateValue;
import work.slhaf.partner.module.action.executor.entity.HistoryAction;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArraySet;
@Slf4j
final class ActionPoolStateCodec {
private ActionPoolStateCodec() {
}
static List<StateValue.Obj> encode(CopyOnWriteArraySet<ExecutableAction> actionPool) {
return actionPool.stream()
.map(ActionPoolStateCodec::encodeExecutableAction)
.toList();
}
static CopyOnWriteArraySet<ExecutableAction> decode(@Nullable JSONArray actionPoolArray) {
CopyOnWriteArraySet<ExecutableAction> restored = new CopyOnWriteArraySet<>();
if (actionPoolArray == null) {
return restored;
}
for (int i = 0; i < actionPoolArray.size(); i++) {
JSONObject actionObject = actionPoolArray.getJSONObject(i);
if (actionObject == null) {
continue;
}
try {
ExecutableAction executableAction = decodeExecutableAction(actionObject);
if (executableAction != null) {
restored.add(executableAction);
}
} catch (Exception e) {
log.warn("Skip invalid action_pool item at index {}", i, e);
}
}
return restored;
}
private static StateValue.Obj encodeExecutableAction(ExecutableAction action) {
Map<String, Object> actionMap = new LinkedHashMap<>();
actionMap.put("kind", action instanceof SchedulableExecutableAction ? "schedulable" : "immediate");
actionMap.put("uuid", action.getUuid());
actionMap.put("source", action.getSource());
actionMap.put("reason", action.getReason());
actionMap.put("description", action.getDescription());
actionMap.put("status", action.getStatus().name());
actionMap.put("tendency", action.getTendency());
actionMap.put("executing_stage", action.getExecutingStage());
String result = resolveExecutableResult(action);
if (result != null) {
actionMap.put("result", result);
}
if (action instanceof SchedulableExecutableAction schedulableAction) {
actionMap.put("schedule_type", schedulableAction.getScheduleType().name());
actionMap.put("schedule_content", schedulableAction.getScheduleContent());
actionMap.put("enabled", schedulableAction.getEnabled());
actionMap.put("schedule_histories", encodeScheduleHistories(schedulableAction));
}
List<StateValue.Obj> chainStates = action.getActionChain().entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.map(entry -> {
Map<String, Object> stageMap = new LinkedHashMap<>();
stageMap.put("stage", entry.getKey());
String stageDescription = action.getStageDescriptions().get(entry.getKey());
if (stageDescription != null && !stageDescription.isBlank()) {
stageMap.put("description", stageDescription);
}
stageMap.put("actions", entry.getValue().stream()
.map(ActionPoolStateCodec::encodeMetaAction)
.toList());
return StateValue.obj(stageMap);
}).toList();
actionMap.put("action_chain", chainStates);
actionMap.put("history", encodeHistoryStages(action.getHistory()));
return StateValue.obj(actionMap);
}
private static StateValue.Obj encodeMetaAction(MetaAction metaAction) {
Map<String, Object> metaMap = new LinkedHashMap<>();
metaMap.put("name", metaAction.getName());
metaMap.put("io", metaAction.getIo());
if (metaAction.getLauncher() != null) {
metaMap.put("launcher", metaAction.getLauncher());
}
metaMap.put("type", metaAction.getType().name());
metaMap.put("location", metaAction.getLocation());
metaMap.put("params_json", JSONObject.toJSONString(metaAction.getParams()));
metaMap.put("result_status", metaAction.getResult().getStatus().name());
if (metaAction.getResult().getData() != null) {
metaMap.put("result_data", metaAction.getResult().getData());
}
return StateValue.obj(metaMap);
}
private static StateValue.Obj encodeHistoryAction(HistoryAction historyAction) {
Map<String, Object> historyMap = new LinkedHashMap<>();
historyMap.put("action_key", historyAction.actionKey());
historyMap.put("description", historyAction.description());
historyMap.put("result", historyAction.result());
return StateValue.obj(historyMap);
}
private static ExecutableAction decodeExecutableAction(JSONObject actionObject) {
String kind = actionObject.getString("kind");
String uuid = actionObject.getString("uuid");
String source = actionObject.getString("source");
String reason = actionObject.getString("reason");
String description = actionObject.getString("description");
String tendency = actionObject.getString("tendency");
String status = actionObject.getString("status");
Integer executingStage = actionObject.getInteger("executing_stage");
if (kind == null || uuid == null || source == null || reason == null || description == null || tendency == null) {
return null;
}
Map<Integer, String> restoredStageDescriptions = new LinkedHashMap<>();
Map<Integer, List<MetaAction>> restoredChain = decodeActionChain(
actionObject.getJSONArray("action_chain"),
restoredStageDescriptions
);
ExecutableAction executableAction;
if ("schedulable".equals(kind)) {
String scheduleType = actionObject.getString("schedule_type");
String scheduleContent = actionObject.getString("schedule_content");
if (scheduleType == null || scheduleContent == null) {
return null;
}
SchedulableExecutableAction schedulableAction = new SchedulableExecutableAction(
tendency,
restoredChain,
reason,
description,
source,
Schedulable.ScheduleType.valueOf(scheduleType),
scheduleContent,
uuid
);
Boolean enabled = actionObject.getBoolean("enabled");
if (enabled != null) {
schedulableAction.setEnabled(enabled);
}
schedulableAction.getScheduleHistories().addAll(decodeScheduleHistories(actionObject.getJSONArray("schedule_histories")));
executableAction = schedulableAction;
} else if ("immediate".equals(kind)) {
executableAction = new ImmediateExecutableAction(
tendency,
restoredChain,
reason,
description,
source,
uuid
);
} else {
return null;
}
if (status != null) {
executableAction.setStatus(Action.Status.valueOf(status));
}
if (executingStage != null) {
executableAction.setExecutingStage(executingStage);
}
String result = actionObject.getString("result");
if (result != null) {
executableAction.setResult(result);
}
executableAction.getStageDescriptions().putAll(restoredStageDescriptions);
executableAction.getHistory().putAll(decodeHistory(actionObject.getJSONArray("history")));
return executableAction;
}
private static Map<Integer, List<MetaAction>> decodeActionChain(
@Nullable JSONArray actionChainArray,
Map<Integer, String> stageDescriptions
) {
Map<Integer, List<MetaAction>> restored = new LinkedHashMap<>();
if (actionChainArray == null) {
return toMutableActionChain(restored);
}
for (int i = 0; i < actionChainArray.size(); i++) {
JSONObject stageObject = actionChainArray.getJSONObject(i);
if (stageObject == null) {
continue;
}
Integer stage = stageObject.getInteger("stage");
String description = stageObject.getString("description");
JSONArray actions = stageObject.getJSONArray("actions");
if (stage == null || actions == null) {
continue;
}
if (description != null && !description.isBlank()) {
stageDescriptions.put(stage, description);
}
List<MetaAction> metaActions = new ArrayList<>();
for (int j = 0; j < actions.size(); j++) {
JSONObject actionObject = actions.getJSONObject(j);
MetaAction metaAction = decodeMetaAction(actionObject);
if (metaAction != null) {
metaActions.add(metaAction);
}
}
restored.put(stage, metaActions);
}
return toMutableActionChain(restored);
}
private static MetaAction decodeMetaAction(@Nullable JSONObject actionObject) {
if (actionObject == null) {
return null;
}
String name = actionObject.getString("name");
Boolean io = actionObject.getBoolean("io");
String type = actionObject.getString("type");
String location = actionObject.getString("location");
if (name == null || io == null || type == null || location == null) {
return null;
}
MetaAction metaAction = new MetaAction(
name,
io,
actionObject.getString("launcher"),
MetaAction.Type.valueOf(type),
location
);
String paramsJson = actionObject.getString("params_json");
if (paramsJson != null && !paramsJson.isBlank()) {
JSONObject paramsObject = JSONObject.parseObject(paramsJson);
if (paramsObject != null) {
metaAction.getParams().putAll(paramsObject);
}
}
String resultStatus = actionObject.getString("result_status");
if (resultStatus != null) {
metaAction.getResult().setStatus(MetaAction.Result.Status.valueOf(resultStatus));
}
metaAction.getResult().setData(actionObject.getString("result_data"));
return metaAction;
}
private static Map<Integer, List<HistoryAction>> decodeHistory(@Nullable JSONArray historyArray) {
Map<Integer, List<HistoryAction>> restored = new LinkedHashMap<>();
if (historyArray == null) {
return restored;
}
for (int i = 0; i < historyArray.size(); i++) {
JSONObject stageObject = historyArray.getJSONObject(i);
if (stageObject == null) {
continue;
}
Integer stage = stageObject.getInteger("stage");
JSONArray actions = stageObject.getJSONArray("actions");
if (stage == null || actions == null) {
continue;
}
List<HistoryAction> historyActions = new ArrayList<>();
for (int j = 0; j < actions.size(); j++) {
JSONObject historyObject = actions.getJSONObject(j);
if (historyObject == null) {
continue;
}
String actionKey = historyObject.getString("action_key");
String description = historyObject.getString("description");
String result = historyObject.getString("result");
if (actionKey == null || description == null || result == null) {
continue;
}
historyActions.add(new HistoryAction(actionKey, description, result));
}
restored.put(stage, historyActions);
}
return restored;
}
private static List<StateValue.Obj> encodeHistoryStages(Map<Integer, ? extends List<HistoryAction>> historyMap) {
return historyMap.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.map(entry -> {
Map<String, Object> stageMap = new LinkedHashMap<>();
stageMap.put("stage", entry.getKey());
stageMap.put("actions", entry.getValue().stream()
.map(ActionPoolStateCodec::encodeHistoryAction)
.toList());
return StateValue.obj(stageMap);
}).toList();
}
private static List<StateValue.Obj> encodeScheduleHistories(SchedulableExecutableAction schedulableAction) {
return schedulableAction.getScheduleHistories().stream()
.map(scheduleHistory -> {
Map<String, Object> historyMap = new LinkedHashMap<>();
historyMap.put("end_time", scheduleHistory.getEndTime().toString());
historyMap.put("result", scheduleHistory.getResult());
historyMap.put("history", encodeHistoryStages(scheduleHistory.getHistory()));
return StateValue.obj(historyMap);
})
.toList();
}
private static List<SchedulableExecutableAction.ScheduleHistory> decodeScheduleHistories(@Nullable JSONArray scheduleHistoriesArray) {
List<SchedulableExecutableAction.ScheduleHistory> restored = new ArrayList<>();
if (scheduleHistoriesArray == null) {
return restored;
}
for (int i = 0; i < scheduleHistoriesArray.size(); i++) {
JSONObject historyObject = scheduleHistoriesArray.getJSONObject(i);
if (historyObject == null) {
continue;
}
try {
String endTime = historyObject.getString("end_time");
String result = historyObject.getString("result");
if (endTime == null || result == null) {
continue;
}
restored.add(new SchedulableExecutableAction.ScheduleHistory(
ZonedDateTime.parse(endTime),
result,
decodeHistory(historyObject.getJSONArray("history"))
));
} catch (Exception e) {
log.warn("Skip invalid schedule_history item at index {}", i, e);
}
}
return restored;
}
private static Map<Integer, List<MetaAction>> toMutableActionChain(Map<Integer, List<MetaAction>> actionChain) {
Map<Integer, List<MetaAction>> restored = new LinkedHashMap<>();
actionChain.forEach((stage, actions) -> restored.put(stage, new ArrayList<>(actions)));
return restored;
}
private static String resolveExecutableResult(ExecutableAction action) {
try {
return action.getResult();
} catch (RuntimeException ignored) {
return null;
}
}
}
@@ -1,297 +0,0 @@
package work.slhaf.partner.core.action.entity
import work.slhaf.partner.module.action.executor.entity.HistoryAction
import java.time.Instant
import java.time.ZonedDateTime
import java.util.*
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
sealed class Action(
open val uuid: String = UUID.randomUUID().toString()
) {
/**
* 行动ID
*/
abstract val source: String
/**
* 行动原因
*/
abstract val reason: String
/**
* 行动描述
*/
abstract val description: String
abstract val timeout: Duration
val timeoutMills: Long
get() = timeout.inWholeMilliseconds
/**
* 行动状态
*/
var status: Status = Status.PREPARE
enum class Status {
/**
* 执行成功
*/
SUCCESS,
/**
* 执行失败
*/
FAILED,
/**
* 执行中
*/
EXECUTING,
/**
* 暂时中断
*/
INTERRUPTED,
/**
* 预备执行
*/
PREPARE
}
}
sealed interface Schedulable {
val scheduleType: ScheduleType
val scheduleContent: String
val uuid: String
val timeout: Duration
val timeoutMills: Long
var enabled: Boolean
enum class ScheduleType {
CYCLE,
ONCE
}
}
/**
* 行动模块传递的行动数据,包含行动uuid、倾向、状态、行动链、结果、发起原因、行动描述等信息。
*/
sealed class ExecutableAction(
override val uuid: String = UUID.randomUUID().toString()
) : Action(uuid) {
val executionLock: Any = Any()
/**
* 行动倾向
*/
abstract val tendency: String
/**
* 行动链
*/
abstract val actionChain: MutableMap<Int, MutableList<MetaAction>>
/**
* 行动阶段描述,用于为每个 stage 提供可落地的执行目标说明
*/
val stageDescriptions: MutableMap<Int, String> = mutableMapOf()
/**
* 行动阶段(当前阶段)
*/
var executingStage: Int = 0
/**
* 行动结果
*/
var result: String? = null
val history: MutableMap<Int, MutableList<HistoryAction>> = mutableMapOf()
override val timeout: Duration = 10.minutes
/**
* @param timeout 最长打断时间
* @return 是否超时结束
*/
fun interrupt(timeout: Int): Boolean {
status = Status.INTERRUPTED
val interruptAt = Instant.now().epochSecond
while (status == Status.INTERRUPTED) {
Thread.sleep(500)
if (Instant.now().epochSecond - interruptAt > timeout) {
return false
}
}
return true
}
fun resume() {
status = Status.EXECUTING
}
fun snapshot(): ExecutableActionSnapshot {
val schedulable = this as? Schedulable
return ExecutableActionSnapshot(
uuid = uuid,
source = source,
reason = reason,
description = description,
timeoutMills = timeoutMills,
status = status,
tendency = tendency,
actionChainSize = actionChain.size,
stageDescriptions = stageDescriptions.toMap(),
executingStage = executingStage,
result = result,
history = history.mapValues { (_, value) -> value.toList() },
scheduleType = schedulable?.scheduleType,
scheduleContent = schedulable?.scheduleContent,
enabled = schedulable?.enabled
)
}
}
/**
* 计划行动数据类,继承自[Action],扩展了[Schedulable]相关调度属性,用于标识计划类型(单次还是周期性任务)和计划内容
*/
data class SchedulableExecutableAction @JvmOverloads constructor(
override val tendency: String,
override val actionChain: MutableMap<Int, MutableList<MetaAction>>,
override val reason: String,
override val description: String,
override val source: String,
override val scheduleType: Schedulable.ScheduleType,
override val scheduleContent: String,
override val uuid: String = UUID.randomUUID().toString(),
) : ExecutableAction(uuid), Schedulable {
override var enabled = true
val scheduleHistories = ArrayList<ScheduleHistory>()
fun recordAndReset() {
val newHistory = ScheduleHistory(ZonedDateTime.now(), result ?: "null", history.toMap())
scheduleHistories.add(newHistory)
executingStage = 0
for (entry in actionChain) {
for (action in entry.value) {
action.params.clear()
action.result.reset()
}
}
}
data class ScheduleHistory(
val endTime: ZonedDateTime,
val result: String,
val history: Map<Int, List<HistoryAction>>
)
}
/**
* 即时行动数据类
*/
data class ImmediateExecutableAction @JvmOverloads constructor(
override val tendency: String,
override val actionChain: MutableMap<Int, MutableList<MetaAction>>,
override val reason: String,
override val description: String,
override val source: String,
override val uuid: String = UUID.randomUUID().toString(),
) : ExecutableAction(uuid)
/**
* 用于计时的一次性或周期性触发或者针对某一数据源进行内容更新的行动
*/
data class StateAction @JvmOverloads constructor(
override val source: String,
override val reason: String,
override val description: String,
override val scheduleType: Schedulable.ScheduleType,
override val scheduleContent: String,
val trigger: Trigger,
override var enabled: Boolean = true,
override val timeout: Duration = 5.minutes,
) : Action(), Schedulable {
fun snapshot(): StateActionSnapshot {
return StateActionSnapshot(
uuid = uuid,
source = source,
reason = reason,
description = description,
timeoutMills = timeoutMills,
status = status,
scheduleType = scheduleType,
scheduleContent = scheduleContent,
enabled = enabled,
)
}
sealed interface Trigger {
fun onTrigger()
/**
* State 更新触发
*/
class Update<T>(val stateSource: T, val update: (stateSource: T) -> Unit) : Trigger {
override fun onTrigger() {
update(stateSource)
}
}
/**
* 常规逻辑触发
*/
class Call(val call: () -> Unit) : Trigger {
override fun onTrigger() {
call()
}
}
}
}
data class ExecutableActionSnapshot(
val uuid: String,
val source: String,
val reason: String,
val description: String,
val timeoutMills: Long,
val status: Action.Status,
val tendency: String,
val actionChainSize: Int,
val stageDescriptions: Map<Int, String>,
val executingStage: Int,
val result: String?,
val history: Map<Int, List<HistoryAction>>,
val scheduleType: Schedulable.ScheduleType? = null,
val scheduleContent: String? = null,
val enabled: Boolean? = null
)
data class StateActionSnapshot(
val uuid: String,
val source: String,
val reason: String,
val description: String,
val timeoutMills: Long,
val status: Action.Status,
val scheduleType: Schedulable.ScheduleType,
val scheduleContent: String,
val enabled: Boolean
)
@@ -1,10 +0,0 @@
package work.slhaf.partner.core.action.entity;
import lombok.Data;
@Data
public class ActionFileMetaData {
private String content;
private String name;
private String ext;
}
@@ -1,84 +0,0 @@
package work.slhaf.partner.core.action.entity
/**
* 行动链中的单一元素,封装了调用外部行动程序的必要信息与结果容器,可被[work.slhaf.partner.core.action.ActionCapability]执行
*/
data class MetaAction(
/**
* 行动name,用于标识行动程序
*/
val name: String,
/**
* 是否IO密集,用于决定使用何种线程池
*/
val io: Boolean = false,
/**
* 启动器/解释器,对于原生 MCP Tool 、Dynamic Action 来说可忽略,目前仅用于 ORIGIN 类型
*/
val launcher: String? = null,
/**
* 行动程序类型,可分为 MCP、ORIGIN、BUILTIN 三种,
* 分别对应读取到的 MCP Tool、生成的临时行动程序、内置行动
*/
val type: Type,
/**
* 当类型为 MCP 时,该字段对应相应 MCP Client 注册时生成的 id;
* 当类型为 ORIGIN 时,该字段对应相应的磁盘路径字符串;
* 当类型为 BUILTIN 时,该字段固定为 builtin
*/
val location: String,
) {
/**
* 行动程序可接受的参数,由调用处设置
*/
val params: MutableMap<String, Any> = mutableMapOf()
/**
* 行动结果,包括执行状态和相应内容(执行结果或者错误信息)
*/
val result = Result()
val key: String
/**
* actionKey 将由 location+name 共同定位
*
* @return actionKey
*/
get() = "$location::$name"
class Result {
var status = Status.WAITING
var data: String? = null
fun reset() {
status = Status.WAITING
data = null
}
enum class Status {
SUCCESS,
FAILED,
WAITING
}
}
enum class Type {
/**
* 将调用的 MCP 工具,可包括远程、本地任意服务
*/
MCP,
/**
* 适用于‘临时生成’的行动程序,在生成后根据序列化选项及执行情况,进行持久化
*/
ORIGIN,
/**
* 由本地内置注册表直接执行的行动
*/
BUILTIN
}
}
@@ -1,44 +0,0 @@
package work.slhaf.partner.core.action.entity
import com.alibaba.fastjson2.JSONObject
data class MetaActionInfo(
/**
* 是否 IO 密集
*/
val io: Boolean,
/**
* 所需的启动器/解释器
*/
val launcher: String?,
/**
* 参数描述
*/
val params: Map<String, String>,
/**
* 行动功能描述
*/
val description: String,
/**
* 行动标签
*/
val tags: Set<String>,
/**
* 前置行动依赖
*/
val preActions: Set<String>,
/**
* 后置行动依赖
*/
val postActions: Set<String>,
/**
* 是否严格依赖前置行动的成功执行,若为true且前置行动失败则不执行该行动,后置任务多为触发式。默认即执行。
*/
val strictDependencies: Boolean,
/**
* 响应格式说明
*/
val responseSchema: JSONObject,
)
@@ -1,28 +0,0 @@
package work.slhaf.partner.core.action.entity.intervention;
public enum InterventionType {
/**
* 追加行动: 追加至指定行动链序列之后才执行
*/
APPEND,
/**
* 插入行动: 指定行动链序列执行过程中即时新增并执行
*/
INSERT,
/**
* 重建行动: 重建指定行动链序列之后的所有行动内容
*/
REBUILD,
/**
* 删除行动: 删除指定行动链序列上的指定行动单元
*/
DELETE,
/**
* 取消行动链: 中断并取消指定行动链的执行
*/
CANCEL
}
@@ -1,21 +0,0 @@
package work.slhaf.partner.core.action.entity.intervention;
import lombok.Data;
import java.util.List;
@Data
public class MetaIntervention {
/**
* 干预数据类型
*/
private InterventionType type;
/**
* 干预数据对应的行动链序列
*/
private int order;
/**
* 干预数据所需的行动key列表
*/
private List<String> actions;
}
@@ -1,39 +0,0 @@
package work.slhaf.partner.core.action.exception;
import org.jetbrains.annotations.Nullable;
import work.slhaf.partner.framework.agent.exception.AgentStartupException;
import work.slhaf.partner.framework.agent.exception.ExceptionReport;
public class ActionInfrastructureStartupException extends AgentStartupException {
private final String component;
private final String path;
private final String command;
public ActionInfrastructureStartupException(String message, String component, @Nullable String path, @Nullable String command) {
super(message, "action-core");
this.component = component;
this.path = path;
this.command = command;
}
public ActionInfrastructureStartupException(String message, String component, @Nullable String path, @Nullable String command, Throwable cause) {
super(message, "action-core", cause);
this.component = component;
this.path = path;
this.command = command;
}
@Override
public ExceptionReport toReport() {
ExceptionReport report = super.toReport();
report.getExtra().put("component", component);
if (path != null) {
report.getExtra().put("path", path);
}
if (command != null) {
report.getExtra().put("command", command);
}
return report;
}
}
@@ -1,30 +0,0 @@
package work.slhaf.partner.core.action.exception;
import work.slhaf.partner.framework.agent.exception.AgentRuntimeException;
import work.slhaf.partner.framework.agent.exception.ExceptionReport;
public class ActionLookupException extends AgentRuntimeException {
private final String actionKey;
private final String lookupTarget;
public ActionLookupException(String message, String actionKey, String lookupTarget) {
super(message);
this.actionKey = actionKey;
this.lookupTarget = lookupTarget;
}
public ActionLookupException(String message, String actionKey, String lookupTarget, Throwable cause) {
super(message, cause);
this.actionKey = actionKey;
this.lookupTarget = lookupTarget;
}
@Override
public ExceptionReport toReport() {
ExceptionReport report = super.toReport();
report.getExtra().put("actionKey", actionKey);
report.getExtra().put("lookupTarget", lookupTarget);
return report;
}
}
@@ -1,58 +0,0 @@
package work.slhaf.partner.core.action.exception;
import org.jetbrains.annotations.Nullable;
import work.slhaf.partner.framework.agent.exception.AgentRuntimeException;
import work.slhaf.partner.framework.agent.exception.ExceptionReport;
public class ActionSerializationException extends AgentRuntimeException {
private final String actionName;
private final String baseDir;
private final String fileExt;
private final String stage;
public ActionSerializationException(
String message,
@Nullable String actionName,
@Nullable String baseDir,
@Nullable String fileExt,
String stage
) {
super(message);
this.actionName = actionName;
this.baseDir = baseDir;
this.fileExt = fileExt;
this.stage = stage;
}
public ActionSerializationException(
String message,
@Nullable String actionName,
@Nullable String baseDir,
@Nullable String fileExt,
String stage,
Throwable cause
) {
super(message, cause);
this.actionName = actionName;
this.baseDir = baseDir;
this.fileExt = fileExt;
this.stage = stage;
}
@Override
public ExceptionReport toReport() {
ExceptionReport report = super.toReport();
report.getExtra().put("stage", stage);
if (actionName != null) {
report.getExtra().put("actionName", actionName);
}
if (baseDir != null) {
report.getExtra().put("baseDir", baseDir);
}
if (fileExt != null) {
report.getExtra().put("fileExt", fileExt);
}
return report;
}
}
@@ -1,207 +0,0 @@
package work.slhaf.partner.core.action.runner;
import cn.hutool.system.OsInfo;
import cn.hutool.system.SystemUtil;
import lombok.extern.slf4j.Slf4j;
import lombok.val;
import org.jetbrains.annotations.Nullable;
import work.slhaf.partner.core.action.entity.ActionFileMetaData;
import work.slhaf.partner.core.action.entity.MetaAction;
import work.slhaf.partner.core.action.entity.MetaActionInfo;
import work.slhaf.partner.core.action.exception.ActionInfrastructureStartupException;
import work.slhaf.partner.core.action.runner.execution.McpActionExecutor;
import work.slhaf.partner.core.action.runner.execution.OriginExecutionService;
import work.slhaf.partner.core.action.runner.mcp.*;
import work.slhaf.partner.core.action.runner.policy.BwrapPolicyProvider;
import work.slhaf.partner.core.action.runner.policy.ExecutionPolicyRegistry;
import work.slhaf.partner.core.action.runner.support.ActionSerializer;
import java.io.IOException;
import java.nio.file.Path;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.atomic.AtomicBoolean;
@Slf4j
public class LocalRunnerClient extends RunnerClient {
public static final String MCP_NAME_DESC = "mcp-desc";
public static final String MCP_NAME_DYNAMIC = "mcp-dynamic";
private final String tmpActionPath;
private final String dynamicActionPath;
private final String mcpServerPath;
private final String mcpDescPath;
private final McpClientRegistry mcpClientRegistry;
private final McpTransportFactory mcpTransportFactory;
private final ActionSerializer actionSerializer;
private final OriginExecutionService originExecutionService;
private final McpActionExecutor mcpActionExecutor;
private final McpMetaRegistry mcpMetaRegistry;
private final McpDescWatcher mcpDescWatcher;
private final DynamicActionMcpManager dynamicActionMcpManager;
private final McpConfigWatcher mcpConfigWatcher;
private final AtomicBoolean closed = new AtomicBoolean(false);
public LocalRunnerClient(ConcurrentHashMap<String, MetaActionInfo> existedMetaActions, ExecutorService executor, @Nullable String baseActionPath) {
super(existedMetaActions, executor, baseActionPath);
this.tmpActionPath = buildPathStr(ACTION_PATH, "tmp");
this.dynamicActionPath = buildPathStr(ACTION_PATH, "dynamic");
this.mcpServerPath = buildPathStr(ACTION_PATH, "mcp");
this.mcpDescPath = buildPathStr(mcpServerPath, "desc");
createPath(tmpActionPath);
createPath(dynamicActionPath);
createPath(mcpServerPath);
createPath(mcpDescPath);
McpClientRegistry clientRegistry = new McpClientRegistry();
McpTransportFactory transportFactory = new McpTransportFactory();
ActionSerializer serializer = new ActionSerializer(tmpActionPath, dynamicActionPath);
OriginExecutionService originService = new OriginExecutionService();
McpActionExecutor actionExecutor = new McpActionExecutor(clientRegistry);
McpMetaRegistry metaRegistry = null;
McpDescWatcher descWatcher = null;
DynamicActionMcpManager dynamicManager = null;
McpConfigWatcher configWatcher = null;
try {
registerPolicyProviders();
metaRegistry = new McpMetaRegistry(existedMetaActions);
registerMcpClient(clientRegistry, transportFactory, MCP_NAME_DESC, metaRegistry.clientConfig(MCP_NAME_DESC, 10));
log.info("DescMcp 注册完毕");
descWatcher = new McpDescWatcher(Path.of(mcpDescPath), metaRegistry, executor);
descWatcher.start();
dynamicManager = new DynamicActionMcpManager(
Path.of(dynamicActionPath),
existedMetaActions,
executor
);
registerMcpClient(clientRegistry, transportFactory, MCP_NAME_DYNAMIC, dynamicManager.clientConfig(10));
log.info("DynamicActionMcp 注册完毕");
dynamicManager.start();
configWatcher = new McpConfigWatcher(
Path.of(mcpServerPath),
existedMetaActions,
clientRegistry,
transportFactory,
metaRegistry,
executor
);
configWatcher.start();
configWatcher.registerPolicyListener();
} catch (ActionInfrastructureStartupException e) {
closeQuietly(configWatcher);
closeQuietly(dynamicManager);
closeQuietly(descWatcher);
closeQuietly(metaRegistry);
closeQuietly(clientRegistry);
throw e;
} catch (Exception e) {
closeQuietly(configWatcher);
closeQuietly(dynamicManager);
closeQuietly(descWatcher);
closeQuietly(metaRegistry);
closeQuietly(clientRegistry);
throw new ActionInfrastructureStartupException(
"LocalRunnerClient initialization failed",
"local-runner-client",
ACTION_PATH,
null,
e
);
}
this.mcpClientRegistry = clientRegistry;
this.mcpTransportFactory = transportFactory;
this.actionSerializer = serializer;
this.originExecutionService = originService;
this.mcpActionExecutor = actionExecutor;
this.mcpMetaRegistry = metaRegistry;
this.mcpDescWatcher = descWatcher;
this.dynamicActionMcpManager = dynamicManager;
this.mcpConfigWatcher = configWatcher;
}
private void registerPolicyProviders() {
OsInfo os = SystemUtil.getOsInfo();
if (os.isLinux()) {
ExecutionPolicyRegistry.INSTANCE.registerPolicyProvider(BwrapPolicyProvider.INSTANCE);
}
}
@Override
protected RunnerResponse doRun(MetaAction metaAction) {
RunnerResponse response;
response = switch (metaAction.getType()) {
case MCP -> mcpActionExecutor.run(metaAction);
case ORIGIN -> originExecutionService.run(metaAction);
case BUILTIN -> doRunWithBuiltin(metaAction);
};
return response;
}
@Override
public String buildTmpPath(String actionKey, String codeType) {
return actionSerializer.buildTmpPath(actionKey, codeType);
}
@Override
public void tmpSerialize(MetaAction tempAction, String code, String codeType) throws IOException {
actionSerializer.tmpSerialize(tempAction, code, codeType);
}
@Override
public void persistSerialize(MetaActionInfo metaActionInfo, ActionFileMetaData fileMetaData) {
actionSerializer.persistSerialize(metaActionInfo, fileMetaData);
}
private void registerMcpClient(McpClientRegistry clientRegistry, McpTransportFactory transportFactory, String id, McpTransportConfig transportConfig) {
val client = io.modelcontextprotocol.client.McpClient.sync(transportFactory.create(transportConfig))
.requestTimeout(java.time.Duration.ofSeconds(transportConfig.timeout()))
.clientInfo(new io.modelcontextprotocol.spec.McpSchema.Implementation(id, "PARTNER"))
.build();
clientRegistry.register(id, client);
}
@Override
public void close() {
if (!closed.compareAndSet(false, true)) {
return;
}
mcpConfigWatcher.unregisterPolicyListener();
closeQuietly(mcpConfigWatcher);
closeQuietly(dynamicActionMcpManager);
closeQuietly(mcpDescWatcher);
closeQuietly(mcpMetaRegistry);
closeQuietly(mcpClientRegistry);
}
private void closeQuietly(AutoCloseable closeable) {
if (closeable == null) {
return;
}
try {
closeable.close();
} catch (Exception ignored) {
}
}
public String buildPathStr(String... path) {
StringBuilder str = new StringBuilder();
for (int i = 0; i < path.length; i++) {
str.append(path[i]);
if (i < path.length - 1) {
str.append("/");
}
}
return str.toString();
}
}
@@ -1,126 +0,0 @@
package work.slhaf.partner.core.action.runner;
import lombok.Data;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import lombok.val;
import org.jetbrains.annotations.NotNull;
import work.slhaf.partner.core.action.entity.ActionFileMetaData;
import work.slhaf.partner.core.action.entity.MetaAction;
import work.slhaf.partner.core.action.entity.MetaAction.Result;
import work.slhaf.partner.core.action.entity.MetaActionInfo;
import work.slhaf.partner.core.action.exception.ActionInfrastructureStartupException;
import work.slhaf.partner.module.action.builtin.BuiltinActionRegistry;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
/**
* 执行客户端抽象类
* <br/>
* 只负责暴露序列化、执行等相应接口,具体逻辑交给下游实现
* <br/>
* 默认存在两类实现,{@link LocalRunnerClient} 和 {@link SandboxRunnerClient}
* <ol>
* LocalRunnerClient:
* <li>
* 对应本地运行环境,可在本地启动 MCP 客户端将 RunnerClient 暴露的能力接口转发至本地 MCP Client 并执行
* </li>
* SandboxRunnerClient:
* <li>
* 对应沙盒运行环境,该 Client 仅作为沙盒环境的客户端,不持有额外能力,仅保持远端连接已存在行动的内容更新
* </li>
* </ol>
*/
@Slf4j
public abstract class RunnerClient implements AutoCloseable {
protected final String ACTION_PATH;
protected final ConcurrentHashMap<String, MetaActionInfo> existedMetaActions;
protected final ExecutorService executor;
@Setter
protected BuiltinActionRegistry builtinActionRegistry;
/**
* ActionCore 将注入虚拟线程池
*/
public RunnerClient(ConcurrentHashMap<String, MetaActionInfo> existedMetaActions, ExecutorService executor, @NotNull String baseActionPath) {
this.existedMetaActions = existedMetaActions;
this.executor = executor;
this.ACTION_PATH = baseActionPath;
createPath(ACTION_PATH);
}
/**
* 执行行动程序
*/
public void submit(MetaAction metaAction) {
log.debug("执行行动: {}", metaAction);
// 获取已存在行动列表
Result result = metaAction.getResult();
if (!result.getStatus().equals(Result.Status.WAITING)) {
return;
}
RunnerResponse response = work.slhaf.partner.framework.agent.support.Result.runCatching(() -> doRun(metaAction)).fold(
runnerResponse -> runnerResponse,
ex -> {
RunnerResponse r = new RunnerResponse();
r.setOk(false);
r.setData(ex.getLocalizedMessage());
return r;
}
);
result.setData(response.getData());
result.setStatus(response.isOk() ? Result.Status.SUCCESS : Result.Status.FAILED);
log.debug("行动执行结果: {}", response);
}
protected abstract RunnerResponse doRun(MetaAction metaAction);
public abstract String buildTmpPath(String actionKey, String codeType);
public abstract void tmpSerialize(MetaAction tempAction, String code, String codeType) throws IOException;
public abstract void persistSerialize(MetaActionInfo metaActionInfo, ActionFileMetaData fileMetaData);
protected RunnerResponse doRunWithBuiltin(MetaAction metaAction) {
RunnerResponse response = new RunnerResponse();
if (builtinActionRegistry == null) {
response.setOk(false);
response.setData("BuiltinActionRegistry 未初始化");
return response;
}
response.setData(builtinActionRegistry.call(metaAction.getKey(), metaAction.getParams()));
response.setOk(true);
return response;
}
protected void createPath(String pathStr) {
val path = Path.of(pathStr);
try {
Files.createDirectory(path);
} catch (IOException e) {
if (!Files.exists(path)) {
throw new ActionInfrastructureStartupException(
"Failed to create action directory: " + pathStr,
"runner-client",
pathStr,
null,
e
);
}
}
}
@Data
public static class RunnerResponse {
private boolean ok;
private String data;
}
}
@@ -1,60 +0,0 @@
package work.slhaf.partner.core.action.runner;
import work.slhaf.partner.core.action.entity.ActionFileMetaData;
import work.slhaf.partner.core.action.entity.MetaAction;
import work.slhaf.partner.core.action.entity.MetaActionInfo;
import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
/**
* 基于 Http 与 WebSocket 的沙盒执行器客户端,负责:
* <ul>
* <li>
* 发送行动单元数据
* </li>
* <li>
* 实时更新获取已存在行动列表
* </li>
* <li>
* 向传入的 MetaAction 回写执行结果
* </li>
* </ul>
*/
public class SandboxRunnerClient extends RunnerClient {
public SandboxRunnerClient(ConcurrentHashMap<String, MetaActionInfo> existedMetaActions, ExecutorService executor) { // 连接沙盒执行器(websocket)
super(existedMetaActions, executor, null);
}
protected RunnerResponse doRun(MetaAction metaAction) {
return switch (metaAction.getType()) {
case BUILTIN -> doRunWithBuiltin(metaAction);
case MCP, ORIGIN -> {
// 调用沙盒执行器
yield null;
}
};
}
@Override
public String buildTmpPath(String actionKey, String codeType) {
throw new UnsupportedOperationException("Unimplemented method 'buildTmpPath'");
}
@Override
public void tmpSerialize(MetaAction tempAction, String code, String codeType) throws IOException {
throw new UnsupportedOperationException("Unimplemented method 'tmpSerialize'");
}
@Override
public void persistSerialize(MetaActionInfo metaActionInfo, ActionFileMetaData fileMetaData) {
throw new UnsupportedOperationException("Unimplemented method 'persistSerialize'");
}
@Override
public void close() throws Exception {
}
}
@@ -1,185 +0,0 @@
package work.slhaf.partner.core.action.runner.execution;
import lombok.Data;
import work.slhaf.partner.core.action.runner.policy.WrappedLaunchSpec;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
public class CommandExecutionService {
public static final CommandExecutionService INSTANCE = new CommandExecutionService();
private CommandExecutionService() {
}
public String[] buildFileExecutionCommands(String launcher, Map<String, Object> params, String absolutePath) {
int paramSize = params == null ? 0 : params.size();
String[] commands = new String[paramSize + 2];
commands[0] = launcher;
commands[1] = absolutePath;
AtomicInteger paramCount = new AtomicInteger(2);
if (params != null) {
params.forEach((param, value) -> commands[paramCount.getAndIncrement()] = "--" + param + "=" + value);
}
return commands;
}
public Result exec(List<String> commands) {
return exec(commands.toArray(new String[0]));
}
public Result exec(WrappedLaunchSpec launchSpec) {
Result result = new Result();
List<String> output = Collections.synchronizedList(new ArrayList<>());
List<String> error = Collections.synchronizedList(new ArrayList<>());
try {
Process process = startProcess(launchSpec);
Thread stdoutThread = Thread.startVirtualThread(() -> {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) {
String line;
while ((line = reader.readLine()) != null) {
output.add(line);
}
} catch (Exception ignored) {
}
});
Thread stderrThread = Thread.startVirtualThread(() -> {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getErrorStream()))) {
String line;
while ((line = reader.readLine()) != null) {
error.add(line);
}
} catch (Exception ignored) {
}
});
int exitCode = process.waitFor();
stdoutThread.join();
stderrThread.join();
result.setOk(exitCode == 0);
List<String> stdoutLines = List.copyOf(output);
List<String> stderrLines = List.copyOf(error);
result.setStdoutLines(stdoutLines);
result.setStderrLines(stderrLines);
result.setResultList(stdoutLines.isEmpty() ? stderrLines : stdoutLines);
result.setTotal(buildDisplayText(stdoutLines, stderrLines));
} catch (Exception e) {
result.setOk(false);
result.setTotal(e.getMessage());
result.setStdoutLines(List.of());
result.setStderrLines(List.of(e.getMessage()));
result.setResultList(result.getStderrLines());
}
return result;
}
public Result exec(String... commands) {
return exec(defaultLaunchSpec(commands));
}
public CommandSession createSessionTask(List<String> commands) {
return createSessionTask(commands.toArray(new String[0]));
}
public CommandSession createSessionTask(WrappedLaunchSpec launchSpec) {
try {
Process process = startProcess(launchSpec);
CommandSession session = new CommandSession();
StringBuilder stdoutBuffer = new StringBuilder();
StringBuilder stderrBuffer = new StringBuilder();
session.setProcess(process);
session.setStdoutBuffer(stdoutBuffer);
session.setStderrBuffer(stderrBuffer);
Thread.startVirtualThread(() -> readToBuffer(process.getInputStream(), stdoutBuffer));
Thread.startVirtualThread(() -> readToBuffer(process.getErrorStream(), stderrBuffer));
return session;
} catch (Exception e) {
throw new IllegalStateException("创建命令会话失败", e);
}
}
public CommandSession createSessionTask(String... commands) {
return createSessionTask(defaultLaunchSpec(commands));
}
private void readToBuffer(java.io.InputStream inputStream, StringBuilder buffer) {
try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
synchronized (buffer) {
if (!buffer.isEmpty()) {
buffer.append('\n');
}
buffer.append(line);
}
}
} catch (Exception ignored) {
}
}
private Process startProcess(WrappedLaunchSpec launchSpec) throws Exception {
ProcessBuilder processBuilder = new ProcessBuilder();
List<String> command = new ArrayList<>();
command.add(launchSpec.getCommand());
command.addAll(launchSpec.getArgs());
processBuilder.command(command);
processBuilder.redirectErrorStream(false);
if (launchSpec.getWorkingDirectory() != null && !launchSpec.getWorkingDirectory().isBlank()) {
processBuilder.directory(new File(launchSpec.getWorkingDirectory()));
}
Map<String, String> environment = processBuilder.environment();
environment.clear();
environment.putAll(launchSpec.getEnvironment());
return processBuilder.start();
}
private WrappedLaunchSpec defaultLaunchSpec(String... commands) {
return new WrappedLaunchSpec(
commands[0],
List.of(commands).subList(1, commands.length),
null,
System.getenv()
);
}
private String buildDisplayText(List<String> stdoutLines, List<String> stderrLines) {
if (stdoutLines.isEmpty()) {
return String.join("\n", stderrLines);
}
if (stderrLines.isEmpty()) {
return String.join("\n", stdoutLines);
}
return String.join("\n", stdoutLines) + "\n" + String.join("\n", stderrLines);
}
@Data
public static class Result {
private boolean ok;
private String total;
private List<String> resultList;
private List<String> stdoutLines;
private List<String> stderrLines;
}
@Data
public static class CommandSession {
private Process process;
private StringBuilder stdoutBuffer;
private StringBuilder stderrBuffer;
}
}
@@ -1,72 +0,0 @@
package work.slhaf.partner.core.action.runner.execution;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.spec.McpSchema;
import work.slhaf.partner.core.action.entity.MetaAction;
import work.slhaf.partner.core.action.runner.RunnerClient;
import work.slhaf.partner.core.action.runner.mcp.McpClientRegistry;
import java.util.List;
import java.util.stream.Collectors;
public class McpActionExecutor {
private final McpClientRegistry mcpClientRegistry;
public McpActionExecutor(McpClientRegistry mcpClientRegistry) {
this.mcpClientRegistry = mcpClientRegistry;
}
public RunnerClient.RunnerResponse run(MetaAction metaAction) {
RunnerClient.RunnerResponse response = new RunnerClient.RunnerResponse();
McpSyncClient mcpClient = mcpClientRegistry.get(metaAction.getLocation());
if (mcpClient == null) {
response.setOk(false);
response.setData("MCP client not found: " + metaAction.getLocation());
return response;
}
McpSchema.CallToolRequest callToolRequest = McpSchema.CallToolRequest.builder()
.name(metaAction.getName())
.arguments(metaAction.getParams())
.build();
McpSchema.CallToolResult callToolResult;
try {
callToolResult = mcpClient.callTool(callToolRequest);
} catch (Exception e) {
response.setOk(false);
response.setData("MCP tool call failed: " + e.getMessage());
return response;
}
Boolean error = callToolResult.isError();
response.setOk(error == null || !error);
response.setData(extractResponseData(callToolResult));
return response;
}
private String extractResponseData(McpSchema.CallToolResult callToolResult) {
Object structuredContent = callToolResult.structuredContent();
if (structuredContent != null) {
return String.valueOf(structuredContent);
}
List<McpSchema.Content> contents = callToolResult.content();
if (contents != null && !contents.isEmpty()) {
String contentSummary = contents.stream()
.map(this::renderContent)
.filter(text -> text != null && !text.isBlank())
.collect(Collectors.joining("\n"));
if (!contentSummary.isBlank()) {
return contentSummary;
}
}
return callToolResult.toString();
}
private String renderContent(McpSchema.Content content) {
if (content instanceof McpSchema.TextContent textContent) {
return textContent.text();
}
return String.valueOf(content);
}
}
@@ -1,35 +0,0 @@
package work.slhaf.partner.core.action.runner.execution;
import work.slhaf.partner.core.action.entity.MetaAction;
import work.slhaf.partner.core.action.runner.RunnerClient;
import work.slhaf.partner.core.action.runner.policy.ExecutionPolicyRegistry;
import work.slhaf.partner.core.action.runner.policy.WrappedLaunchSpec;
import java.io.File;
import java.util.Arrays;
import static work.slhaf.partner.core.action.ActionCore.ORIGIN_LOCATION;
public class OriginExecutionService {
public OriginExecutionService() {
}
public RunnerClient.RunnerResponse run(MetaAction metaAction) {
RunnerClient.RunnerResponse response = new RunnerClient.RunnerResponse();
File file = new File(resolveOriginPath(metaAction));
String[] commands = CommandExecutionService.INSTANCE.buildFileExecutionCommands(metaAction.getLauncher(), metaAction.getParams(), file.getAbsolutePath());
WrappedLaunchSpec wrapped = ExecutionPolicyRegistry.INSTANCE.prepare(Arrays.stream(commands).toList());
CommandExecutionService.Result execResult = CommandExecutionService.INSTANCE.exec(wrapped);
response.setOk(execResult.isOk());
response.setData(execResult.getTotal());
return response;
}
private String resolveOriginPath(MetaAction metaAction) {
if (ORIGIN_LOCATION.equals(metaAction.getLocation())) {
return metaAction.getName();
}
return metaAction.getLocation();
}
}
@@ -1,360 +0,0 @@
package work.slhaf.partner.core.action.runner.mcp;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.server.McpStatelessAsyncServer;
import io.modelcontextprotocol.server.McpStatelessServerFeatures;
import io.modelcontextprotocol.spec.McpSchema;
import lombok.extern.slf4j.Slf4j;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import work.slhaf.partner.core.action.entity.MetaActionInfo;
import work.slhaf.partner.core.action.exception.ActionInfrastructureStartupException;
import work.slhaf.partner.core.action.runner.execution.CommandExecutionService;
import work.slhaf.partner.core.action.runner.policy.ExecutionPolicyRegistry;
import work.slhaf.partner.framework.agent.support.DirectoryWatchSupport;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.BiFunction;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Slf4j
public class DynamicActionMcpManager implements AutoCloseable {
private final Path root;
private final ConcurrentHashMap<String, MetaActionInfo> existedMetaActions;
private final CommandExecutionService commandExecutionService;
private final McpStatelessAsyncServer server;
private final InProcessMcpTransport clientTransport;
private final DirectoryWatchSupport watchSupport;
public DynamicActionMcpManager(Path root,
ConcurrentHashMap<String, MetaActionInfo> existedMetaActions,
ExecutorService executor) throws IOException {
this.root = root;
this.existedMetaActions = existedMetaActions;
this.commandExecutionService = CommandExecutionService.INSTANCE;
InProcessMcpTransport.Pair pair = InProcessMcpTransport.pair();
this.clientTransport = pair.clientSide();
McpSchema.ServerCapabilities serverCapabilities = McpSchema.ServerCapabilities.builder()
.tools(true)
.build();
this.server = McpServer.async(pair.serverSide())
.capabilities(serverCapabilities)
.jsonMapper(McpJsonMapper.getDefault())
.build();
this.watchSupport = new DirectoryWatchSupport(new DirectoryWatchSupport.Context(root), executor, 1, this::loadExisting)
.onCreate(this::handleCreate)
.onModify(this::handleModify)
.onDelete(this::handleDelete)
.onOverflow((thisDir, context) -> reconcile());
}
public McpTransportConfig.InProcess clientConfig(int timeout) {
return new McpTransportConfig.InProcess(timeout, clientTransport);
}
public void start() {
watchSupport.start();
log.info("DynamicActionMcp 文件监听注册完毕");
}
private void loadExisting() {
File file = root.toFile();
if (file.isFile()) {
throw new ActionInfrastructureStartupException(
"Expected a directory but found a file: " + root,
"dynamic-action-mcp-manager",
root.toString(),
null
);
}
File[] files = file.listFiles();
if (files == null) {
throw new ActionInfrastructureStartupException(
"Failed to read action directory: " + root,
"dynamic-action-mcp-manager",
root.toString(),
null
);
}
for (File dir : files) {
if (!normalPath(dir.toPath())) {
continue;
}
addAction(dir.getName(), dir.toPath());
}
}
private boolean isTmp(Path context) {
return context.getFileName().endsWith(".tmp");
}
private void handleModify(Path thisDir, Path context) {
if (context == null || isTmp(context) || !normalPath(thisDir)) {
return;
}
modify(thisDir, context);
}
private void handleCreate(Path thisDir, Path context) {
if (context == null || isTmp(context)) {
return;
}
if (thisDir.equals(root) && Files.isDirectory(context)) {
try {
watchSupport.registerDirectory(context);
} catch (IOException e) {
log.error("监听目录注册失败: {}", context);
}
}
if (normalPath(thisDir)) {
modify(thisDir, context);
}
if (Files.isDirectory(context) && normalPath(context)) {
File[] files = context.toFile().listFiles();
if (files == null) {
log.warn("目录无法访问: {}", context);
return;
}
for (File file : files) {
modify(context, file.toPath());
}
}
}
private void handleDelete(Path thisDir, Path context) {
if (context == null || isTmp(context)) {
return;
}
if (thisDir.equals(root)) {
String name = context.getFileName().toString();
Path candidate = root.resolve(name);
if (Files.isDirectory(candidate)) {
return;
}
removeAction(name);
AtomicReference<java.nio.file.WatchKey> toRemove = new AtomicReference<>();
watchSupport.context().watchKeys().forEach((key, path) -> {
if (path.getFileName().toString().equals(name)) {
key.cancel();
toRemove.set(key);
}
});
if (toRemove.get() != null) {
watchSupport.context().watchKeys().remove(toRemove.get());
}
return;
}
if (!thisDir.equals(root) && !normalPath(thisDir)) {
removeAction(thisDir.getFileName().toString());
}
}
private void reconcile() {
Set<String> existed = existedMetaActions.keySet().stream()
.filter(actionKey -> actionKey.startsWith("local::"))
.map(actionKey -> actionKey.split("::")[1])
.collect(Collectors.toSet());
Set<String> currentDirs = new HashSet<>();
try (Stream<Path> stream = Files.list(root).filter(Files::isDirectory)) {
stream.forEach(path -> {
String name = path.getFileName().toString();
currentDirs.add(name);
boolean contains = existed.contains(name);
boolean normal = normalPath(path);
if (contains && !normal) {
removeAction(name);
}
if (!contains) {
boolean alreadyWatching = watchSupport.isWatching(path);
if (!alreadyWatching) {
try {
watchSupport.registerDirectory(path);
} catch (IOException e) {
log.error("监听目录注册失败: {}", path);
}
}
if (normal) {
addAction(name, path);
}
}
});
} catch (IOException e) {
log.error("目录无法读取: {}", root);
return;
}
for (String existedName : existed) {
if (!currentDirs.contains(existedName)) {
removeAction(existedName);
}
}
}
private void modify(Path thisDir, Path context) {
String fileName = context.getFileName().toString();
if (fileName.equals("desc.json")) {
handleMetaModify(thisDir);
}
if (fileName.startsWith("run.")) {
handleProgramModify(thisDir);
}
}
private void handleProgramModify(Path thisDir) {
String name = thisDir.getFileName().toString();
String actionKey = "local::" + name;
if (existedMetaActions.containsKey(actionKey)) {
return;
}
if (!addAction(name, thisDir)) {
removeAction(name);
}
}
private void handleMetaModify(Path thisDir) {
String name = thisDir.getFileName().toString();
if (!addAction(name, thisDir)) {
removeAction(name);
}
}
private boolean addAction(String name, Path dir) {
File program = null;
try (Stream<Path> stream = Files.list(dir)) {
for (Path path : stream.toList()) {
if (isTmp(path)) {
continue;
}
if (path.getFileName().toString().startsWith("run.")) {
program = path.toFile();
}
}
} catch (Exception e) {
log.error("添加 action 失败", e);
return false;
}
MetaActionInfo info;
try {
info = JSON.parseObject(Files.readString(dir.resolve("desc.json"), StandardCharsets.UTF_8), MetaActionInfo.class);
} catch (Exception e) {
log.error("desc.json 加载失败: {}", dir);
return false;
}
String actionKey = "local::" + name;
existedMetaActions.put(actionKey, info);
server.addTool(buildAsyncToolSpecification(info, program, actionKey, name)).subscribe();
return true;
}
private void removeAction(String name) {
existedMetaActions.remove("local::" + name);
server.removeTool(name).subscribe();
}
private boolean normalPath(Path path) {
File[] files = loadFiles(path);
if (files == null || files.length < 2) {
return false;
}
boolean desc = false;
int run = 0;
for (File file : files) {
String fileName = file.getName();
if (fileName.endsWith(".tmp")) {
continue;
}
if (fileName.equals("desc.json")) {
desc = true;
}
if (fileName.startsWith("run.")) {
run++;
}
}
return run == 1 && desc;
}
private File[] loadFiles(Path path) {
if (!Files.isDirectory(path)) {
return null;
}
return path.toFile().listFiles();
}
private McpStatelessServerFeatures.AsyncToolSpecification buildAsyncToolSpecification(MetaActionInfo info, File program, String actionKey, String name) {
Map<String, Object> additional = Map.of(
"pre", info.getPreActions(),
"post", info.getPostActions(),
"strict_pre", info.getStrictDependencies(),
"io", info.getIo()
);
McpSchema.Tool tool = McpSchema.Tool.builder()
.name(name)
.description(info.getDescription())
.inputSchema(McpJsonMapper.getDefault(), JSONObject.toJSONString(info.getParams()))
.outputSchema(info.getResponseSchema())
.title(actionKey)
.meta(additional)
.build();
return McpStatelessServerFeatures.AsyncToolSpecification.builder()
.tool(tool)
.callHandler(buildToolHandler(program, info.getLauncher()))
.build();
}
private BiFunction<McpTransportContext, McpSchema.CallToolRequest, Mono<McpSchema.CallToolResult>> buildToolHandler(File program, String launcher) {
return (mcpTransportContext, callToolRequest) -> {
Map<String, Object> arguments = callToolRequest.arguments();
if (arguments == null) {
arguments = Map.of();
}
String[] commands = commandExecutionService.buildFileExecutionCommands(launcher, arguments, program.getAbsolutePath());
if (commands == null) {
return Mono.just(McpSchema.CallToolResult.builder()
.addTextContent("未知文件类型: " + program.getName())
.isError(true)
.build());
}
return Mono.fromCallable(() -> {
CommandExecutionService.Result execResult = commandExecutionService.exec(
ExecutionPolicyRegistry.INSTANCE.prepare(List.of(commands))
);
McpSchema.CallToolResult.Builder builder = McpSchema.CallToolResult.builder()
.isError(!execResult.isOk());
List<String> resultList = execResult.getResultList();
if (resultList != null && !resultList.isEmpty()) {
builder.textContent(resultList);
builder.structuredContent(resultList);
} else {
builder.addTextContent(execResult.getTotal());
builder.structuredContent(execResult.getTotal());
}
return builder.build();
}).subscribeOn(Schedulers.boundedElastic());
};
}
@Override
public void close() {
try {
watchSupport.close();
} catch (IOException ignored) {
}
server.close();
}
}
@@ -1,155 +0,0 @@
package work.slhaf.partner.core.action.runner.mcp;
import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.json.TypeRef;
import io.modelcontextprotocol.server.McpStatelessServerHandler;
import io.modelcontextprotocol.spec.McpClientTransport;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpStatelessServerTransport;
import reactor.core.publisher.Mono;
import reactor.core.publisher.Sinks;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
import java.util.function.Function;
public final class InProcessMcpTransport implements McpClientTransport, McpStatelessServerTransport {
// 每个 transport 只处理一条 inbound 流
private final Sinks.Many<McpSchema.JSONRPCMessage> inbound =
Sinks.many().unicast().onBackpressureBuffer();
private final AtomicBoolean clientConnected = new AtomicBoolean(false);
private final AtomicBoolean serverConnected = new AtomicBoolean(false);
/**
* 对端
*/
private volatile InProcessMcpTransport peer;
private volatile McpStatelessServerHandler serverHandler;
public static Pair pair() {
InProcessMcpTransport client = new InProcessMcpTransport();
InProcessMcpTransport server = new InProcessMcpTransport();
client.peer = server;
server.peer = client;
return new Pair(client, server);
}
/* ======================================================
* Internal receive: peer.sendMessage -> this.receive
* ====================================================== */
private void receive(McpSchema.JSONRPCMessage message) {
if (inbound.tryEmitNext(message).isFailure()) {
throw new RuntimeException("Failed to receive message: " + message);
}
}
/* ======================================================
* Client → Server sendMessage
* ====================================================== */
@Override
public Mono<Void> sendMessage(McpSchema.JSONRPCMessage message) {
InProcessMcpTransport p = this.peer;
if (p == null) {
return Mono.error(new IllegalStateException("Transport is not linked"));
}
return Mono.fromRunnable(() -> p.receive(message));
}
/* ======================================================
* Client connect(handler) 处理 server → client 消息
* ====================================================== */
@Override
public Mono<Void> connect(Function<Mono<McpSchema.JSONRPCMessage>, Mono<McpSchema.JSONRPCMessage>> handler) {
if (!clientConnected.compareAndSet(false, true)) {
return Mono.error(new IllegalStateException("Client already connected"));
}
return inbound.asFlux()
.concatMap(msg ->
handler.apply(Mono.just(msg))
// handler may emit response message → send back to server
.flatMap(resp -> resp != null ? sendMessage(resp) : Mono.empty())
)
.doFinally(sig -> clientConnected.set(false))
.then();
}
@Override
public void setExceptionHandler(Consumer<Throwable> handler) {
McpClientTransport.super.setExceptionHandler(handler);
}
/* ======================================================
* Server: bind stateless handler = process client → server inbound
* ====================================================== */
@Override
public void setMcpHandler(McpStatelessServerHandler handler) {
this.serverHandler = handler;
if (!serverConnected.compareAndSet(false, true)) {
throw new IllegalStateException("Server already connected");
}
// 订阅 client → server 消息
inbound.asFlux()
.concatMap(this::handleServerMessage)
.doFinally(sig -> serverConnected.set(false))
.subscribe();
}
/**
* Server 端处理 JSONRPCMessage
*/
private Mono<Void> handleServerMessage(McpSchema.JSONRPCMessage msg) {
// 创建 transport context(简单实现即可)
McpTransportContext ctx = key -> null;
if (msg instanceof McpSchema.JSONRPCRequest req) {
return serverHandler.handleRequest(ctx, req)
.flatMap(this::sendMessage);
}
if (msg instanceof McpSchema.JSONRPCNotification noti) {
return serverHandler.handleNotification(ctx, noti);
}
return Mono.empty();
}
@Override
public void close() {
McpClientTransport.super.close();
}
/* ======================================================
* other boilerplate
* ====================================================== */
@Override
public Mono<Void> closeGracefully() {
inbound.tryEmitComplete();
clientConnected.set(false);
serverConnected.set(false);
return Mono.empty();
}
@Override
public <T> T unmarshalFrom(Object data, TypeRef<T> typeRef) {
return McpJsonMapper.getDefault().convertValue(data, typeRef);
}
@Override
public List<String> protocolVersions() {
return McpClientTransport.super.protocolVersions();
}
public record Pair(InProcessMcpTransport clientSide, InProcessMcpTransport serverSide) {
}
}
@@ -1,49 +0,0 @@
package work.slhaf.partner.core.action.runner.mcp;
import io.modelcontextprotocol.client.McpSyncClient;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
public class McpClientRegistry implements AutoCloseable {
private final ConcurrentHashMap<String, McpSyncClient> clients = new ConcurrentHashMap<>();
public McpSyncClient get(String serverName) {
return clients.get(serverName);
}
public void register(String serverName, McpSyncClient client) {
McpSyncClient old = clients.put(serverName, client);
if (old != null && old != client) {
old.close();
}
}
public McpSyncClient remove(String serverName) {
McpSyncClient client = detach(serverName);
if (client != null) {
client.close();
}
return client;
}
public McpSyncClient detach(String serverName) {
return clients.remove(serverName);
}
public boolean contains(String serverName) {
return clients.containsKey(serverName);
}
public Set<String> listIds() {
return new HashSet<>(clients.keySet());
}
@Override
public void close() {
clients.forEach((id, client) -> client.close());
clients.clear();
}
}
@@ -1,335 +0,0 @@
package work.slhaf.partner.core.action.runner.mcp;
import cn.hutool.json.JSONUtil;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.spec.McpSchema;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import work.slhaf.partner.core.action.entity.MetaActionInfo;
import work.slhaf.partner.core.action.runner.LocalRunnerClient;
import work.slhaf.partner.core.action.runner.policy.ExecutionPolicy;
import work.slhaf.partner.core.action.runner.policy.RunnerExecutionPolicyListener;
import work.slhaf.partner.framework.agent.support.DirectoryWatchSupport;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Duration;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
@Slf4j
public class McpConfigWatcher implements AutoCloseable, RunnerExecutionPolicyListener {
private final Path root;
private final ConcurrentHashMap<String, MetaActionInfo> existedMetaActions;
private final McpClientRegistry mcpClientRegistry;
private final McpTransportFactory mcpTransportFactory;
private final McpMetaRegistry mcpMetaRegistry;
private final DirectoryWatchSupport watchSupport;
private final Map<File, McpConfigFileRecord> mcpConfigFileCache = new HashMap<>();
public McpConfigWatcher(Path root,
ConcurrentHashMap<String, MetaActionInfo> existedMetaActions,
McpClientRegistry mcpClientRegistry,
McpTransportFactory mcpTransportFactory,
McpMetaRegistry mcpMetaRegistry,
ExecutorService executor) throws IOException {
this.root = root;
this.existedMetaActions = existedMetaActions;
this.mcpClientRegistry = mcpClientRegistry;
this.mcpTransportFactory = mcpTransportFactory;
this.mcpMetaRegistry = mcpMetaRegistry;
this.watchSupport = new DirectoryWatchSupport(new DirectoryWatchSupport.Context(root), executor, 0, this::loadInitial)
.onCreate(this::handleCreate)
.onModify((thisDir, context) -> checkAndReload(true))
.onDelete(this::handleDelete)
.onOverflow((thisDir, context) -> checkAndReload(false));
}
public void start() {
watchSupport.start();
log.info("CommonMcp 文件监听注册完毕");
}
private void loadInitial() {
File[] files = loadFiles(root);
if (files == null) {
return;
}
for (File file : files) {
if (!normalFile(file)) {
continue;
}
loadAndRegisterMcpClientsFromFile(file);
}
}
private void handleCreate(Path thisDir, Path context) {
if (context == null) {
return;
}
File file = context.toFile();
if (!normalFile(file)) {
return;
}
loadAndRegisterMcpClientsFromFile(file);
}
private void handleDelete(Path thisDir, Path context) {
if (context == null) {
return;
}
File file = context.toFile();
if (!file.getName().endsWith(".json")) {
return;
}
McpConfigFileRecord fileRecord = mcpConfigFileCache.remove(file);
if (fileRecord == null) {
return;
}
for (String clientId : fileRecord.paramsCacheMap().keySet()) {
unregisterClient(clientId);
}
}
private boolean normalFile(File file) {
return file.exists() && file.isFile() && file.getName().endsWith(".json");
}
private void registerMcpClient(String id, McpTransportConfig transportConfig) {
McpSyncClient client = McpClient.sync(mcpTransportFactory.create(transportConfig))
.requestTimeout(Duration.ofSeconds(transportConfig.timeout()))
.clientInfo(new McpSchema.Implementation(id, "PARTNER"))
.build();
try {
for (McpSchema.Tool tool : client.listTools().tools()) {
existedMetaActions.put(id + "::" + tool.name(), mcpMetaRegistry.buildMetaActionInfo(id, tool));
}
mcpClientRegistry.register(id, client);
} catch (Exception e) {
log.warn("[{}] MCP client init failed, skipped (probably non-stdio-safe)", id, e);
client.close();
}
}
private void unregisterClient(String clientId) {
McpSyncClient client = mcpClientRegistry.detach(clientId);
removeClientActions(clientId, client);
if (client != null) {
try {
client.close();
} catch (Exception e) {
log.warn("[{}] MCP client close failed", clientId, e);
}
}
}
private void removeClientActions(String clientId, McpSyncClient client) {
boolean removedByListing = false;
if (client != null) {
try {
List<McpSchema.Tool> tools = client.listTools().tools();
if (tools != null) {
for (McpSchema.Tool tool : tools) {
existedMetaActions.remove(clientId + "::" + tool.name());
}
removedByListing = true;
}
} catch (Exception e) {
log.warn("[{}] MCP client listTools failed during unregister, fallback to key scan", clientId, e);
}
}
if (!removedByListing) {
String prefix = clientId + "::";
existedMetaActions.keySet().removeIf(actionKey -> actionKey.startsWith(prefix));
}
}
private cn.hutool.json.JSONObject readJson(File file) {
try {
return JSONUtil.readJSONObject(file, StandardCharsets.UTF_8);
} catch (Exception ignored) {
return null;
}
}
private cn.hutool.json.JSONObject readMcp(cn.hutool.json.JSONObject json, String id) {
try {
return json.getJSONObject(id);
} catch (Exception ignored) {
return null;
}
}
@SuppressWarnings("unchecked")
private McpTransportConfig readParams(cn.hutool.json.JSONObject mcp) {
Set<String> keys = mcp.keySet();
int timeout = mcp.getInt("timeout", 30);
if (matchesKeys(keys, Set.of("command", "args", "env"), Set.of("timeout"))) {
String command = mcp.getStr("command");
Map<String, String> env = mcp.getBean("env", Map.class);
java.util.List<String> args = mcp.getBeanList("args", String.class);
if (command == null || env == null || args == null) {
return null;
}
return new McpTransportConfig.Stdio(timeout, command, env, args);
}
if (matchesKeys(keys, Set.of("uri", "endpoint", "headers"), Set.of("timeout"))) {
String uri = mcp.getStr("uri");
String endpoint = mcp.getStr("endpoint");
Map<String, String> headers = mcp.getBean("headers", Map.class);
if (uri == null || endpoint == null || headers == null) {
return null;
}
return new McpTransportConfig.Http(timeout, uri, endpoint, headers);
}
if (matchesKeys(keys, Set.of("url"), Set.of("timeout"))) {
String url = mcp.getStr("url");
if (url == null) {
return null;
}
return new McpTransportConfig.Http(timeout, url, "", Map.of());
}
return null;
}
private boolean matchesKeys(Set<String> actualKeys, Set<String> requiredKeys, Set<String> optionalKeys) {
if (!actualKeys.containsAll(requiredKeys)) {
return false;
}
Set<String> allowedKeys = new HashSet<>(requiredKeys);
allowedKeys.addAll(optionalKeys);
return allowedKeys.containsAll(actualKeys);
}
private void checkAndReload(boolean trustCache) {
HashMap<String, McpTransportConfig> changedMap = new HashMap<>();
HashSet<String> existingMcpIdSet = new HashSet<>();
File[] files = loadFiles(root);
if (files == null) {
return;
}
for (File file : files) {
if (!normalFile(file)) {
continue;
}
McpConfigFileRecord fileRecord = mcpConfigFileCache.get(file);
boolean fileRecordExists = fileRecord != null;
if (fileRecordExists && !fileChanged(file, fileRecord) && trustCache) {
existingMcpIdSet.addAll(fileRecord.paramsCacheMap().keySet());
continue;
}
cn.hutool.json.JSONObject mcpConfigJson = readJson(file);
if (mcpConfigJson == null) {
if (fileRecordExists) {
existingMcpIdSet.addAll(fileRecord.paramsCacheMap().keySet());
}
continue;
}
McpConfigFileRecord newFileRecord = new McpConfigFileRecord(file.lastModified(), file.length(), new HashMap<>());
for (String id : mcpConfigJson.keySet()) {
cn.hutool.json.JSONObject mcp = readMcp(mcpConfigJson, id);
if (mcp == null) {
continue;
}
McpTransportConfig params = readParams(mcp);
if (params == null) {
continue;
}
existingMcpIdSet.add(id);
newFileRecord.paramsCacheMap().put(id, params);
if (fileRecordExists) {
McpTransportConfig paramsCache = fileRecord.paramsCacheMap().get(id);
if (paramsCache != null && paramsCache.equals(params)) {
continue;
}
}
changedMap.put(id, params);
}
mcpConfigFileCache.put(file, newFileRecord);
}
updateMcpClients(changedMap, existingMcpIdSet);
}
private void updateMcpClients(HashMap<String, McpTransportConfig> changedMap, HashSet<String> existingMcpIdSet) {
changedMap.forEach((clientId, config) -> {
unregisterClient(clientId);
registerMcpClient(clientId, config);
});
for (String clientId : mcpClientRegistry.listIds()) {
if (clientId.equals(LocalRunnerClient.MCP_NAME_DESC) || clientId.equals(LocalRunnerClient.MCP_NAME_DYNAMIC)) {
continue;
}
if (!existingMcpIdSet.contains(clientId)) {
unregisterClient(clientId);
}
}
existedMetaActions.keySet().removeIf(actionKey -> {
String serverId = actionKey.split("::")[0];
return !serverId.equals("local")
&& !serverId.equals(LocalRunnerClient.MCP_NAME_DESC)
&& !serverId.equals(LocalRunnerClient.MCP_NAME_DYNAMIC)
&& !existingMcpIdSet.contains(serverId);
});
}
private boolean fileChanged(File file, McpConfigFileRecord fileRecord) {
return fileRecord.lastModified() != file.lastModified() || fileRecord.length() != file.length();
}
private void loadAndRegisterMcpClientsFromFile(File file) {
cn.hutool.json.JSONObject mcpConfigJson = readJson(file);
if (mcpConfigJson == null) {
return;
}
McpConfigFileRecord newFileRecord = new McpConfigFileRecord(file.lastModified(), file.length());
for (String id : mcpConfigJson.keySet()) {
cn.hutool.json.JSONObject mcp = readMcp(mcpConfigJson, id);
if (mcp == null) {
continue;
}
McpTransportConfig params = readParams(mcp);
if (params == null) {
continue;
}
registerMcpClient(id, params);
newFileRecord.paramsCacheMap().put(id, params);
}
mcpConfigFileCache.put(file, newFileRecord);
}
private File[] loadFiles(Path path) {
if (!path.toFile().isDirectory()) {
return null;
}
return path.toFile().listFiles();
}
@Override
public void close() throws Exception {
watchSupport.close();
}
@Override
public void onPolicyChanged(@NotNull ExecutionPolicy policy) {
checkAndReload(false);
}
private record McpConfigFileRecord(long lastModified, long length, Map<String, McpTransportConfig> paramsCacheMap) {
private McpConfigFileRecord(long lastModified, long length) {
this(lastModified, length, new HashMap<>());
}
}
}
@@ -1,53 +0,0 @@
package work.slhaf.partner.core.action.runner.mcp;
import lombok.extern.slf4j.Slf4j;
import work.slhaf.partner.framework.agent.support.DirectoryWatchSupport;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.ExecutorService;
@Slf4j
public class McpDescWatcher implements AutoCloseable {
private final Path root;
private final McpMetaRegistry mcpMetaRegistry;
private final DirectoryWatchSupport watchSupport;
public McpDescWatcher(Path root, McpMetaRegistry mcpMetaRegistry, ExecutorService executor) throws IOException {
this.root = root;
this.mcpMetaRegistry = mcpMetaRegistry;
this.watchSupport = new DirectoryWatchSupport(new DirectoryWatchSupport.Context(root), executor, 0, () -> mcpMetaRegistry.loadDirectory(root))
.onCreate(this::handleUpsert)
.onModify(this::handleUpsert)
.onDelete(this::handleDelete)
.onOverflow((thisDir, context) -> mcpMetaRegistry.reconcile(root));
}
public void start() {
watchSupport.start();
log.info("DescMcp 文件监听注册完毕");
}
private void handleUpsert(Path thisDir, Path context) {
if (context == null || Files.isDirectory(context) || !mcpMetaRegistry.isValidDescFile(context.getFileName().toString())) {
return;
}
if (!mcpMetaRegistry.addOrUpdate(context)) {
mcpMetaRegistry.remove(context);
}
}
private void handleDelete(Path thisDir, Path context) {
if (context == null || !mcpMetaRegistry.isValidDescFile(context.getFileName().toString())) {
return;
}
mcpMetaRegistry.remove(context);
}
@Override
public void close() throws Exception {
watchSupport.close();
}
}
@@ -1,260 +0,0 @@
package work.slhaf.partner.core.action.runner.mcp;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.server.McpStatelessAsyncServer;
import io.modelcontextprotocol.server.McpStatelessServerFeatures;
import io.modelcontextprotocol.spec.McpSchema;
import javassist.NotFoundException;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import reactor.core.publisher.Mono;
import work.slhaf.partner.core.action.entity.MetaActionInfo;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.BiFunction;
@Slf4j
public class McpMetaRegistry implements AutoCloseable {
private final ConcurrentHashMap<String, MetaActionInfo> existedMetaActions;
private final ConcurrentHashMap<String, String> descCache = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, MetaActionInfo> descInfoCache = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, MetaActionInfo> originalInfoCache = new ConcurrentHashMap<>();
private final McpStatelessAsyncServer descServer;
private final InProcessMcpTransport clientTransport;
public McpMetaRegistry(ConcurrentHashMap<String, MetaActionInfo> existedMetaActions) {
this.existedMetaActions = existedMetaActions;
InProcessMcpTransport.Pair pair = InProcessMcpTransport.pair();
this.clientTransport = pair.clientSide();
McpSchema.ServerCapabilities serverCapabilities = McpSchema.ServerCapabilities.builder()
.resources(true, true)
.build();
this.descServer = McpServer.async(pair.serverSide())
.capabilities(serverCapabilities)
.jsonMapper(McpJsonMapper.getDefault())
.build();
}
public McpTransportConfig.InProcess clientConfig(String serverName, int timeout) {
return new McpTransportConfig.InProcess(timeout, clientTransport);
}
public void loadDirectory(Path root) {
File[] files = loadFiles(root);
if (files == null) {
return;
}
for (File file : files) {
addOrUpdate(file);
}
}
public boolean addOrUpdate(Path path) {
return addOrUpdate(path.toFile());
}
public boolean addOrUpdate(File file) {
String name = file.getName();
if (!isValidDescFile(name)) {
return false;
}
try {
MetaActionInfo info = JSON.parseObject(Files.readString(file.toPath(), StandardCharsets.UTF_8), MetaActionInfo.class);
String uri = file.toPath().toUri().toString();
descCache.put(uri, JSONObject.toJSONString(info));
String actionKey = name.replace(".desc.json", "");
descInfoCache.put(actionKey, copyMetaActionInfo(info));
descServer.addResource(buildAsyncResourceSpecification(name, uri)).block();
if (existedMetaActions.containsKey(actionKey)) {
existedMetaActions.put(actionKey, mergeWithOriginal(actionKey, info));
}
return true;
} catch (Exception e) {
log.error("desc.json 解析失败: {}", file.getAbsolutePath());
return false;
}
}
public void remove(Path path) {
String uri = path.toUri().toString();
String actionKey = path.getFileName().toString().replace(".desc.json", "");
descCache.remove(uri);
descInfoCache.remove(actionKey);
descServer.removeResource(uri).block();
MetaActionInfo originalInfo = originalInfoCache.get(actionKey);
if (originalInfo != null) {
existedMetaActions.put(actionKey, copyMetaActionInfo(originalInfo));
return;
}
MetaActionInfo info = existedMetaActions.get(actionKey);
if (info != null) {
existedMetaActions.put(actionKey, resetMetaActionInfo(info));
}
}
public void reconcile(Path root) {
File[] files = loadFiles(root);
if (files == null) {
return;
}
Set<String> currentUris = ConcurrentHashMap.newKeySet();
for (File file : files) {
if (!isValidDescFile(file.getName())) {
continue;
}
currentUris.add(file.toURI().toString());
if (!addOrUpdate(file)) {
remove(file.toPath());
}
}
List<String> serverUris = descServer.listResources()
.map(McpSchema.Resource::uri)
.collectList()
.block();
if (serverUris == null) {
log.error("无法获取 DescMcpServer 持有的资源列表");
return;
}
for (String uri : serverUris) {
if (!currentUris.contains(uri)) {
remove(Path.of(java.net.URI.create(uri)));
}
}
}
public MetaActionInfo buildMetaActionInfo(String serverId, McpSchema.Tool tool) {
String actionKey = serverId + "::" + tool.name();
MetaActionInfo baseInfo = buildToolMetaActionInfo(tool);
originalInfoCache.put(actionKey, copyMetaActionInfo(baseInfo));
MetaActionInfo override = descInfoCache.get(actionKey);
return override == null ? baseInfo : mergeWithOriginal(actionKey, override);
}
private McpStatelessServerFeatures.AsyncResourceSpecification buildAsyncResourceSpecification(String name, String uri) {
McpSchema.Resource resource = McpSchema.Resource.builder()
.name(name)
.title(name)
.description("Action descriptor for " + name)
.mimeType("application/json")
.uri(uri)
.build();
BiFunction<McpTransportContext, McpSchema.ReadResourceRequest, Mono<McpSchema.ReadResourceResult>> readHandler = (context, request) -> {
String result = descCache.get(request.uri());
if (result == null) {
return Mono.error(new NotFoundException("未找到 Resource: " + request.uri()));
}
return Mono.just(new McpSchema.ReadResourceResult(List.of(
new McpSchema.TextResourceContents(request.uri(), "application/json", result)
)));
};
return new McpStatelessServerFeatures.AsyncResourceSpecification(resource, readHandler);
}
public boolean isValidDescFile(String fileName) {
return fileName.endsWith(".desc.json") && fileName.contains("::");
}
private File[] loadFiles(Path root) {
if (!Files.isDirectory(root)) {
return null;
}
return root.toFile().listFiles();
}
private MetaActionInfo resetMetaActionInfo(@NotNull MetaActionInfo info) {
return new MetaActionInfo(
false,
info.getLauncher(),
copyParams(info.getParams()),
info.getDescription(),
new LinkedHashSet<>(),
new LinkedHashSet<>(),
new LinkedHashSet<>(),
false,
copyResponseSchema(info.getResponseSchema())
);
}
@Override
public void close() {
descServer.close();
}
private MetaActionInfo buildToolMetaActionInfo(McpSchema.Tool tool) {
Map<String, Object> outputSchema = tool.outputSchema();
boolean io = false;
Set<String> preActions = new LinkedHashSet<>();
Set<String> postActions = new LinkedHashSet<>();
boolean strictDependencies = false;
Set<String> tags = new LinkedHashSet<>();
Map<String, Object> meta = tool.meta();
if (meta != null) {
JSONObject metaJson = JSONObject.from(meta);
io = Boolean.TRUE.equals(metaJson.getBoolean("io"));
preActions = toOrderedSet(metaJson.getList("pre", String.class));
postActions = toOrderedSet(metaJson.getList("post", String.class));
strictDependencies = Boolean.TRUE.equals(metaJson.getBoolean("strict"));
tags = toOrderedSet(metaJson.getList("tag", String.class));
}
return new MetaActionInfo(
io,
null,
copyParams(tool.inputSchema().properties()),
tool.description(),
tags,
preActions,
postActions,
strictDependencies,
outputSchema == null ? JSONObject.of() : JSONObject.from(outputSchema)
);
}
private MetaActionInfo mergeWithOriginal(String actionKey, MetaActionInfo override) {
MetaActionInfo original = originalInfoCache.get(actionKey);
return override == null ? copyMetaActionInfo(original) : copyMetaActionInfo(override);
}
private MetaActionInfo copyMetaActionInfo(MetaActionInfo source) {
if (source == null) {
return null;
}
return new MetaActionInfo(
source.getIo(),
source.getLauncher(),
copyParams(source.getParams()),
source.getDescription(),
toOrderedSet(source.getTags()),
toOrderedSet(source.getPreActions()),
toOrderedSet(source.getPostActions()),
source.getStrictDependencies(),
copyResponseSchema(source.getResponseSchema())
);
}
private <T> LinkedHashSet<T> toOrderedSet(Collection<T> source) {
return source == null ? new LinkedHashSet<>() : new LinkedHashSet<>(source);
}
private Map<String, String> copyParams(Map<String, ?> params) {
if (params == null) {
return null;
}
Map<String, String> copied = new LinkedHashMap<>();
params.forEach((key, value) -> copied.put(key, value == null ? null : String.valueOf(value)));
return copied;
}
private JSONObject copyResponseSchema(JSONObject responseSchema) {
return responseSchema == null ? JSONObject.of() : JSONObject.from(responseSchema);
}
}
@@ -1,20 +0,0 @@
package work.slhaf.partner.core.action.runner.mcp;
import java.util.List;
import java.util.Map;
public sealed interface McpTransportConfig permits McpTransportConfig.Http, McpTransportConfig.Stdio, McpTransportConfig.InProcess {
int timeout();
record Http(int timeout, String baseUri, String endpoint,
Map<String, String> headers) implements McpTransportConfig {
}
record Stdio(int timeout, String command, Map<String, String> env,
List<String> args) implements McpTransportConfig {
}
record InProcess(int timeout, InProcessMcpTransport clientTransport) implements McpTransportConfig {
}
}
@@ -1,44 +0,0 @@
package work.slhaf.partner.core.action.runner.mcp;
import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport;
import io.modelcontextprotocol.client.transport.ServerParameters;
import io.modelcontextprotocol.client.transport.StdioClientTransport;
import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.spec.McpClientTransport;
import work.slhaf.partner.core.action.runner.policy.ExecutionPolicyRegistry;
import work.slhaf.partner.core.action.runner.policy.WrappedLaunchSpec;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class McpTransportFactory {
public McpClientTransport create(McpTransportConfig config) {
return switch (config) {
case McpTransportConfig.Stdio stdio -> {
List<String> commands = new ArrayList<>();
commands.add(stdio.command());
commands.addAll(stdio.args());
WrappedLaunchSpec wrapped = ExecutionPolicyRegistry.INSTANCE.prepare(commands);
Map<String, String> env = new HashMap<>(stdio.env());
env.putAll(wrapped.getEnvironment());
ServerParameters serverParameters = ServerParameters.builder(wrapped.getCommand())
.args(wrapped.getArgs())
.env(env)
.build();
yield new StdioClientTransport(serverParameters, McpJsonMapper.getDefault());
}
case McpTransportConfig.Http http -> {
McpSyncHttpClientRequestCustomizer customizer = (builder, method, endpoint, body, context) -> http.headers().forEach(builder::setHeader);
yield HttpClientSseClientTransport.builder(http.baseUri())
.httpRequestCustomizer(customizer)
.sseEndpoint(http.endpoint())
.build();
}
case McpTransportConfig.InProcess inProcess -> inProcess.clientTransport();
};
}
}
@@ -1,82 +0,0 @@
package work.slhaf.partner.core.action.runner.policy
import work.slhaf.partner.core.action.exception.ActionInfrastructureStartupException
private const val BWRAP_COMMAND = "bwrap"
object BwrapPolicyProvider : PolicyProvider(
policyName = "bwrap"
) {
init {
requireBwrapAvailable()
}
override fun prepare(
policy: ExecutionPolicy,
commands: List<String>
): WrappedLaunchSpec {
val (command, args) = splitCommands(commands)
val wrappedArgs = buildList {
add("--ro-bind")
add("/")
add("/")
add("--proc")
add("/proc")
add("--dev")
add("/dev")
if (policy.net == ExecutionPolicy.Network.DISABLE) {
add("--unshare-net")
}
if (!policy.workingDirectory.isNullOrBlank()) {
add("--chdir")
add(policy.workingDirectory)
}
policy.readOnlyPaths.forEach { path ->
add("--ro-bind")
add(path)
add(path)
}
policy.writablePaths.forEach { path ->
add("--bind")
add(path)
add(path)
}
add("--")
add(command)
addAll(args)
}
return WrappedLaunchSpec(
command = BWRAP_COMMAND,
args = wrappedArgs,
workingDirectory = policy.workingDirectory,
environment = resolveEnvironment(policy)
)
}
private fun requireBwrapAvailable() {
val available = try {
val process = ProcessBuilder(BWRAP_COMMAND, "--version")
.redirectErrorStream(true)
.start()
val exitCode = process.waitFor()
exitCode == 0
} catch (e: Exception) {
throw ActionInfrastructureStartupException(
"Failed to detect executable command '$BWRAP_COMMAND'",
"bwrap-policy-provider",
null,
BWRAP_COMMAND,
e
)
}
if (!available) {
throw ActionInfrastructureStartupException(
"Executable command '$BWRAP_COMMAND' is not available",
"bwrap-policy-provider",
null,
BWRAP_COMMAND
)
}
}
}
@@ -1,20 +0,0 @@
package work.slhaf.partner.core.action.runner.policy
object DirectPolicyProvider : PolicyProvider(
policyName = "direct"
) {
override fun prepare(
policy: ExecutionPolicy,
commands: List<String>
): WrappedLaunchSpec {
val (command, args) = splitCommands(commands)
return WrappedLaunchSpec(
command = command,
args = args,
workingDirectory = policy.workingDirectory,
environment = resolveEnvironment(policy)
)
}
}
@@ -1,157 +0,0 @@
package work.slhaf.partner.core.action.runner.policy
import com.alibaba.fastjson2.JSONObject
import work.slhaf.partner.framework.agent.config.Config
import work.slhaf.partner.framework.agent.config.ConfigRegistration
import work.slhaf.partner.framework.agent.config.Configurable
import java.nio.file.Path
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArraySet
object ExecutionPolicyRegistry : Configurable, ConfigRegistration<ExecutionPolicy> {
private const val DEFAULT_PROVIDER = "direct"
private val policyProviders = ConcurrentHashMap<String, PolicyProvider>().apply {
put(DEFAULT_PROVIDER, DirectPolicyProvider)
}
private val listeners = CopyOnWriteArraySet<RunnerExecutionPolicyListener>()
init {
register()
}
@Volatile
private lateinit var currentPolicy: ExecutionPolicy
fun prepare(commands: List<String>): WrappedLaunchSpec {
val policy = currentPolicy
val provider = policyProviders[policy.provider]
?: policyProviders[DEFAULT_PROVIDER]
?: error("Default provider '${DEFAULT_PROVIDER}' is not registered")
return provider.prepare(policy, commands)
}
fun updatePolicy(policy: ExecutionPolicy) {
currentPolicy = policy
listeners.forEach { it.onPolicyChanged(policy) }
}
fun addListener(listener: RunnerExecutionPolicyListener) {
listeners += listener
}
fun removeListener(listener: RunnerExecutionPolicyListener) {
listeners -= listener
}
fun registerPolicyProvider(policyProvider: PolicyProvider) {
val name = policyProvider.policyName
if (policyProviders.containsKey(name)) {
return
}
policyProviders[name] = policyProvider
}
override fun declare(): Map<Path, ConfigRegistration<out Config>> {
return mapOf(Path.of("action", "runner_policy.json") to this)
}
override fun type(): Class<ExecutionPolicy> {
return ExecutionPolicy::class.java
}
override fun init(
config: ExecutionPolicy,
json: JSONObject?
) {
this.currentPolicy = config
}
override fun defaultConfig(): ExecutionPolicy {
return ExecutionPolicy(
provider = "direct",
mode = ExecutionPolicy.Mode.DIRECT,
net = ExecutionPolicy.Network.ENABLE,
inheritEnv = true,
env = emptyMap(),
workingDirectory = null,
readOnlyPaths = emptySet(),
writablePaths = emptySet(),
)
}
override fun onReload(
config: ExecutionPolicy,
json: JSONObject?
) {
this.currentPolicy = config
}
}
data class ExecutionPolicy(
val mode: Mode,
val provider: String,
val net: Network,
val inheritEnv: Boolean,
val env: Map<String, String>,
val workingDirectory: String?,
val readOnlyPaths: Set<String>,
val writablePaths: Set<String>,
) : Config() {
enum class Mode {
DIRECT,
SANDBOX
}
enum class Network {
DISABLE,
ENABLE
}
}
data class WrappedLaunchSpec(
val command: String,
val args: List<String>,
val workingDirectory: String? = null,
val environment: Map<String, String> = emptyMap()
)
abstract class PolicyProvider(
val policyName: String
) {
abstract fun prepare(
policy: ExecutionPolicy,
commands: List<String>
): WrappedLaunchSpec
protected fun resolveEnvironment(policy: ExecutionPolicy): Map<String, String> {
val result = LinkedHashMap<String, String>()
if (policy.inheritEnv) {
result.putAll(System.getenv())
}
result.putAll(policy.env)
return result
}
protected fun splitCommands(commands: List<String>): Pair<String, List<String>> {
require(commands.isNotEmpty()) { "commands must not be empty" }
return commands.first() to commands.drop(1)
}
}
interface RunnerExecutionPolicyListener {
fun onPolicyChanged(policy: ExecutionPolicy)
fun registerPolicyListener() {
ExecutionPolicyRegistry.addListener(this)
}
fun unregisterPolicyListener() {
ExecutionPolicyRegistry.removeListener(this)
}
}
@@ -1,131 +0,0 @@
package work.slhaf.partner.core.action.runner.support;
import com.alibaba.fastjson2.JSONObject;
import lombok.extern.slf4j.Slf4j;
import lombok.val;
import org.jetbrains.annotations.NotNull;
import work.slhaf.partner.core.action.entity.ActionFileMetaData;
import work.slhaf.partner.core.action.entity.MetaAction;
import work.slhaf.partner.core.action.entity.MetaActionInfo;
import work.slhaf.partner.core.action.exception.ActionSerializationException;
import java.io.File;
import java.io.IOException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
@Slf4j
public class ActionSerializer {
private final String tmpActionPath;
private final String dynamicActionPath;
public ActionSerializer(String tmpActionPath, String dynamicActionPath) {
this.tmpActionPath = tmpActionPath;
this.dynamicActionPath = dynamicActionPath;
}
public static String normalizeCodeType(String codeType) {
if (codeType == null || codeType.isBlank()) {
throw new IllegalArgumentException("codeType 不能为空");
}
return codeType.startsWith(".") ? codeType : "." + codeType;
}
private static @NotNull Path createActionDir(String baseName, Path baseDir, String fileExt) {
for (int i = 0; ; i++) {
String dirName = i == 0 ? baseName : baseName + "(" + i + ")";
Path candidate = baseDir.resolve(dirName);
try {
Files.createDirectory(candidate);
return candidate;
} catch (FileAlreadyExistsException ignored) {
} catch (IOException e) {
throw new ActionSerializationException(
"Failed to create action directory: " + candidate.toAbsolutePath(),
baseName,
baseDir.toAbsolutePath().toString(),
fileExt,
"CREATE_DIRECTORY",
e
);
}
}
}
public String buildTmpPath(String actionKey, String codeType) {
return Path.of(tmpActionPath, System.currentTimeMillis() + "-" + actionKey + normalizeCodeType(codeType)).toString();
}
public void tmpSerialize(MetaAction tempAction, String code, String codeType) throws IOException {
log.debug("行动程序临时序列化: {}", tempAction);
Path path = Path.of(tempAction.getLocation());
validateTmpLocation(path, codeType);
File file = path.toFile();
file.createNewFile();
Files.writeString(path, code);
log.debug("临时序列化完毕");
}
private void validateTmpLocation(Path path, String codeType) throws IOException {
String normalizedCodeType = normalizeCodeType(codeType);
String fileName = path.getFileName().toString();
if (!fileName.endsWith(normalizedCodeType)) {
throw new IOException("临时文件路径与 codeType 不匹配: " + path);
}
}
public void persistSerialize(MetaActionInfo metaActionInfo, ActionFileMetaData fileMetaData) {
log.debug("行动程序持久序列化: {}", metaActionInfo);
val baseDir = Path.of(dynamicActionPath);
if (!Files.isDirectory(baseDir)) {
throw new ActionSerializationException(
"Action base directory is not available: " + baseDir.toAbsolutePath(),
fileMetaData.getName(),
baseDir.toAbsolutePath().toString(),
fileMetaData.getExt(),
"VALIDATE_BASE_DIR"
);
}
val actionDir = createActionDir(fileMetaData.getName(), baseDir, fileMetaData.getExt());
val runTmp = actionDir.resolve("run." + fileMetaData.getExt() + ".tmp");
val descTmp = actionDir.resolve("desc.json.tmp");
val runFinal = actionDir.resolve("run." + fileMetaData.getExt());
val descFinal = actionDir.resolve("desc.json");
try {
Files.writeString(runTmp, fileMetaData.getContent());
Files.writeString(descTmp, JSONObject.toJSONString(metaActionInfo));
Files.move(runTmp, runFinal, StandardCopyOption.ATOMIC_MOVE);
Files.move(descTmp, descFinal, StandardCopyOption.ATOMIC_MOVE);
} catch (IOException e) {
safeDelete(runTmp);
safeDelete(descTmp);
safeDelete(runFinal);
safeDelete(descFinal);
safeDelete(actionDir);
throw new ActionSerializationException(
"Failed to persist action files",
fileMetaData.getName(),
baseDir.toAbsolutePath().toString(),
fileMetaData.getExt(),
"WRITE_FILES",
e
);
}
log.debug("持久序列化结束");
}
private void safeDelete(Path path) {
try {
if (Files.exists(path)) {
Files.delete(path);
}
} catch (IOException ignored) {
}
}
}
@@ -1,85 +0,0 @@
package work.slhaf.partner.core.cognition;
import org.w3c.dom.Element;
import work.slhaf.partner.core.cognition.context.ContextWorkspace;
import work.slhaf.partner.core.cognition.impression.ActiveEntity;
import work.slhaf.partner.core.cognition.impression.Entity;
import work.slhaf.partner.framework.agent.factory.capability.annotation.Capability;
import work.slhaf.partner.framework.agent.model.pojo.Message;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.locks.Lock;
@Capability("cognition")
public interface CognitionCapability {
void initiateTurn(String input, String target, String... skippedModules);
ContextWorkspace contextWorkspace();
List<Message> getChatMessages();
List<Message> snapshotChatMessages();
void rollChatMessagesWithSnapshot(int snapshotSize, int retainDivisor);
void refreshRecentChatMessagesContext();
Element messageNotesElement();
Lock getMessageLock();
/**
* Project user input onto known or currently active entities and append the input as runtime evidence.
*/
Set<ActiveEntity> projectEntity(String input);
/**
* Return current active entities with their bound known entities when available.
* ActiveEntity values are snapshots; Entity values are live known-entity references and should be updated through this capability.
*/
Map<ActiveEntity, Entity> showEntities();
/**
* Create and register a new known entity by subject, then refresh search indexes for it.
*/
Entity createEntity(String subject);
/**
* Return a known entity by uuid, or null when it does not exist.
*/
Entity getEntity(String uuid);
/**
* Bind an active runtime entity to a known entity and refresh the active-entity search document.
*/
boolean bindActiveEntity(String runtimeId, String entityUuid);
/**
* Rename the canonical subject of a known entity and refresh entity/active-entity indexes.
*/
boolean renameEntitySubject(String entityUuid, String newSubject);
/**
* Add an alias or mention form for a known entity and refresh entity indexes.
*/
boolean addEntityAlias(String entityUuid, String alias, boolean deprecated);
/**
* Add or replace an impression on a known entity and refresh all entity indexes.
*/
boolean updateEntityImpression(String entityUuid, String impression, String newImpression, double confidence);
/**
* Add or replace a stable feature on a known entity and refresh all entity indexes.
*/
boolean updateEntityFeature(String entityUuid, String feature, String newFeature, double confidence);
/**
* Add or update a relation from one known entity to another target and refresh all entity indexes.
*/
boolean updateEntityRelation(String entityUuid, String target, String relation, double strength);
}
@@ -1,213 +0,0 @@
package work.slhaf.partner.core.cognition.context;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import kotlin.Unit;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import work.slhaf.partner.common.base.Block;
import work.slhaf.partner.framework.agent.factory.capability.annotation.CapabilityCore;
import work.slhaf.partner.framework.agent.factory.capability.annotation.CapabilityMethod;
import work.slhaf.partner.framework.agent.interaction.AgentRuntime;
import work.slhaf.partner.framework.agent.model.pojo.Message;
import work.slhaf.partner.framework.agent.state.State;
import work.slhaf.partner.framework.agent.state.StateSerializable;
import work.slhaf.partner.framework.agent.state.StateValue;
import work.slhaf.partner.runtime.PartnerRunningFlowContext;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
@Slf4j
@CapabilityCore(value = "cognition")
public class ContextCore implements StateSerializable {
private static final String RECENT_CHAT_MESSAGE_NOTES = """
消息格式:
- 所有消息统一写为“标记行 + 空行 + 正文”,比如:
[[AGENT]: self]: [NOT_REPLIED][COMPRESSED]:
正文内容
- 标记行一定包含身份标签,通常格式为 [[USER]: <userName>] 或 [[AGENT]: self]
- 若身份标签提取失败,可能回退为 [[Unknown]: Unknown]
- 若存在其他标签,则写为“身份标签: 状态标签串:”
- 正文永远从空行后开始
标记含义:
- [USER]: 外部用户来源
- [AGENT]: 系统内部来源
- [NOT_REPLIED]: 仅保留在历史中的未直接回复结果
- [COMPRESSED]: 该消息正文经过压缩
""";
private final ReentrantLock messageLock = new ReentrantLock();
/**
* 主模型的聊天记录
*/
private List<Message> chatMessages = new ArrayList<>();
private final ContextWorkspace contextWorkspace = new ContextWorkspace();
public ContextCore() {
register();
}
@CapabilityMethod
public ContextWorkspace contextWorkspace() {
return contextWorkspace;
}
@CapabilityMethod
public void initiateTurn(String input, String target, String... skippedModules) {
PartnerRunningFlowContext primaryContext = PartnerRunningFlowContext.fromSelf(input);
primaryContext.setTarget(target);
if (skippedModules != null) {
for (String skippedModule : skippedModules) {
primaryContext.addSkippedModule(skippedModule);
}
}
AgentRuntime.INSTANCE.submit(primaryContext);
}
@CapabilityMethod
public List<Message> getChatMessages() {
return chatMessages;
}
@CapabilityMethod
public List<Message> snapshotChatMessages() {
messageLock.lock();
try {
return List.copyOf(chatMessages);
} finally {
messageLock.unlock();
}
}
@CapabilityMethod
public void rollChatMessagesWithSnapshot(int snapshotSize, int retainDivisor) {
messageLock.lock();
try {
int safeSnapshotSize = Math.clamp(snapshotSize, 0, chatMessages.size());
if (safeSnapshotSize == 0) {
return;
}
int safeDivisor = Math.max(retainDivisor, 1);
int retainCount = safeSnapshotSize / safeDivisor;
int retainStart = Math.max(0, safeSnapshotSize - retainCount);
List<Message> rolled = new ArrayList<>(chatMessages.subList(retainStart, safeSnapshotSize));
if (chatMessages.size() > safeSnapshotSize) {
rolled.addAll(chatMessages.subList(safeSnapshotSize, chatMessages.size()));
}
chatMessages = rolled;
} finally {
messageLock.unlock();
}
}
@CapabilityMethod
public Lock getMessageLock() {
return messageLock;
}
@CapabilityMethod
public void refreshRecentChatMessagesContext() {
ContextBlock block = new ContextBlock(
new BlockContent("recent_chat_messages", "communication_producer") {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
root.appendChild(document.importNode(messageNotesElement(), true));
Element chatMessagesElement = document.createElement("chat_messages");
root.appendChild(chatMessagesElement);
appendRepeatedElements(document, chatMessagesElement, "chat_message", resolveRecentChatMessages(), (messageElement, message) -> {
messageElement.setAttribute("role", message.roleValue());
messageElement.setTextContent(message.getContent());
return Unit.INSTANCE;
});
}
},
Set.of(ContextBlock.FocusedDomain.COMMUNICATION),
100,
10,
4
);
contextWorkspace.register(block);
}
@CapabilityMethod
public Element messageNotesElement() {
return new Block("message_tag_notes") {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
root.setTextContent(RECENT_CHAT_MESSAGE_NOTES);
}
}.encodeToXml();
}
private List<Message> resolveRecentChatMessages() {
int exclusiveEnd = Math.max(chatMessages.size() - 1, 0);
if (exclusiveEnd == 0) {
return List.of();
}
int start = Math.max(exclusiveEnd - 6, 0);
return chatMessages.subList(start, exclusiveEnd);
}
@Override
public @NotNull Path statePath() {
return Path.of("core", "cognition.json");
}
@Override
public void load(@NotNull JSONObject state) {
JSONArray messageArray = state.getJSONArray("chat_messages");
if (messageArray == null) {
log.warn("chat_messages is missing");
return;
}
for (int i = 0; i < messageArray.size(); i++) {
JSONObject messageObject = messageArray.getJSONObject(i);
if (messageObject == null) {
continue;
}
String role = messageObject.getString("role");
String content = messageObject.getString("content");
if (role == null || content == null) {
continue;
}
this.chatMessages.add(new Message(Message.Character.fromValue(role), content));
}
refreshRecentChatMessagesContext();
}
@Override
public @NotNull State convert() {
State state = new State();
List<StateValue.Obj> convertedMessageList = chatMessages.stream()
.map(message -> StateValue.obj(Map.of(
"role", message.roleValue(),
"content", message.getContent()
)))
.toList();
state.append("chat_messages", StateValue.arr(convertedMessageList));
return state;
}
}
@@ -1,401 +0,0 @@
package work.slhaf.partner.core.cognition.context
import com.alibaba.fastjson2.JSONObject
import org.w3c.dom.Document
import org.w3c.dom.Element
import work.slhaf.partner.common.base.Block
import work.slhaf.partner.framework.agent.log.TraceEvent
import work.slhaf.partner.framework.agent.log.TraceRecorder
import java.time.Duration
import java.time.Instant
import java.util.*
import java.util.concurrent.locks.ReentrantReadWriteLock
import kotlin.concurrent.write
import kotlin.math.max
import kotlin.math.min
class ContextWorkspace {
private val stateSet = mutableSetOf<ContextBlock>()
private val lock = ReentrantReadWriteLock()
/**
* 根据传入的 [ContextBlock.FocusedDomain] 列表,获取上下文块
* @param domains 需要获取上下文的域列表,顺序将决定权重优先级,按照列表排序将具备线性权重分层,最终反映到 blockContent 列表的排序上
*/
fun resolve(domains: List<ContextBlock.FocusedDomain>): ResolvedContext = lock.write {
if (domains.isEmpty()) {
return@write ResolvedContext(emptyList())
}
val primaryDomain = domains.first()
val domainWeights = domains
.distinct()
.withIndex()
.associate { (index, domain) -> domain to (domains.size - index) }
val activeBlocks = mutableListOf<ResolvedContextBlock>()
val iterator = stateSet.iterator()
while (iterator.hasNext()) {
val block = iterator.next()
val fadedScore = block.applyTimeFade()
if (fadedScore <= 0.0) {
iterator.remove()
continue
}
val matchedDomains = block.focusedOn.intersect(domainWeights.keys)
if (matchedDomains.isEmpty()) {
continue
}
val exposure = if (primaryDomain in matchedDomains) {
ContextBlock.Exposure.PRIMARY
} else {
ContextBlock.Exposure.SECONDARY
}
val activationScore = block.activate(exposure)
if (activationScore <= 0.0) {
iterator.remove()
continue
}
activeBlocks += ResolvedContextBlock(
block = block,
domainWeight = matchedDomains.sumOf { domainWeights.getValue(it) },
activationScore = activationScore,
renderedBlock = block.render(exposure)
)
}
val blocks = activeBlocks
.sortedWith(
compareByDescending<ResolvedContextBlock> { it.domainWeight }
.thenByDescending { it.activationScore }
.thenBy { it.block.sourceKey.blockName }
.thenBy { it.block.sourceKey.source }
)
.groupBy { it.block.sourceKey }
.values
.map { groupedBlocks ->
if (groupedBlocks.size == 1) {
renderResolvedBlock(groupedBlocks.first())
} else {
AggregatedBlockContent(groupedBlocks)
}
}
ResolvedContext(blocks)
}
private fun renderResolvedBlock(resolved: ResolvedContextBlock): BlockContent {
return resolved.renderedBlock
}
/**
* @param contextBlock 注册的新上下文块
*/
fun register(contextBlock: ContextBlock) = lock.write {
val changedSourceKeys = linkedSetOf<ContextBlock.SourceKey>()
val iterator = stateSet.iterator()
while (iterator.hasNext()) {
val currentBlock = iterator.next()
if (!currentBlock.sameWith(contextBlock)) {
continue
}
if (currentBlock.applyReplaceFade() <= 0.0) {
iterator.remove()
changedSourceKeys.add(currentBlock.sourceKey)
}
}
stateSet += contextBlock
changedSourceKeys.add(contextBlock.sourceKey)
record("register", changedSourceKeys)
}
fun expire(blockName: String, source: String) = lock.write {
val sourceKey = ContextBlock.SourceKey(blockName, source)
val changedSourceKeys = linkedSetOf<ContextBlock.SourceKey>()
val iterator = stateSet.iterator()
while (iterator.hasNext()) {
val block = iterator.next()
if (block.sourceKey == sourceKey) {
iterator.remove()
changedSourceKeys.add(block.sourceKey)
}
}
if (changedSourceKeys.isNotEmpty()) {
record("expire", changedSourceKeys)
}
}
private fun record(action: String, changedSourceKeys: Set<ContextBlock.SourceKey>) {
val payload = JSONObject()
payload["action"] = action
payload["changedSourceKeys"] = changedSourceKeys
.sortedWith(
compareBy<ContextBlock.SourceKey> { it.blockName }
.thenBy { it.source }
)
.map { sourceKey ->
JSONObject.of(
"blockName", sourceKey.blockName,
"source", sourceKey.source
)
}
payload["blocks"] = stateSet
.sortedWith(
compareBy<ContextBlock> { it.sourceKey.blockName }
.thenBy { it.sourceKey.source }
)
.map(::blockSnapshot)
TraceRecorder.record(TraceEvent("context-workspace", payload))
}
private fun blockSnapshot(block: ContextBlock): JSONObject {
val payload = JSONObject()
payload["blockName"] = block.sourceKey.blockName
payload["source"] = block.sourceKey.source
payload["focusedOn"] = block.focusedOn.map { it.name }
payload["fullRendered"] = block.blockContent.encodeToXmlString()
payload["compactRendered"] = block.compactBlock.encodeToXmlString()
payload["abstractRendered"] = block.abstractBlock.encodeToXmlString()
return payload
}
}
private data class ResolvedContextBlock(
val block: ContextBlock,
val domainWeight: Int,
val activationScore: Double,
val renderedBlock: BlockContent
)
data class ContextBlock @JvmOverloads constructor(
val blockContent: BlockContent,
val compactBlock: BlockContent = blockContent,
val abstractBlock: BlockContent = compactBlock,
/**
* 该 block 集中在哪些域
*/
val focusedOn: Set<FocusedDomain>,
/**
* 新的 [blockContent] 属性与其相同时,发生的衰退步长
*/
private val replaceFadeFactor: Double,
/**
* 随时间发生的衰退步长,按照分钟定义
*/
private val timeFadeFactor: Double,
/**
* 触发一次激活时,发生的强化步长
*/
private val activateFactor: Double
) {
internal enum class Exposure {
PRIMARY,
SECONDARY
}
private enum class ProjectionLevel {
ABSTRACT,
COMPACT,
FULL
}
/**
* 默认活跃分数,降低至0时将在 [ContextWorkspace] 中移除该 block
* 此外还参与到当同源 block 存在时的排序,按该分数升序排列,只影响同源 block 间的顺序
*/
private var activationScore = 100.0
private var lastTouchedAt = Instant.now()
enum class FocusedDomain {
ACTION,
MEMORY,
PERCEIVE,
COGNITION,
COMMUNICATION,
}
internal val sourceKey: SourceKey
get() = SourceKey(blockContent.blockName, blockContent.source)
fun applyTimeFade(): Double {
refreshByElapsedTime()
return activationScore
}
fun applyReplaceFade(): Double {
refreshByElapsedTime()
activationScore = max(0.0, activationScore - replaceFadeFactor)
return activationScore
}
internal fun activate(exposure: Exposure): Double {
refreshByElapsedTime()
val currentLevel = currentProjectionLevel()
val increasedScore = when (exposure) {
Exposure.PRIMARY -> activationScore + when (currentLevel) {
ProjectionLevel.FULL -> activateFactor
ProjectionLevel.COMPACT -> activateFactor * 0.6
ProjectionLevel.ABSTRACT -> activateFactor * 0.6
}
Exposure.SECONDARY -> activationScore + when (currentLevel) {
ProjectionLevel.COMPACT -> activateFactor * 0.2
ProjectionLevel.ABSTRACT -> activateFactor * 0.1
ProjectionLevel.FULL -> 0.0
}
}
activationScore = min(activationCeiling(exposure, currentLevel), increasedScore)
return activationScore
}
private fun refreshByElapsedTime() {
val now = Instant.now()
val elapsedSeconds = Duration.between(lastTouchedAt, now).toMillis() / 1000.0
activationScore = max(0.0, activationScore - elapsedSeconds * (timeFadeFactor / 60.0))
lastTouchedAt = now
}
fun sameWith(contextBlock: ContextBlock): Boolean {
return this.sourceKey == contextBlock.sourceKey
}
internal fun render(exposure: Exposure): BlockContent {
return when (exposure) {
Exposure.PRIMARY -> when (currentProjectionLevel()) {
ProjectionLevel.FULL -> blockContent
ProjectionLevel.COMPACT, ProjectionLevel.ABSTRACT -> compactBlock
}
Exposure.SECONDARY -> when (currentProjectionLevel()) {
ProjectionLevel.ABSTRACT -> abstractBlock
ProjectionLevel.COMPACT, ProjectionLevel.FULL -> compactBlock
}
}
}
fun render(): BlockContent {
return when (currentProjectionLevel()) {
ProjectionLevel.ABSTRACT -> abstractBlock
ProjectionLevel.COMPACT -> compactBlock
ProjectionLevel.FULL -> blockContent
}
}
private fun currentProjectionLevel(): ProjectionLevel {
return when {
activationScore < ABSTRACT_TO_COMPACT_THRESHOLD -> ProjectionLevel.ABSTRACT
activationScore < COMPACT_TO_FULL_THRESHOLD -> ProjectionLevel.COMPACT
else -> ProjectionLevel.FULL
}
}
private fun activationCeiling(exposure: Exposure, currentLevel: ProjectionLevel): Double {
return when (exposure) {
Exposure.PRIMARY -> when (currentLevel) {
ProjectionLevel.ABSTRACT -> COMPACT_TO_FULL_THRESHOLD - PROJECTION_EPSILON
ProjectionLevel.COMPACT, ProjectionLevel.FULL -> MAX_ACTIVATION_SCORE
}
Exposure.SECONDARY -> when (currentLevel) {
ProjectionLevel.ABSTRACT -> ABSTRACT_TO_COMPACT_THRESHOLD - PROJECTION_EPSILON
ProjectionLevel.COMPACT -> COMPACT_TO_FULL_THRESHOLD - PROJECTION_EPSILON
ProjectionLevel.FULL -> MAX_ACTIVATION_SCORE
}
}
}
data class SourceKey(
val blockName: String,
val source: String
)
companion object {
private const val MAX_ACTIVATION_SCORE = 100.0
private const val ABSTRACT_TO_COMPACT_THRESHOLD = 30.0
private const val COMPACT_TO_FULL_THRESHOLD = 70.0
private const val PROJECTION_EPSILON = 0.000001
}
}
private class AggregatedBlockContent(
private val groupedBlocks: List<ResolvedContextBlock>
) : BlockContent(
groupedBlocks.first().block.sourceKey.blockName,
groupedBlocks.first().block.sourceKey.source,
groupedBlocks.maxByOrNull { it.renderedBlock.urgency.ordinal }?.renderedBlock?.urgency ?: Urgency.NORMAL
) {
override fun fillXml(document: Document, root: Element) {
val snapshotIndex = groupedBlocks.withIndex()
.maxWithOrNull(
compareBy<IndexedValue<ResolvedContextBlock>> { it.value.activationScore }
.thenBy { it.index }
)?.index ?: 0
groupedBlocks.forEachIndexed { index, groupedBlock ->
val tagName = if (index == snapshotIndex) "snapshot" else "history_snapshot"
val wrapper = document.createElement(tagName)
val renderedBlock = groupedBlock.renderedBlock
val encoded = renderedBlock.encodeToXml()
val attributes = encoded.attributes
for (attributeIndex in 0 until attributes.length) {
val attribute = attributes.item(attributeIndex)
wrapper.setAttribute(attribute.nodeName, attribute.nodeValue)
}
root.appendChild(wrapper)
val childNodes = encoded.childNodes
for (childIndex in 0 until childNodes.length) {
wrapper.appendChild(document.importNode(childNodes.item(childIndex), true))
}
}
}
}
abstract class BlockContent @JvmOverloads protected constructor(
blockName: String,
val source: String,
val urgency: Urgency = Urgency.NORMAL
) : Block(blockName) {
enum class Urgency {
LOW,
NORMAL,
HIGH,
CRITICAL
}
override fun appendRootAttributes(): Map<String, String> {
return mapOf(
"source" to source,
"urgency" to urgency.name.lowercase(Locale.ROOT)
)
}
}
abstract class CommunicationBlockContent @JvmOverloads constructor(
blockName: String,
source: String,
urgency: Urgency = Urgency.NORMAL,
val type: Projection = Projection.CONTEXT,
) : BlockContent(
blockName,
source,
urgency
) {
enum class Projection {
SUPPLY,
CONTEXT
}
}
@@ -1,61 +0,0 @@
package work.slhaf.partner.core.cognition.context
import org.w3c.dom.Document
import work.slhaf.partner.framework.agent.model.pojo.Message
import java.io.StringWriter
import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.transform.OutputKeys
import javax.xml.transform.TransformerFactory
import javax.xml.transform.dom.DOMSource
import javax.xml.transform.stream.StreamResult
data class ResolvedContext(
val blocks: List<BlockContent>
) {
fun encodeToMessage(): Message {
val content = if (blocks.isEmpty()) {
"<no_context></no_context>"
} else {
buildContextXml(blocks)
}
return Message(Message.Character.USER, content)
}
private fun buildContextXml(blocks: List<BlockContent>): String {
return try {
val document = newDocument()
val root = document.createElement("context")
document.appendChild(root)
blocks.stream()
.map(BlockContent::encodeToXml)
.forEach { blockElement ->
root.appendChild(document.importNode(blockElement, true))
}
toXmlString(document)
} catch (e: Exception) {
throw IllegalStateException("构建 context 区段失败", e)
}
}
private fun newDocument(): Document {
return DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.newDocument()
}
private fun toXmlString(document: Document): String {
val transformer = TransformerFactory.newInstance().newTransformer().apply {
setOutputProperty(OutputKeys.INDENT, "yes")
setOutputProperty(OutputKeys.ENCODING, "UTF-8")
setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes")
setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "2")
}
return StringWriter().use { writer ->
transformer.transform(DOMSource(document), StreamResult(writer))
writer.toString()
}
}
}
@@ -1,145 +0,0 @@
package work.slhaf.partner.core.cognition.impression
import org.w3c.dom.Document
import org.w3c.dom.Element
import work.slhaf.partner.core.cognition.context.BlockContent
import java.time.Instant
import java.time.ZoneId
import java.util.*
import java.util.concurrent.atomic.AtomicReference
class ActiveEntity @JvmOverloads constructor(
val runtimeId: String = newActiveEntityRuntimeId(),
val createdAt: Instant = Instant.now(),
boundEntityUuid: String? = null,
private val _evidences: MutableList<EntityEvidence> = mutableListOf(),
) : BlockContent("active_entity_$runtimeId", "impression") {
val evidences: List<EntityEvidence>
get() = synchronized(_evidences) { _evidences.toList() }
@Volatile
var lastMentionedAt: Instant = createdAt
private set
private val _subject = AtomicReference("UNKNOWN")
val subject: String get() = _subject.get()
private val _boundEntityUuid = AtomicReference<String?>(boundEntityUuid)
val boundEntityUuid: String? get() = _boundEntityUuid.get()
private val _projectedFeatures: MutableMap<String, Double> = mutableMapOf()
val projectedFeatures: Map<String, Double>
get() = synchronized(_projectedFeatures) { _projectedFeatures.toMap() }
private val _projectedImpressions: MutableMap<String, Double> = mutableMapOf()
val projectedImpressions: Map<String, Double>
get() = synchronized(_projectedImpressions) { _projectedImpressions.toMap() }
@JvmOverloads
fun addEvidence(
content: String,
associationConfidence: Double = 1.0,
source: EntityEvidence.Source = EntityEvidence.Source.USER_INPUT,
timestamp: Long = System.currentTimeMillis(),
) = addEvidence(EntityEvidence(content, associationConfidence, source, timestamp))
fun addEvidence(evidence: EntityEvidence) = synchronized(_evidences) {
_evidences.add(evidence)
touch(Instant.ofEpochMilli(evidence.timestamp))
}
fun updateSubject(subject: String) = _subject.set(subject)
fun bindEntity(uuid: String?) = _boundEntityUuid.set(uuid)
fun touch(time: Instant = Instant.now()) {
lastMentionedAt = time
}
fun addProjectedFeatures(vararg features: Pair<String, Double>) = synchronized(_projectedFeatures) {
features.forEach { _projectedFeatures[it.first] = it.second }
}
fun addProjectedImpressions(vararg impressions: Pair<String, Double>) = synchronized(_projectedImpressions) {
impressions.forEach { _projectedImpressions[it.first] = it.second }
}
/**
* Creates a detached runtime snapshot for external inspection without exposing mutable internal collections.
*/
fun snapshot(): ActiveEntity {
val copied = ActiveEntity(
runtimeId = runtimeId,
createdAt = createdAt,
boundEntityUuid = boundEntityUuid,
_evidences = synchronized(_evidences) { _evidences.toMutableList() },
)
copied.updateSubject(subject)
copied.touch(lastMentionedAt)
copied.addProjectedFeatures(*projectedFeatures.entries.map { it.key to it.value }.toTypedArray())
copied.addProjectedImpressions(*projectedImpressions.entries.map { it.key to it.value }.toTypedArray())
return copied
}
override fun fillXml(document: Document, root: Element) {
root.setAttribute("runtime_id", runtimeId)
boundEntityUuid?.let { root.setAttribute("bound_entity_uuid", it) }
root.setAttribute("created_at", modelTime(createdAt))
root.setAttribute("last_mentioned_at", modelTime(lastMentionedAt))
appendTextElement(document, root, "subject", subject)
appendListElement(
document,
root,
"evidences",
"evidence",
synchronized(_evidences) { _evidences.toList() }
) { evidence ->
setAttribute("association_confidence", evidence.associationConfidence.toString())
setAttribute("source", evidence.source.name)
setAttribute("timestamp", evidence.timestamp.toString())
setAttribute("truncated", evidence.isContentTruncated().toString())
setAttribute("original_length", evidence.content.length.toString())
textContent = evidence.contentForContext()
}
appendListElement(
document,
root,
"projected_features",
"feature",
synchronized(_projectedFeatures) { _projectedFeatures.entries.toList() }
) { entry ->
setAttribute("confidence", entry.value.toString())
textContent = entry.key
}
appendListElement(
document,
root,
"projected_impressions",
"impression",
synchronized(_projectedImpressions) { _projectedImpressions.entries.toList() }
) { entry ->
setAttribute("confidence", entry.value.toString())
textContent = entry.key
}
}
private fun modelTime(time: Instant): String =
time.atZone(ZoneId.systemDefault()).toString()
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ActiveEntity) return false
return runtimeId == other.runtimeId
}
override fun hashCode(): Int {
return runtimeId.hashCode()
}
}
private fun newActiveEntityRuntimeId(): String =
UUID.randomUUID().toString().replace("-", "").take(12)
@@ -1,399 +0,0 @@
package work.slhaf.partner.core.cognition.impression
import com.alibaba.fastjson2.JSONArray
import com.alibaba.fastjson2.JSONObject
import work.slhaf.partner.framework.agent.state.State
import work.slhaf.partner.framework.agent.state.StateSerializable
import work.slhaf.partner.framework.agent.state.StateValue
import java.nio.file.Path
import java.time.Instant
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
class Entity @JvmOverloads constructor(
val uuid: String = UUID.randomUUID().toString(),
subject: String,
private val relations: MutableMap<String, MutableMap<String, Double>> = mutableMapOf(),
private val impressions: MutableMap<String, IndexableData> = mutableMapOf(),
private val features: MutableMap<String, IndexableData> = mutableMapOf(),
private val aliases: MutableMap<String, AliasMetadata> = mutableMapOf()
) : StateSerializable {
private var _subject: String = normalizeIdentityText(subject)
private val impressionLock = ReentrantLock()
private val relationLock = ReentrantLock()
private val featureLock = ReentrantLock()
private val identityLock = ReentrantLock()
val subject: String
get() = identityLock.withLock { _subject }
@JvmOverloads
fun renameSubject(newSubject: String, keepOldSubjectAsAlias: Boolean = true): Boolean = identityLock.withLock {
val normalizedSubject = normalizeIdentityText(newSubject)
if (normalizedSubject.isBlank() || normalizedSubject == _subject) {
return@withLock false
}
val previousSubject = _subject
if (keepOldSubjectAsAlias && previousSubject.isNotBlank()) {
aliases[previousSubject] = aliases[previousSubject]?.copy(deprecated = true)
?: AliasMetadata(Instant.now(), deprecated = true)
}
aliases.remove(normalizedSubject)
_subject = normalizedSubject
true
}
@JvmOverloads
fun addAlias(alias: String, deprecated: Boolean = false): Boolean = identityLock.withLock {
val normalizedAlias = normalizeIdentityText(alias)
if (normalizedAlias.isBlank() || normalizedAlias == _subject) {
return@withLock false
}
aliases[normalizedAlias] = aliases[normalizedAlias]?.copy(deprecated = deprecated)
?: AliasMetadata(Instant.now(), deprecated)
true
}
@JvmOverloads
fun showAliases(includeDeprecated: Boolean = false): Set<AliasView> = identityLock.withLock {
aliases.asSequence()
.filter { (_, metadata) -> includeDeprecated || !metadata.deprecated }
.map { (alias, metadata) ->
AliasView(alias, metadata.instant, metadata.deprecated)
}
.sortedWith(compareBy<AliasView> { it.createdAt }.thenBy { it.alias })
.toCollection(LinkedHashSet())
}
fun snapshotAliases(): Map<String, AliasMetadata> = identityLock.withLock {
aliases.mapValues { (_, metadata) -> metadata.copy() }
}
@JvmOverloads
fun updateRelation(
target: String,
relation: String,
strength: Double = 1.0
) = relationLock.withLock {
relations.computeIfAbsent(target) { mutableMapOf() }[relation] = strength
}
@JvmOverloads
fun updateImpression(
impression: String,
newImpression: String? = null,
confidence: Double = 1.0
): IndexableData = impressionLock.withLock {
if (newImpression == null) {
impressions.computeIfAbsent(impression) { IndexableData(confidence) }
.also {
it.confidence = confidence
if (it.confidence >= 0.9) {
featureLock.withLock { features[impression] = it }
}
}
} else {
impressions.remove(impression)
IndexableData(confidence).also {
impressions[newImpression] = it
if (it.confidence >= 0.9) {
featureLock.withLock { features[newImpression] = it }
}
}
}
}
fun updateFeature(feature: String, newFeature: String? = null, confidence: Double = 1.0) = featureLock.withLock {
if (newFeature == null) {
features.computeIfAbsent(feature) { IndexableData(confidence) }
.also { it.confidence = confidence }
} else {
features.remove(feature)
IndexableData(confidence).also {
features[newFeature] = it
}
}
}
fun removeFeature(feature: String) = featureLock.withLock {
features.remove(feature)
}
fun removeImpression(impression: String) = impressionLock.withLock {
impressions.remove(impression)
}
@JvmOverloads
fun removeRelation(
target: String,
relation: String? = null
) = relationLock.withLock {
if (relation == null) {
relations.remove(target)
} else {
relations[target]?.remove(relation)
if (relations[target].isNullOrEmpty()) {
relations.remove(target)
}
}
}
fun showRelations(): Set<RelationView> = relationLock.withLock {
relations.map {
RelationView(
it.key,
it.value.toMap()
)
}.toSet()
}
fun showImpressions(embeddingModel: String): Set<ImpressionView> = impressionLock.withLock {
impressions.map {
ImpressionView(
it.key,
it.value.confidence,
it.value.getVector(embeddingModel)
)
}.toSet()
}
fun showFeatures(): Set<FeatureView> = featureLock.withLock {
features.map {
FeatureView(
it.key,
it.value.confidence
)
}.toSet()
}
fun snapshotImpressions(): Map<String, IndexableData> = impressionLock.withLock {
impressions.toMap()
}
fun snapshotFeatures(): Map<String, IndexableData> = featureLock.withLock {
features.toMap()
}
override fun statePath(): Path = Path.of("core", "impression", "entity-$uuid.json")
override fun load(state: JSONObject) {
state.getJSONObject("relations")?.let { loadedRelations ->
relationLock.withLock {
relations.clear()
loadedRelations.forEach { (target, relationValue) ->
val relationObject = relationValue as? JSONObject ?: return@forEach
val relationMap = mutableMapOf<String, Double>()
relationObject.forEach { (relation, strengthValue) ->
doubleValue(strengthValue)?.let { relationMap[relation] = it }
}
if (relationMap.isNotEmpty()) {
relations[target] = relationMap
}
}
}
}
state.getJSONObject("impressions")?.let { loadedImpressions ->
impressionLock.withLock {
impressions.clear()
impressions.putAll(loadIndexableDataMap(loadedImpressions))
}
}
identityLock.withLock {
state.getString("subject")
?.let(::normalizeIdentityText)
?.takeIf(String::isNotBlank)
?.let { _subject = it }
}
state.getJSONObject("features")?.let { loadedFeatures ->
featureLock.withLock {
features.clear()
features.putAll(loadIndexableDataMap(loadedFeatures))
}
}
state.getJSONObject("aliases")?.let { loadedAliases ->
identityLock.withLock {
aliases.clear()
loadedAliases.forEach { (alias, metadataValue) ->
val normalizedAlias = normalizeIdentityText(alias)
if (normalizedAlias.isBlank() || normalizedAlias == _subject) {
return@forEach
}
val metadata = when (metadataValue) {
is JSONObject -> loadAliasMetadata(metadataValue)
else -> AliasMetadata(Instant.now(), deprecated = false)
}
aliases[normalizedAlias] = metadata
}
}
}
}
override fun convert(): State {
val state = State()
state.append("uuid", StateValue.str(uuid))
val identityState = identityLock.withLock {
IdentityState(
subject = _subject,
aliases = aliases.mapValues { (_, metadata) ->
mapOf(
"timestamp" to metadata.instant.toEpochMilli(),
"deprecated" to metadata.deprecated
)
}
)
}
state.append("subject", StateValue.str(identityState.subject))
state.append("aliases", StateValue.obj(identityState.aliases))
val relationState = relationLock.withLock {
relations.mapValues { (_, relationMap) -> relationMap.toMap() }
}
state.append("relations", StateValue.obj(relationState))
val impressionState = impressionLock.withLock {
indexableDataState(impressions)
}
state.append("impressions", StateValue.obj(impressionState))
val featureState = featureLock.withLock {
indexableDataState(features)
}
state.append("features", StateValue.obj(featureState))
return state
}
override fun autoLoadOnRegister(): Boolean = false
private fun normalizeIdentityText(value: String): String =
value.replace(IDENTITY_WHITESPACE_REGEX, " ").trim()
private fun loadAliasMetadata(state: JSONObject): AliasMetadata {
val instant = state.getLong("timestamp")
?.let(Instant::ofEpochMilli)
?: state.getString("instant")
?.let { runCatching { Instant.parse(it) }.getOrNull() }
?: Instant.now()
return AliasMetadata(
instant = instant,
deprecated = state.getBoolean("deprecated") ?: false
)
}
private fun loadIndexableDataMap(state: JSONObject): Map<String, IndexableData> {
val loaded = mutableMapOf<String, IndexableData>()
state.forEach { (key, value) ->
val data = when (value) {
is JSONObject -> loadIndexableData(value)
else -> IndexableData(doubleValue(value) ?: return@forEach)
}
loaded[key] = data
}
return loaded
}
private fun loadIndexableData(state: JSONObject): IndexableData {
val data = IndexableData(state.getDouble("confidence") ?: 1.0)
state.getJSONObject("vectors")?.forEach { (embeddingModel, vectorValue) ->
val vectorArray = vectorValue as? JSONArray ?: return@forEach
val vector = FloatArray(vectorArray.size)
for (index in vectorArray.indices) {
vector[index] = floatValue(vectorArray[index]) ?: return@forEach
}
data.updateVector(embeddingModel, vector)
}
return data
}
private fun indexableDataState(source: Map<String, IndexableData>): Map<String, Map<String, Any>> =
source.mapValues { (_, data) ->
mapOf(
"confidence" to data.confidence,
"vectors" to data.snapshotVectors().mapValues { (_, vector) -> vector.toList() }
)
}
private fun doubleValue(value: Any?): Double? = when (value) {
is Number -> value.toDouble()
is String -> value.toDoubleOrNull()
else -> null
}
private fun floatValue(value: Any?): Float? = when (value) {
is Number -> value.toFloat()
is String -> value.toFloatOrNull()
else -> null
}
data class IndexableData(
var confidence: Double
) {
private val vectors: ConcurrentHashMap<String, FloatArray> = ConcurrentHashMap()
fun updateVector(
embeddingModel: String,
vector: FloatArray
) {
vectors[embeddingModel] = vector.copyOf()
}
fun getVector(embeddingModel: String): FloatArray? {
return vectors[embeddingModel]?.copyOf()
}
fun snapshotVectors(): Map<String, FloatArray> {
return vectors.mapValues { (_, vector) -> vector.copyOf() }
}
}
data class RelationView(
val target: String,
val relations: Map<String, Double>
)
data class FeatureView(
val feature: String,
val confidence: Double
)
@Suppress("ArrayInDataClass")
data class ImpressionView(
val impression: String,
val confidence: Double,
val vector: FloatArray?
)
private data class IdentityState(
val subject: String,
val aliases: Map<String, Map<String, Any>>
)
data class AliasView(
val alias: String,
val createdAt: Instant,
val deprecated: Boolean
)
data class AliasMetadata(
val instant: Instant,
val deprecated: Boolean
)
companion object {
private val IDENTITY_WHITESPACE_REGEX = Regex("\\s+")
}
}
@@ -1,112 +0,0 @@
package work.slhaf.partner.core.cognition.impression
/**
* Runtime evidence associated with an active entity.
*
* The confidence describes how strongly this evidence is associated with the
* current active entity, not whether the evidence content itself is true.
*/
data class EntityEvidence @JvmOverloads constructor(
val content: String,
val associationConfidence: Double = 1.0,
val source: Source = Source.USER_INPUT,
val timestamp: Long = System.currentTimeMillis(),
) {
enum class Source {
USER_INPUT,
ASSISTANT_REPLY
}
fun isContentTruncated(maxLength: Int = CONTEXT_CONTENT_MAX_LENGTH): Boolean =
content.length > maxLength
fun contentForContext(maxLength: Int = CONTEXT_CONTENT_MAX_LENGTH): String {
if (content.length <= maxLength) {
return content
}
val available = maxLength - OMITTED_MARKER.length
if (available <= 0) {
return content.take(maxLength)
}
val headBudget = available / 2
val tailBudget = available - headBudget
val headEnd = adjustHeadEnd(content, headBudget)
val tailStart = adjustTailStart(content, content.length - tailBudget)
if (tailStart <= headEnd) {
return content.take(maxLength).trimEnd()
}
return content.substring(0, headEnd).trimEnd() +
OMITTED_MARKER +
content.substring(tailStart).trimStart()
}
private fun adjustHeadEnd(source: String, preferredEnd: Int): Int {
val safePreferredEnd = preferredEnd.coerceIn(0, source.length)
findForwardBoundary(source, safePreferredEnd, STRONG_BOUNDARY_SEARCH_WINDOW, ::isStrongBoundary)?.let {
return it + 1
}
findForwardBoundary(source, safePreferredEnd, SOFT_BOUNDARY_SEARCH_WINDOW, ::isSoftBoundary)?.let {
return it + 1
}
return safePreferredEnd
}
private fun adjustTailStart(source: String, preferredStart: Int): Int {
val safePreferredStart = preferredStart.coerceIn(0, source.length)
findBackwardBoundary(source, safePreferredStart, STRONG_BOUNDARY_SEARCH_WINDOW, ::isStrongBoundary)?.let {
return it
}
findBackwardBoundary(source, safePreferredStart, SOFT_BOUNDARY_SEARCH_WINDOW, ::isSoftBoundary)?.let {
return it
}
return safePreferredStart
}
private fun findForwardBoundary(
source: String,
start: Int,
window: Int,
predicate: (Char) -> Boolean,
): Int? {
val end = (start + window).coerceAtMost(source.length)
for (index in start until end) {
if (predicate(source[index])) {
return index
}
}
return null
}
private fun findBackwardBoundary(
source: String,
start: Int,
window: Int,
predicate: (Char) -> Boolean,
): Int? {
val end = (start - window).coerceAtLeast(0)
for (index in start downTo end + 1) {
if (predicate(source[index - 1])) {
return index
}
}
return null
}
private fun isStrongBoundary(char: Char): Boolean = char == '\n'
private fun isSoftBoundary(char: Char): Boolean = when (char) {
'。', '!', '?', ';', ';', '.' -> true
else -> false
}
companion object {
const val CONTEXT_CONTENT_MAX_LENGTH = 480
private const val OMITTED_MARKER = "\n...[omitted]...\n"
private const val STRONG_BOUNDARY_SEARCH_WINDOW = 120
private const val SOFT_BOUNDARY_SEARCH_WINDOW = 80
}
}
@@ -1,486 +0,0 @@
package work.slhaf.partner.core.cognition.impression;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import org.jetbrains.annotations.NotNull;
import work.slhaf.partner.core.cognition.impression.search.*;
import work.slhaf.partner.framework.agent.factory.capability.annotation.CapabilityCore;
import work.slhaf.partner.framework.agent.factory.capability.annotation.CapabilityMethod;
import work.slhaf.partner.framework.agent.state.State;
import work.slhaf.partner.framework.agent.state.StateSerializable;
import work.slhaf.partner.framework.agent.state.StateValue;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
@CapabilityCore(value = "cognition")
public class ImpressionCore implements StateSerializable {
/**
* Keyed by entity uuid. Subject can be revised or merged later, so it should not be used as the stable key.
*/
private final ConcurrentHashMap<String, Entity> knownEntitiesByUuid = new ConcurrentHashMap<>();
private final ImpressionVectorIndex vectorIndex = new ImpressionVectorIndex();
private final Set<ActiveEntity> activeEntities = new HashSet<>();
private final ImpressionTextSearch textSearch = new SimpleTextSearch();
private static final int TEXT_SEARCH_LIMIT = 20;
private static final int ASSOCIATION_MATCH_LIMIT = 8;
private static final double SUPPORTING_HIT_FACTOR = 0.3;
private static final double ASSOCIATION_CONFIDENCE_DIVISOR = 5.0;
/**
* 根据新的 Input 召回相关的实体,如果实体已重复,则将输入追加到 ActiveEntity 的证据中。
*
* @param input 本次输入内容
* @return 本次被召回的活跃实体(包括重复的实体)
*/
@CapabilityMethod
public Set<ActiveEntity> projectEntity(String input) {
if (input == null || input.isBlank()) {
return Set.of();
}
List<ImpressionSearchHit> textSearchHits = textSearch.search(input, TEXT_SEARCH_LIMIT);
List<EntityAssociationMatch> associationMatches = aggregateMatches(textSearchHits, ASSOCIATION_MATCH_LIMIT);
if (associationMatches.isEmpty()) {
return Set.of();
}
Set<ActiveEntity> projected = new HashSet<>();
for (EntityAssociationMatch match : associationMatches) {
Optional<ActiveEntity> activeEntity = resolveActiveEntity(match.getTarget());
if (activeEntity.isEmpty()) {
continue;
}
ActiveEntity entity = activeEntity.get();
entity.addEvidence(
input,
associationConfidence(match),
EntityEvidence.Source.USER_INPUT
);
refreshActiveEntityTextSearch(entity);
projected.add(entity);
}
return projected;
}
/**
* 列出当前已存在的 ActiveEntity 以及对应的 Entity。ActiveEntity 返回快照,Entity 返回当前已知实体引用。
*
* 注意:外部模块不要直接修改返回的 Entity,否则文本索引 / 向量索引不会刷新。
* Impression 更新应走 updateEntity* 系列接口。
*
* @return ActiveEntity 快照与已绑定 Entity 的映射
*/
@CapabilityMethod
public Map<ActiveEntity, Entity> showEntities() {
Map<ActiveEntity, Entity> result = new LinkedHashMap<>();
List<ActiveEntity> entities;
synchronized (activeEntities) {
entities = activeEntities.stream()
.sorted(Comparator
.comparing(ActiveEntity::getLastMentionedAt)
.reversed()
.thenComparing(ActiveEntity::getRuntimeId))
.toList();
}
for (ActiveEntity activeEntity : entities) {
Entity boundEntity = Optional.ofNullable(activeEntity.getBoundEntityUuid())
.map(knownEntitiesByUuid::get)
.orElse(null);
result.put(activeEntity.snapshot(), boundEntity);
}
return Collections.unmodifiableMap(result);
}
/**
* Create a new known entity and make it visible to recall/update indexes immediately.
*/
@CapabilityMethod
public Entity createEntity(String subject) {
if (subject == null || subject.isBlank()) {
throw new IllegalArgumentException("subject must not be blank");
}
Entity entity = new Entity(UUID.randomUUID().toString(), subject.trim());
entity.register();
knownEntitiesByUuid.put(entity.getUuid(), entity);
refreshKnownEntityIndexes(entity);
return entity;
}
/**
* Look up a known entity by stable uuid.
*/
@CapabilityMethod
public Entity getEntity(String uuid) {
if (uuid == null || uuid.isBlank()) {
return null;
}
return knownEntitiesByUuid.get(uuid);
}
/**
* Bind a runtime active entity to a known entity.
* This keeps the active entity in current context while giving later updates a stable storage target.
*/
@CapabilityMethod
public boolean bindActiveEntity(String runtimeId, String entityUuid) {
if (runtimeId == null || runtimeId.isBlank() || entityUuid == null || entityUuid.isBlank()) {
return false;
}
Entity entity = knownEntitiesByUuid.get(entityUuid);
if (entity == null) {
return false;
}
Optional<ActiveEntity> activeEntity = findActiveEntityByRuntimeId(runtimeId);
if (activeEntity.isEmpty()) {
return false;
}
ActiveEntity active = activeEntity.get();
active.bindEntity(entityUuid);
active.updateSubject(entity.getSubject());
refreshActiveEntityTextSearch(active);
return true;
}
/**
* Rename the canonical subject of a known entity and keep its previous subject as a historical alias.
*/
@CapabilityMethod
public boolean renameEntitySubject(String entityUuid, String newSubject) {
Entity entity = knownEntitiesByUuid.get(entityUuid);
if (entity == null || newSubject == null || newSubject.isBlank()) {
return false;
}
boolean renamed = entity.renameSubject(newSubject.trim());
if (!renamed) {
return false;
}
refreshKnownEntityIndexes(entity);
syncBoundActiveEntitySubjects(entity);
return true;
}
/**
* Add an alias or mention form for a known entity and refresh search indexes.
*/
@CapabilityMethod
public boolean addEntityAlias(String entityUuid, String alias, boolean deprecated) {
Entity entity = knownEntitiesByUuid.get(entityUuid);
if (entity == null || alias == null || alias.isBlank()) {
return false;
}
boolean added = entity.addAlias(alias.trim(), deprecated);
if (!added) {
return false;
}
refreshKnownEntityIndexes(entity);
return true;
}
/**
* Update a known entity impression through the core so text/vector indexes stay consistent.
* newImpression can be null or blank to update the existing impression in place.
*/
@CapabilityMethod
public boolean updateEntityImpression(
String entityUuid,
String impression,
String newImpression,
double confidence
) {
Entity entity = knownEntitiesByUuid.get(entityUuid);
if (entity == null || impression == null || impression.isBlank()) {
return false;
}
entity.updateImpression(
impression.trim(),
normalizeNullableText(newImpression),
confidence
);
refreshKnownEntityIndexes(entity);
return true;
}
/**
* Update a known entity feature through the core so text/vector indexes stay consistent.
* newFeature can be null or blank to update the existing feature in place.
*/
@CapabilityMethod
public boolean updateEntityFeature(
String entityUuid,
String feature,
String newFeature,
double confidence
) {
Entity entity = knownEntitiesByUuid.get(entityUuid);
if (entity == null || feature == null || feature.isBlank()) {
return false;
}
entity.updateFeature(
feature.trim(),
normalizeNullableText(newFeature),
confidence
);
refreshKnownEntityIndexes(entity);
return true;
}
/**
* Update a known entity relation through the core so search documents reflect the changed relation.
*/
@CapabilityMethod
public boolean updateEntityRelation(
String entityUuid,
String target,
String relation,
double strength
) {
Entity entity = knownEntitiesByUuid.get(entityUuid);
if (entity == null || target == null || target.isBlank() || relation == null || relation.isBlank()) {
return false;
}
entity.updateRelation(target.trim(), relation.trim(), strength);
refreshKnownEntityIndexes(entity);
return true;
}
/**
* Normalize optional replacement text used by update methods.
*/
private String normalizeNullableText(String value) {
if (value == null || value.isBlank()) {
return null;
}
return value.trim();
}
private List<EntityAssociationMatch> aggregateMatches(
List<ImpressionSearchHit> hits,
int limit
) {
if (hits == null || hits.isEmpty() || limit <= 0) {
return List.of();
}
return hits.stream()
.collect(Collectors.groupingBy(
hit -> hit.getDocument().getTarget(),
LinkedHashMap::new,
Collectors.toList()
))
.entrySet()
.stream()
.map(entry -> {
List<ImpressionSearchHit> sortedHits = entry.getValue()
.stream()
.sorted(Comparator
.comparingDouble(ImpressionSearchHit::getScore)
.reversed()
.thenComparing(hit -> hit.getDocument().getId()))
.toList();
return new EntityAssociationMatch(
entry.getKey(),
aggregateScore(sortedHits),
sortedHits
);
})
.sorted(Comparator
.comparingDouble(EntityAssociationMatch::getScore)
.reversed()
.thenComparing(match -> match.getTarget().getType().name())
.thenComparing(match -> match.getTarget().getId()))
.limit(limit)
.toList();
}
private double aggregateScore(List<ImpressionSearchHit> sortedHits) {
if (sortedHits.isEmpty()) {
return 0.0;
}
double bestHitScore = sortedHits.getFirst().getScore();
double supportingScore = sortedHits.stream()
.skip(1)
.limit(2)
.mapToDouble(hit -> hit.getScore() * SUPPORTING_HIT_FACTOR)
.sum();
return bestHitScore + supportingScore;
}
private Optional<ActiveEntity> resolveActiveEntity(ImpressionSearchTarget target) {
return switch (target.getType()) {
case ACTIVE_ENTITY -> findActiveEntityByRuntimeId(target.getId());
case ENTITY -> activateKnownEntity(target.getId());
};
}
private Optional<ActiveEntity> findActiveEntityByRuntimeId(String runtimeId) {
synchronized (activeEntities) {
return activeEntities.stream()
.filter(activeEntity -> activeEntity.getRuntimeId().equals(runtimeId))
.findFirst();
}
}
private Optional<ActiveEntity> findActiveEntityByBoundEntityUuid(String uuid) {
synchronized (activeEntities) {
return activeEntities.stream()
.filter(activeEntity -> uuid.equals(activeEntity.getBoundEntityUuid()))
.findFirst();
}
}
private Optional<ActiveEntity> activateKnownEntity(String uuid) {
Entity knownEntity = knownEntitiesByUuid.get(uuid);
if (knownEntity == null) {
return Optional.empty();
}
Optional<ActiveEntity> existing = findActiveEntityByBoundEntityUuid(uuid);
if (existing.isPresent()) {
return existing;
}
ActiveEntity activeEntity = new ActiveEntity();
activeEntity.updateSubject(knownEntity.getSubject());
activeEntity.bindEntity(uuid);
synchronized (activeEntities) {
activeEntities.add(activeEntity);
}
refreshActiveEntityTextSearch(activeEntity);
return Optional.of(activeEntity);
}
private double associationConfidence(EntityAssociationMatch match) {
double normalized = match.getScore() / ASSOCIATION_CONFIDENCE_DIVISOR;
return Math.clamp(normalized, 0.05, 1.0);
}
private void refreshActiveEntityTextSearch(ActiveEntity activeEntity) {
ImpressionSearchTarget target = new ImpressionSearchTarget(
ImpressionSearchTarget.Type.ACTIVE_ENTITY,
activeEntity.getRuntimeId()
);
textSearch.removeByTarget(target);
for (ImpressionSearchDocument document : ImpressionSearchDocuments.INSTANCE.fromActiveEntity(activeEntity)) {
textSearch.upsert(document);
}
}
/**
* Refresh every index derived from a known entity after mutation.
*/
private void refreshKnownEntityIndexes(Entity entity) {
vectorIndex.sync(entity);
refreshKnownEntityTextSearch(entity);
}
private void syncBoundActiveEntitySubjects(Entity entity) {
List<ActiveEntity> boundEntities;
synchronized (activeEntities) {
boundEntities = activeEntities.stream()
.filter(activeEntity -> entity.getUuid().equals(activeEntity.getBoundEntityUuid()))
.toList();
}
boundEntities.forEach(activeEntity -> {
activeEntity.updateSubject(entity.getSubject());
refreshActiveEntityTextSearch(activeEntity);
});
}
/**
* Replace text-search documents for one known entity.
*/
private void refreshKnownEntityTextSearch(Entity entity) {
ImpressionSearchTarget target = new ImpressionSearchTarget(
ImpressionSearchTarget.Type.ENTITY,
entity.getUuid()
);
textSearch.removeByTarget(target);
for (ImpressionSearchDocument document : ImpressionSearchDocuments.INSTANCE.fromEntity(entity)) {
textSearch.upsert(document);
}
}
private void rebuildTextSearch() {
List<ImpressionSearchDocument> documents = new ArrayList<>();
knownEntitiesByUuid.values().forEach(entity ->
documents.addAll(ImpressionSearchDocuments.INSTANCE.fromEntity(entity))
);
synchronized (activeEntities) {
activeEntities.forEach(activeEntity ->
documents.addAll(ImpressionSearchDocuments.INSTANCE.fromActiveEntity(activeEntity))
);
}
textSearch.rebuild(documents);
}
@Override
public @NotNull Path statePath() {
return Path.of("core", "impression.json");
}
@Override
public void load(@NotNull JSONObject state) {
JSONArray entityArray = state.getJSONArray("entities");
if (entityArray == null) {
return;
}
knownEntitiesByUuid.clear();
for (int i = 0; i < entityArray.size(); i++) {
JSONObject entityObject = entityArray.getJSONObject(i);
if (entityObject == null) {
continue;
}
String uuid = entityObject.getString("uuid");
String subject = entityObject.getString("subject");
if (uuid == null || uuid.isBlank() || subject == null || subject.isBlank()) {
continue;
}
Entity entity = new Entity(uuid, subject);
entity.register();
entity.load();
vectorIndex.sync(entity);
knownEntitiesByUuid.put(uuid, entity);
}
rebuildTextSearch();
}
@Override
public @NotNull State convert() {
State state = new State();
List<StateValue.Obj> entities = knownEntitiesByUuid.values().stream()
.map(entity -> StateValue.obj(Map.of(
"uuid", entity.getUuid(),
"subject", entity.getSubject()
)))
.toList();
state.append("entities", StateValue.arr(entities));
return state;
}
}
@@ -1,38 +0,0 @@
package work.slhaf.partner.core.cognition.impression;
import work.slhaf.partner.common.vector.VectorClient;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
public class ImpressionVectorIndex {
private final Executor executor = Executors.newFixedThreadPool(2, r -> {
Thread thread = new Thread(r, "impression-vector-index");
thread.setDaemon(true);
return thread;
});
public void sync(Entity entity) {
if (!VectorClient.status){
return;
}
entity.snapshotFeatures().forEach(this::upsert);
entity.snapshotImpressions().forEach(this::upsert);
}
public void upsert(String text, Entity.IndexableData indexableData){
if (!VectorClient.status){
return;
}
String modelId = VectorClient.VECTOR_MODEL_ID;
if (indexableData.getVector(modelId) != null) {
return;
}
executor.execute(() -> {
float[] vector = VectorClient.INSTANCE.compute(text);
indexableData.updateVector(modelId,vector);
});
}
}
@@ -1,7 +0,0 @@
package work.slhaf.partner.core.cognition.impression.search
data class EntityAssociationMatch(
val target: ImpressionSearchTarget,
val score: Double,
val hits: List<ImpressionSearchHit> = emptyList(),
)
@@ -1,10 +0,0 @@
package work.slhaf.partner.core.cognition.impression.search
data class ImpressionSearchDocument(
val id: String,
val target: ImpressionSearchTarget,
val field: ImpressionSearchField,
val text: String,
val weight: Double = 1.0,
val metadata: Map<String, String> = emptyMap(),
)
@@ -1,151 +0,0 @@
package work.slhaf.partner.core.cognition.impression.search
import work.slhaf.partner.core.cognition.impression.ActiveEntity
import work.slhaf.partner.core.cognition.impression.Entity
object ImpressionSearchDocuments {
fun fromActiveEntity(activeEntity: ActiveEntity): List<ImpressionSearchDocument> {
val target = ImpressionSearchTarget(
ImpressionSearchTarget.Type.ACTIVE_ENTITY,
activeEntity.runtimeId
)
val metadata = activeEntity.boundEntityUuid
?.let { mapOf("boundEntityUuid" to it) }
.orEmpty()
return buildList {
add(
ImpressionSearchDocument(
id = "active:${activeEntity.runtimeId}:subject",
target = target,
field = ImpressionSearchField.SUBJECT,
text = activeEntity.subject,
weight = SUBJECT_WEIGHT,
metadata = metadata,
)
)
activeEntity.evidences.forEachIndexed { index, evidence ->
add(
ImpressionSearchDocument(
id = "active:${activeEntity.runtimeId}:evidence:$index",
target = target,
field = ImpressionSearchField.EVIDENCE,
text = evidence.contentForContext(),
weight = EVIDENCE_WEIGHT * evidence.associationConfidence,
metadata = metadata,
)
)
}
activeEntity.projectedFeatures.entries.forEachIndexed { index, entry ->
add(
ImpressionSearchDocument(
id = "active:${activeEntity.runtimeId}:feature:$index",
target = target,
field = ImpressionSearchField.FEATURE,
text = entry.key,
weight = FEATURE_WEIGHT * entry.value,
metadata = metadata,
)
)
}
activeEntity.projectedImpressions.entries.forEachIndexed { index, entry ->
add(
ImpressionSearchDocument(
id = "active:${activeEntity.runtimeId}:impression:$index",
target = target,
field = ImpressionSearchField.IMPRESSION,
text = entry.key,
weight = IMPRESSION_WEIGHT * entry.value,
metadata = metadata,
)
)
}
}
}
fun fromEntity(entity: Entity): List<ImpressionSearchDocument> {
val target = ImpressionSearchTarget(
ImpressionSearchTarget.Type.ENTITY,
entity.uuid
)
return buildList {
add(
ImpressionSearchDocument(
id = "entity:${entity.uuid}:subject",
target = target,
field = ImpressionSearchField.SUBJECT,
text = entity.subject,
weight = SUBJECT_WEIGHT,
)
)
entity.showAliases(includeDeprecated = true).forEachIndexed { index, alias ->
add(
ImpressionSearchDocument(
id = "entity:${entity.uuid}:alias:$index",
target = target,
field = ImpressionSearchField.SUBJECT,
text = alias.alias,
weight = SUBJECT_WEIGHT * ALIAS_WEIGHT_FACTOR,
)
)
}
entity.snapshotFeatures().keys.forEachIndexed { index, feature ->
add(
ImpressionSearchDocument(
id = "entity:${entity.uuid}:feature:$index",
target = target,
field = ImpressionSearchField.FEATURE,
text = feature,
weight = FEATURE_WEIGHT,
)
)
}
entity.snapshotImpressions().keys.forEachIndexed { index, impression ->
add(
ImpressionSearchDocument(
id = "entity:${entity.uuid}:impression:$index",
target = target,
field = ImpressionSearchField.IMPRESSION,
text = impression,
weight = IMPRESSION_WEIGHT,
)
)
}
entity.showRelations().forEachIndexed { index, relation ->
val relationText = buildString {
append(relation.target)
relation.relations.keys.forEach { name ->
append(' ')
append(name)
}
}
add(
ImpressionSearchDocument(
id = "entity:${entity.uuid}:relation:$index",
target = target,
field = ImpressionSearchField.RELATION,
text = relationText,
weight = RELATION_WEIGHT,
)
)
}
}
}
private const val SUBJECT_WEIGHT = 1.0
private const val ALIAS_WEIGHT_FACTOR = 0.9
private const val FEATURE_WEIGHT = 0.85
private const val IMPRESSION_WEIGHT = 0.75
private const val RELATION_WEIGHT = 0.65
private const val EVIDENCE_WEIGHT = 0.8
}
@@ -1,9 +0,0 @@
package work.slhaf.partner.core.cognition.impression.search
enum class ImpressionSearchField {
SUBJECT,
FEATURE,
IMPRESSION,
RELATION,
EVIDENCE
}
@@ -1,7 +0,0 @@
package work.slhaf.partner.core.cognition.impression.search
data class ImpressionSearchHit(
val document: ImpressionSearchDocument,
val score: Double,
val matchedTerms: Set<String> = emptySet(),
)
@@ -1,11 +0,0 @@
package work.slhaf.partner.core.cognition.impression.search
data class ImpressionSearchTarget(
val type: Type,
val id: String,
) {
enum class Type {
ACTIVE_ENTITY,
ENTITY
}
}
@@ -1,15 +0,0 @@
package work.slhaf.partner.core.cognition.impression.search
interface ImpressionTextSearch {
fun rebuild(documents: Collection<ImpressionSearchDocument>)
fun upsert(document: ImpressionSearchDocument)
fun removeByTarget(target: ImpressionSearchTarget)
fun search(
query: String,
limit: Int = 20,
): List<ImpressionSearchHit>
}
@@ -1,5 +0,0 @@
package work.slhaf.partner.core.cognition.impression.search
interface ImpressionTokenizer {
fun tokenize(text: String): Set<String>
}
@@ -1,37 +0,0 @@
package work.slhaf.partner.core.cognition.impression.search
import com.huaban.analysis.jieba.JiebaSegmenter
class JiebaImpressionTokenizer(
private val segmenter: JiebaSegmenter = JiebaSegmenter(),
private val mode: JiebaSegmenter.SegMode = JiebaSegmenter.SegMode.SEARCH,
) : ImpressionTokenizer {
override fun tokenize(text: String): Set<String> {
val normalized = normalize(text)
if (normalized.isBlank()) {
return emptySet()
}
val jiebaTerms = segmenter.process(normalized, mode)
.asSequence()
.map { it.word }
.map(::normalize)
.filter { it.isNotBlank() }
return (jiebaTerms + alphaNumericTerms(normalized)).toSet()
}
private fun alphaNumericTerms(text: String): Sequence<String> =
ALPHA_NUMERIC_REGEX.findAll(text).map { it.value }
private fun normalize(text: String): String =
text.lowercase()
.replace(WHITESPACE_REGEX, " ")
.trim()
companion object {
private val WHITESPACE_REGEX = Regex("\\s+")
private val ALPHA_NUMERIC_REGEX = Regex("[a-z0-9]+(?:[-_./][a-z0-9]+)*")
}
}
@@ -1,136 +0,0 @@
package work.slhaf.partner.core.cognition.impression.search
class SimpleTextSearch(
private val tokenizer: ImpressionTokenizer = JiebaImpressionTokenizer(),
) : ImpressionTextSearch {
private val documents = linkedMapOf<String, IndexedDocument>()
private val invertedIndex = linkedMapOf<String, MutableSet<String>>()
@Synchronized
override fun rebuild(documents: Collection<ImpressionSearchDocument>) {
this.documents.clear()
invertedIndex.clear()
documents.forEach(::upsertInternal)
}
@Synchronized
override fun upsert(document: ImpressionSearchDocument) {
removeByDocumentId(document.id)
upsertInternal(document)
}
@Synchronized
override fun removeByTarget(target: ImpressionSearchTarget) {
documents.values
.asSequence()
.filter { it.document.target == target }
.map { it.document.id }
.toList()
.forEach(::removeByDocumentId)
}
@Synchronized
override fun search(query: String, limit: Int): List<ImpressionSearchHit> {
if (limit <= 0) {
return emptyList()
}
val normalizedQuery = normalize(query)
if (normalizedQuery.isBlank()) {
return emptyList()
}
val queryTerms = tokenizer.tokenize(normalizedQuery)
val candidateIds = if (queryTerms.isEmpty()) {
documents.keys.toSet()
} else {
queryTerms
.asSequence()
.flatMap { invertedIndex[it].orEmpty().asSequence() }
.toSet()
}
return candidateIds
.asSequence()
.mapNotNull { documentId -> scoreDocument(documents[documentId] ?: return@mapNotNull null, normalizedQuery, queryTerms) }
.filter { it.score > 0.0 }
.sortedWith(compareByDescending<ImpressionSearchHit> { it.score }.thenBy { it.document.id })
.take(limit)
.toList()
}
private fun upsertInternal(document: ImpressionSearchDocument) {
val normalizedText = normalize(document.text)
val terms = tokenizer.tokenize(normalizedText)
val indexedDocument = IndexedDocument(document, normalizedText, terms)
documents[document.id] = indexedDocument
terms.forEach { term ->
invertedIndex.getOrPut(term) { linkedSetOf() }.add(document.id)
}
}
private fun removeByDocumentId(documentId: String) {
val indexedDocument = documents.remove(documentId) ?: return
indexedDocument.terms.forEach { term ->
val ids = invertedIndex[term] ?: return@forEach
ids.remove(documentId)
if (ids.isEmpty()) {
invertedIndex.remove(term)
}
}
}
private fun scoreDocument(
indexedDocument: IndexedDocument,
normalizedQuery: String,
queryTerms: Set<String>,
): ImpressionSearchHit? {
val matchedTerms = if (queryTerms.isEmpty()) {
emptySet()
} else {
queryTerms.intersect(indexedDocument.terms)
}
val exactPhraseMatched = indexedDocument.normalizedText.contains(normalizedQuery)
if (matchedTerms.isEmpty() && !exactPhraseMatched) {
return null
}
val coverage = if (queryTerms.isEmpty()) 0.0 else matchedTerms.size.toDouble() / queryTerms.size.toDouble()
val termScore = matchedTerms.size.toDouble()
val exactPhraseBonus = if (exactPhraseMatched) EXACT_PHRASE_BONUS else 0.0
val fieldBonus = fieldBonus(indexedDocument.document.field)
val score = (termScore + coverage + exactPhraseBonus + fieldBonus) * indexedDocument.document.weight
return ImpressionSearchHit(
document = indexedDocument.document,
score = score,
matchedTerms = matchedTerms,
)
}
private fun fieldBonus(field: ImpressionSearchField): Double = when (field) {
ImpressionSearchField.SUBJECT -> 0.8
ImpressionSearchField.FEATURE -> 0.35
ImpressionSearchField.IMPRESSION -> 0.25
ImpressionSearchField.RELATION -> 0.15
ImpressionSearchField.EVIDENCE -> 0.0
}
private fun normalize(text: String): String =
text.lowercase()
.replace(WHITESPACE_REGEX, " ")
.trim()
private data class IndexedDocument(
val document: ImpressionSearchDocument,
val normalizedText: String,
val terms: Set<String>,
)
companion object {
private const val EXACT_PHRASE_BONUS = 1.5
private val WHITESPACE_REGEX = Regex("\\s+")
}
}
@@ -1,27 +0,0 @@
package work.slhaf.partner.core.memory;
import work.slhaf.partner.core.memory.pojo.MemorySlice;
import work.slhaf.partner.core.memory.pojo.MemoryUnit;
import work.slhaf.partner.framework.agent.factory.capability.annotation.Capability;
import work.slhaf.partner.framework.agent.model.pojo.Message;
import work.slhaf.partner.framework.agent.support.Result;
import java.util.Collection;
import java.util.List;
@Capability(value = "memory")
public interface MemoryCapability {
MemoryUnit getMemoryUnit(String unitId);
Result<MemorySlice> getMemorySlice(String unitId, String sliceId);
MemoryUnit updateMemoryUnit(List<Message> chatMessages, String summary);
Collection<MemoryUnit> listMemoryUnits();
void refreshMemorySession();
String getMemorySessionId();
}
@@ -1,183 +0,0 @@
package work.slhaf.partner.core.memory;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import work.slhaf.partner.core.memory.pojo.MemorySlice;
import work.slhaf.partner.core.memory.pojo.MemoryUnit;
import work.slhaf.partner.framework.agent.factory.capability.annotation.CapabilityCore;
import work.slhaf.partner.framework.agent.factory.capability.annotation.CapabilityMethod;
import work.slhaf.partner.framework.agent.model.pojo.Message;
import work.slhaf.partner.framework.agent.state.State;
import work.slhaf.partner.framework.agent.state.StateSerializable;
import work.slhaf.partner.framework.agent.state.StateValue;
import work.slhaf.partner.framework.agent.support.Result;
import work.slhaf.partner.module.memory.runtime.exception.MemoryLookupException;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
@CapabilityCore(value = "memory")
@Slf4j
public class MemoryCore implements StateSerializable {
private final Lock memoryLock = new ReentrantLock();
private final ConcurrentHashMap<String, MemoryUnit> memoryUnits = new ConcurrentHashMap<>();
// 默认值一般只存在于智能体初次启动时
private String memorySessionId = UUID.randomUUID().toString();
public MemoryCore() {
register();
}
@CapabilityMethod
public MemoryUnit updateMemoryUnit(List<Message> chatMessages, String summary) {
memoryLock.lock();
try {
MemoryUnit unit = getMemoryUnit(memorySessionId);
unit.updateTimestamp();
List<Message> conversationMessages = unit.getConversationMessages();
int startIndex = conversationMessages.size();
MemorySlice memorySlice = new MemorySlice(
startIndex,
startIndex + chatMessages.size(),
summary
);
conversationMessages.addAll(chatMessages);
unit.getSlices().add(memorySlice);
normalizeMemoryUnit(unit);
return unit;
} finally {
memoryLock.unlock();
}
}
@CapabilityMethod
public MemoryUnit getMemoryUnit(String unitId) {
MemoryUnit unit = memoryUnits.computeIfAbsent(unitId, id -> {
MemoryUnit newUnit = new MemoryUnit(id);
newUnit.register();
return newUnit;
});
unit.load();
return unit;
}
@CapabilityMethod
public Result<MemorySlice> getMemorySlice(String unitId, String sliceId) {
MemoryUnit memoryUnit = memoryUnits.get(unitId);
if (memoryUnit == null) {
return Result.failure(new MemoryLookupException(
"Memory slice not found: " + unitId + ":" + sliceId,
unitId + ":" + sliceId,
"MEMORY_SLICE"
));
}
for (MemorySlice slice : memoryUnit.getSlices()) {
if (sliceId.equals(slice.getId())) {
return Result.success(slice);
}
}
return Result.failure(new MemoryLookupException(
"Memory slice not found: " + unitId + ":" + sliceId,
unitId + ":" + sliceId,
"MEMORY_SLICE"
));
}
@CapabilityMethod
public Collection<MemoryUnit> listMemoryUnits() {
return new ArrayList<>(memoryUnits.values());
}
@CapabilityMethod
public void refreshMemorySession() {
memorySessionId = UUID.randomUUID().toString();
}
@CapabilityMethod
public String getMemorySessionId() {
return memorySessionId;
}
private void normalizeMemoryUnit(MemoryUnit memoryUnit) {
if (memoryUnit.getTimestamp() == null || memoryUnit.getTimestamp() <= 0) {
memoryUnit.updateTimestamp();
}
int maxEndExclusive = memoryUnit.getConversationMessages().size();
List<MemorySlice> normalizedSlices = new ArrayList<>(memoryUnit.getSlices().size());
for (MemorySlice slice : memoryUnit.getSlices()) {
if (slice == null) {
continue;
}
String sliceId = slice.getId();
if (sliceId == null || sliceId.isBlank()) {
sliceId = UUID.randomUUID().toString();
}
long sliceTimestamp = slice.getTimestamp() == null || slice.getTimestamp() <= 0
? memoryUnit.getTimestamp()
: slice.getTimestamp();
int startIndex = slice.getStartIndex() == null || slice.getStartIndex() < 0
? 0
: Math.min(slice.getStartIndex(), maxEndExclusive);
int endIndex = slice.getEndIndex() == null || slice.getEndIndex() < startIndex
? maxEndExclusive
: Math.min(slice.getEndIndex(), maxEndExclusive);
normalizedSlices.add(MemorySlice.restore(
sliceId,
startIndex,
endIndex,
slice.getSummary(),
sliceTimestamp
));
}
memoryUnit.getSlices().clear();
memoryUnit.getSlices().addAll(normalizedSlices);
memoryUnit.getSlices().sort(Comparator.naturalOrder());
}
@Override
public @NotNull Path statePath() {
return Path.of("core", "memory.json");
}
@Override
public void load(@NotNull JSONObject state) {
String memorySessionId = state.getString("memory_session_id");
if (memorySessionId == null) {
throw new IllegalStateException("Memory session id is missing");
}
JSONArray array = state.getJSONArray("memory_unit_uuid_set");
if (array == null) {
throw new IllegalStateException("Memory unit uuid set is missing");
}
for (int i = 0; i < array.size(); i++) {
String unitUuid = array.getString(i);
if (unitUuid == null) {
throw new IllegalStateException("memory_unit_uuid_set is not a uuid array, index: " + i);
}
MemoryUnit memoryUnit = new MemoryUnit(unitUuid);
memoryUnits.put(unitUuid, memoryUnit);
}
}
@Override
public @NotNull State convert() {
State state = new State();
state.append("memory_session_id", StateValue.str(memorySessionId));
List<String> unitOverview = memoryUnits.keySet().stream()
.toList();
state.append("memory_unit_uuid_set", StateValue.arr(unitOverview));
return state;
}
}
@@ -1,45 +0,0 @@
package work.slhaf.partner.core.memory.pojo;
import lombok.Getter;
import java.util.UUID;
@Getter
public class MemorySlice implements Comparable<MemorySlice> {
private final String id;
private final Integer startIndex;
private final Integer endIndex;
private final String summary;
private final Long timestamp;
public MemorySlice(Integer startIndex, Integer endIndex, String summary) {
this.id = UUID.randomUUID().toString();
this.timestamp = System.currentTimeMillis();
this.startIndex = startIndex;
this.endIndex = endIndex;
this.summary = summary;
}
private MemorySlice(String id, Integer startIndex, Integer endIndex, String summary, Long timestamp) {
this.id = id;
this.startIndex = startIndex;
this.endIndex = endIndex;
this.summary = summary;
this.timestamp = timestamp;
}
public static MemorySlice restore(String id, Integer startIndex, Integer endIndex, String summary, Long timestamp) {
return new MemorySlice(id, startIndex, endIndex, summary, timestamp);
}
@Override
public int compareTo(MemorySlice memorySlice) {
if (memorySlice.getTimestamp() > this.getTimestamp()) {
return -1;
} else if (memorySlice.getTimestamp() < this.timestamp) {
return 1;
}
return 0;
}
}
@@ -1,123 +0,0 @@
package work.slhaf.partner.core.memory.pojo;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import lombok.Getter;
import org.jetbrains.annotations.NotNull;
import work.slhaf.partner.framework.agent.model.pojo.Message;
import work.slhaf.partner.framework.agent.state.State;
import work.slhaf.partner.framework.agent.state.StateSerializable;
import work.slhaf.partner.framework.agent.state.StateValue;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Getter
public class MemoryUnit implements StateSerializable {
private final String id;
private final List<Message> conversationMessages = new ArrayList<>();
private Long timestamp = 0L;
private final List<MemorySlice> slices = new ArrayList<>();
public MemoryUnit(String id) {
this.id = id;
this.register();
}
public void updateTimestamp() {
timestamp = System.currentTimeMillis();
}
@Override
public @NotNull Path statePath() {
return Path.of("core", "memory", "memory-unit" + id + ".json");
}
@Override
public void load(@NotNull JSONObject state) {
Long loadedTimestamp = state.getLong("update_timestamp");
this.timestamp = loadedTimestamp != null ? loadedTimestamp : 0L;
this.conversationMessages.clear();
this.slices.clear();
JSONArray messageArray = state.getJSONArray("conversation_messages");
if (messageArray != null) {
for (int i = 0; i < messageArray.size(); i++) {
JSONObject messageObject = messageArray.getJSONObject(i);
if (messageObject == null) {
continue;
}
String role = messageObject.getString("role");
String content = messageObject.getString("content");
if (role == null || content == null) {
continue;
}
Message message = new Message(Message.Character.fromValue(role), content);
this.conversationMessages.add(message);
}
}
var sliceArray = state.getJSONArray("memory_slices");
if (sliceArray != null) {
for (int i = 0; i < sliceArray.size(); i++) {
JSONObject sliceObject = sliceArray.getJSONObject(i);
if (sliceObject == null) {
continue;
}
String sliceId = sliceObject.getString("id");
Integer startIndex = sliceObject.getInteger("start_index");
Integer endIndex = sliceObject.getInteger("end_index");
String summary = sliceObject.getString("summary");
Long createdTimestamp = sliceObject.getLong("created_timestamp");
if (sliceId == null || startIndex == null || endIndex == null || summary == null || createdTimestamp == null) {
continue;
}
MemorySlice slice = MemorySlice.restore(sliceId, startIndex, endIndex, summary, createdTimestamp);
this.slices.add(slice);
}
}
}
@Override
public @NotNull State convert() {
State state = new State();
state.append("id", StateValue.str(id));
state.append("update_timestamp", StateValue.num(timestamp));
List<StateValue.Obj> convertedMessageList = conversationMessages.stream()
.map(message -> StateValue.obj(Map.of(
"role", message.roleValue(),
"content", message.getContent()
)))
.toList();
state.append("conversation_messages", StateValue.arr(convertedMessageList));
List<StateValue.Obj> convertedSliceList = slices.stream()
.map(slice -> StateValue.obj(Map.of(
"id", slice.getId(),
"start_index", slice.getStartIndex(),
"end_index", slice.getEndIndex(),
"summary", slice.getSummary(),
"created_timestamp", slice.getTimestamp()
)))
.toList();
state.append("memory_slices", StateValue.arr(convertedSliceList));
return state;
}
@Override
public boolean autoLoadOnRegister() {
return false;
}
}
@@ -1,13 +0,0 @@
package work.slhaf.partner.core.memory.pojo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SliceRef {
private String unitId;
private String sliceId;
}
@@ -1,12 +0,0 @@
package work.slhaf.partner.core.perceive;
import work.slhaf.partner.framework.agent.factory.capability.annotation.Capability;
import java.time.Instant;
@Capability(value = "perceive")
public interface PerceiveCapability {
String refreshInteract();
Instant showLastInteract();
}
@@ -1,53 +0,0 @@
package work.slhaf.partner.core.perceive;
import com.alibaba.fastjson2.JSONObject;
import org.jetbrains.annotations.NotNull;
import work.slhaf.partner.framework.agent.factory.capability.annotation.CapabilityCore;
import work.slhaf.partner.framework.agent.factory.capability.annotation.CapabilityMethod;
import work.slhaf.partner.framework.agent.state.State;
import work.slhaf.partner.framework.agent.state.StateSerializable;
import work.slhaf.partner.framework.agent.state.StateValue;
import java.nio.file.Path;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
@CapabilityCore(value = "perceive")
public class PerceiveCore implements StateSerializable {
private Instant lastInteractTime = Instant.ofEpochMilli(0);
public PerceiveCore() {
register();
}
@CapabilityMethod
public String refreshInteract() {
String last = lastInteractTime.atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
lastInteractTime = Instant.now();
return last;
}
@CapabilityMethod
public Instant showLastInteract() {
return lastInteractTime;
}
@Override
public @NotNull Path statePath() {
return Path.of("core", "perceive.json");
}
@Override
public void load(@NotNull JSONObject state) {
this.lastInteractTime = Instant.ofEpochMilli(state.getLong("last_interact_time"));
}
@Override
public @NotNull State convert() {
State state = new State();
state.append("last_interact_time", StateValue.num(lastInteractTime.toEpochMilli()));
return state;
}
}
@@ -1,14 +0,0 @@
package work.slhaf.partner.module.action.builtin;
import java.util.List;
public interface BuiltinActionProvider {
List<BuiltinActionRegistry.BuiltinActionDefinition> provideBuiltinActions();
String createActionKey(String actionName);
default String param(String requirement, String type, String detail) {
return requirement + "|" + type + "|" + detail;
}
}
@@ -1,130 +0,0 @@
package work.slhaf.partner.module.action.builtin;
import lombok.Getter;
import lombok.NonNull;
import work.slhaf.partner.core.action.ActionCapability;
import work.slhaf.partner.core.action.entity.MetaActionInfo;
import work.slhaf.partner.core.action.exception.ActionLookupException;
import work.slhaf.partner.framework.agent.factory.capability.annotation.InjectCapability;
import work.slhaf.partner.framework.agent.factory.component.abstracts.AbstractAgentModule;
import work.slhaf.partner.framework.agent.factory.component.annotation.Init;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import static work.slhaf.partner.core.action.ActionCore.BUILTIN_LOCATION;
public class BuiltinActionRegistry extends AbstractAgentModule.Standalone {
@Getter
private final Map<String, BuiltinActionDefinition> definitions = new LinkedHashMap<>();
@InjectCapability
private ActionCapability actionCapability;
@Init
public void init() {
actionCapability.runnerClient().setBuiltinActionRegistry(this);
}
public void register(BuiltinActionProvider provider) {
List<BuiltinActionDefinition> builtinActionDefinitions = provider.provideBuiltinActions();
if (builtinActionDefinitions == null || builtinActionDefinitions.isEmpty()) {
return;
}
Map<String, MetaActionInfo> metaActionInfos = new LinkedHashMap<>();
for (BuiltinActionDefinition definition : builtinActionDefinitions) {
definitions.put(definition.actionKey(), definition);
metaActionInfos.put(definition.actionKey(), definition.metaActionInfo());
}
actionCapability.registerMetaActions(metaActionInfos);
}
public void defineBuiltinAction(String name, MetaActionInfo metaActionInfo, Function<Map<String, Object>, String> invoker) {
BuiltinActionDefinition definition = new BuiltinActionDefinition(BUILTIN_LOCATION + "::" + name, metaActionInfo, invoker);
definitions.put(definition.actionKey(), definition);
actionCapability.registerMetaActions(Map.of(definition.actionKey(), definition.metaActionInfo()));
}
public String call(@NonNull String actionKey, @NonNull Map<String, Object> params) {
BuiltinActionDefinition definition = definitions.get(actionKey);
if (definition == null) {
throw new ActionLookupException(
"Builtin action definition not found: " + actionKey,
actionKey,
"BUILTIN_DEFINITION"
);
}
String result = definition.invoker().apply(params);
if (result == null) {
return "null";
}
return result;
}
public record BuiltinActionDefinition(
String actionKey,
MetaActionInfo metaActionInfo,
Function<Map<String, Object>, String> invoker
) {
static String requireString(Map<String, Object> params, String key) {
Object value = params.get(key);
if (value == null) {
throw new IllegalArgumentException("缺少参数: " + key);
}
if (!(value instanceof String s)) {
throw new IllegalArgumentException("参数 " + key + " 必须为字符串");
}
return s;
}
static String optionalString(Map<String, Object> params, String key, String defaultValue) {
Object value = params.get(key);
if (value == null) {
return defaultValue;
}
if (!(value instanceof String s)) {
throw new IllegalArgumentException("参数 " + key + " 必须为字符串");
}
return s;
}
static Integer requireInt(Map<String, Object> params, String key) {
Object value = params.get(key);
if (value == null) {
throw new IllegalArgumentException("缺少参数: " + key);
}
if (value instanceof Number number) {
return number.intValue();
}
try {
if (value instanceof String string) {
return Integer.parseInt(string);
}
} catch (NumberFormatException ignored) {
}
throw new IllegalArgumentException("参数 " + key + " 必须为整数");
}
static Integer optionalInt(Map<String, Object> params, String key, Integer defaultValue) {
Object value = params.get(key);
if (value == null) {
return defaultValue;
}
if (value instanceof Number n) {
return n.intValue();
}
try {
if (value instanceof String string) {
return Integer.parseInt(string);
}
} catch (NumberFormatException ignored) {
}
throw new IllegalArgumentException("参数 " + key + " 必须为整数");
}
}
}
@@ -1,167 +0,0 @@
package work.slhaf.partner.module.action.builtin;
import com.alibaba.fastjson2.JSONObject;
import kotlin.Unit;
import org.jetbrains.annotations.NotNull;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import work.slhaf.partner.core.action.entity.MetaActionInfo;
import work.slhaf.partner.core.cognition.CognitionCapability;
import work.slhaf.partner.core.cognition.context.BlockContent;
import work.slhaf.partner.core.cognition.context.ContextBlock;
import work.slhaf.partner.core.memory.MemoryCapability;
import work.slhaf.partner.core.memory.pojo.MemorySlice;
import work.slhaf.partner.core.memory.pojo.MemoryUnit;
import work.slhaf.partner.framework.agent.factory.capability.annotation.InjectCapability;
import work.slhaf.partner.framework.agent.factory.component.annotation.AgentComponent;
import work.slhaf.partner.framework.agent.factory.component.annotation.Init;
import work.slhaf.partner.framework.agent.factory.component.annotation.InjectModule;
import work.slhaf.partner.framework.agent.support.Result;
import java.util.*;
import java.util.function.Function;
import static work.slhaf.partner.core.action.ActionCore.BUILTIN_LOCATION;
@AgentComponent
class BuiltinCapabilityActionProvider implements BuiltinActionProvider {
private static final String CAPABILITY_LOCATION = BUILTIN_LOCATION + "::" + "capability";
private final Set<String> basicTags = Set.of("Builtin MetaAction", "Agent Capability");
@InjectCapability
private CognitionCapability cognitionCapability;
@InjectCapability
private MemoryCapability memoryCapability;
@InjectModule
private BuiltinActionRegistry builtinActionRegistry;
@Init
public void init() {
builtinActionRegistry.register(this);
}
@Override
public List<BuiltinActionRegistry.BuiltinActionDefinition> provideBuiltinActions() {
return List.of(
buildInitiateTurnDefinition(),
buildMemoryRecallDefinition()
);
}
private BuiltinActionRegistry.BuiltinActionDefinition buildMemoryRecallDefinition() {
Set<String> tags = new HashSet<>(basicTags);
tags.add("Memory");
MetaActionInfo info = new MetaActionInfo(
false,
null,
Map.of(
"unit_id", param("required", "string", "Memory unit id that contains the target memory slice."),
"slice_id", param("required", "string", "Memory slice id to load into short-lived context.")
),
"Purpose: bring a known memory slice back into the agent's working context so later reasoning can use the original conversation messages. Use when: the exact memory unit and slice have already been identified. Input: memory slice id and memory unit id. Does not: search, rank, summarize, or infer memories. Returns: whether the slice was recalled and a short result message.",
tags,
Set.of(),
Set.of(),
false,
JSONObject.of(
"ok", "boolean, whether the target memory slice is found and recalled successfully",
"message", "string, short execution result description"
)
);
Function<Map<String, Object>, String> invoker = params -> {
String unitId = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "unit_id");
String sliceId = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "slice_id");
Result<MemorySlice> sliceResult = memoryCapability.getMemorySlice(unitId, sliceId);
if (sliceResult.exceptionOrNull() != null) {
return JSONObject.of(
"ok", false,
"message", sliceResult.exceptionOrNull().getLocalizedMessage()
).toJSONString();
}
MemorySlice slice = sliceResult.getOrThrow();
MemoryUnit unit = memoryCapability.getMemoryUnit(unitId);
cognitionCapability.contextWorkspace().register(new ContextBlock(
buildMemoryRecallFullBlock(unit, slice),
Set.of(ContextBlock.FocusedDomain.MEMORY),
60,
16,
28
));
return JSONObject.of(
"ok", true,
"message", "Memory slice found and recalled into context"
).toJSONString();
};
return new BuiltinActionRegistry.BuiltinActionDefinition(
createActionKey("memory_recall"),
info,
invoker
);
}
private @NotNull BlockContent buildMemoryRecallFullBlock(MemoryUnit unit, MemorySlice slice) {
return new BlockContent("memory_recall", "memory_capability") {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
root.setAttribute("unit_id", unit.getId());
root.setAttribute("slice_id", slice.getId());
appendRepeatedElements(document, root, "message", unit.getConversationMessages().subList(slice.getStartIndex(), slice.getEndIndex()), (messageElement, message) -> {
messageElement.setAttribute("role", message.getRole().name().toLowerCase(Locale.ROOT));
messageElement.setTextContent(message.getContent());
return Unit.INSTANCE;
});
}
};
}
/**
* 用于发起自对话的 Builtin MetaAction
*
* @return 内建 MetaAction 定义数据
*/
private BuiltinActionRegistry.BuiltinActionDefinition buildInitiateTurnDefinition() {
Set<String> tags = new HashSet<>(basicTags);
tags.add("Agent Turn");
MetaActionInfo info = new MetaActionInfo(
false,
null,
Map.of(
"input", param("required", "string", "Prompt content for the internal Agent Turn."),
"target", param("required", "string", "Target user expected to receive/respond to the turn.")
),
"Purpose: initiate a self-dialogue or proactive dialogue by sending input to a target participant. Use when: the agent needs to start a new turn for reflection, clarification, coordination, or active communication. Input: input content and the user expected to respond. Returns: confirmation that the turn was initiated. Does not: wait for or include the target's answer.",
tags,
Set.of(),
Set.of(),
false,
JSONObject.of(
"result", "turn initiate result"
)
);
Function<Map<String, Object>, String> invoker = params -> {
String input = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "input");
String target = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "target");
cognitionCapability.initiateTurn(input, target);
return "agent turn initiated";
};
return new BuiltinActionRegistry.BuiltinActionDefinition(
createActionKey("initiate_turn"),
info,
invoker
);
}
@Override
public String createActionKey(String actionName) {
return CAPABILITY_LOCATION + "::" + actionName;
}
}
@@ -1,625 +0,0 @@
package work.slhaf.partner.module.action.builtin;
import com.alibaba.fastjson2.JSONObject;
import lombok.AllArgsConstructor;
import work.slhaf.partner.core.action.entity.MetaActionInfo;
import work.slhaf.partner.core.action.runner.execution.CommandExecutionService;
import work.slhaf.partner.core.action.runner.policy.ExecutionPolicyRegistry;
import work.slhaf.partner.core.action.runner.policy.WrappedLaunchSpec;
import work.slhaf.partner.framework.agent.factory.component.annotation.AgentComponent;
import work.slhaf.partner.framework.agent.factory.component.annotation.Init;
import work.slhaf.partner.framework.agent.factory.component.annotation.InjectModule;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import static work.slhaf.partner.core.action.ActionCore.BUILTIN_LOCATION;
@AgentComponent
class BuiltinCommandActionProvider implements BuiltinActionProvider {
private static final String COMMAND_LOCATION = BUILTIN_LOCATION + "::" + "command";
private static final String COMMAND_ARG_PREFIX = "arg";
private static final int DEFAULT_READ_LIMIT = 4096;
private static final int SUMMARY_MAX_LINES = 5;
private static final int SUMMARY_MAX_LENGTH = 2048;
private static final Duration COMMAND_SESSION_TTL = Duration.ofMinutes(10);
private static final int COMMAND_SESSION_TAIL_LIMIT = 64 * 1024;
private static final Duration OUTPUT_FLUSH_INTERVAL = Duration.ofMillis(100);
private static final Path COMMAND_SESSION_LOG_DIR = Path.of(System.getProperty("java.io.tmpdir"), "partner-command-sessions");
private final Set<String> basicTags = Set.of("Builtin MetaAction", "System Command Tool");
private final ConcurrentHashMap<String, CommandHandle> commandHandles = new ConcurrentHashMap<>();
private final CommandExecutionService commandExecutionService = CommandExecutionService.INSTANCE;
@InjectModule
private BuiltinActionRegistry builtinActionRegistry;
@Init
public void init() {
builtinActionRegistry.register(this);
}
private Map<String, String> commandParams() {
Map<String, String> params = new LinkedHashMap<>();
params.put("arg", param("required", "string", "Command executable name or path."));
params.put("arg1", param("optional", "string", "First command argument after the executable."));
params.put("arg2", param("optional", "string", "Second command argument after the executable."));
params.put("argN", param("optional", "string", "Additional command arguments after arg2. Use consecutive numeric keys such as arg3, arg4, arg5, ... when more arguments are needed."));
return params;
}
private Map<String, String> commandSessionParams() {
Map<String, String> params = new LinkedHashMap<>();
params.put("desc", param("required", "string", "Short human-readable description of why this background command session exists."));
params.putAll(commandParams());
return params;
}
@Override
public List<BuiltinActionRegistry.BuiltinActionDefinition> provideBuiltinActions() {
return List.of(
buildCommandExecuteDefinition(),
buildCommandStartDefinition(),
buildCommandInspectDefinition(),
buildCommandReadDefinition(),
buildCommandCancelDefinition(),
buildCommandOverviewDefinition()
);
}
/**
* 用于直接执行的 Builtin MetaAction
*
* @return 内建 MetaAction 定义数据,参数为常规命令列表,返回值为该命令的响应内容
*/
private BuiltinActionRegistry.BuiltinActionDefinition buildCommandExecuteDefinition() {
Set<String> tags = new HashSet<>(basicTags);
tags.add("Command Execution");
MetaActionInfo info = new MetaActionInfo(
false,
null,
commandParams(),
"Purpose: execute a short, non-interactive system command synchronously and return its completed output. Inputs: argv entries through arg/argN. Returns: JSON with result containing stdout/stderr/exit summary text. Use when: the task can finish quickly in one process call. Notes: do not use for long-running services, interactive programs, streaming output, or processes that need later inspection.",
tags,
Set.of(),
Set.of(createActionKey("inspect")),
false,
JSONObject.of("result", "Command execution result text.")
);
Function<Map<String, Object>, String> invoker = params -> {
List<String> commands = requireCommandArguments(params);
CommandExecutionService.Result result = commandExecutionService.exec(wrapCommands(commands));
return JSONObject.of("result", result.getTotal()).toJSONString();
};
return new BuiltinActionRegistry.BuiltinActionDefinition(
createActionKey("execute"),
info,
invoker
);
}
/**
* 用于启动后台命令的 Builtin MetaAction,后台将持续接收 stdout/stderr
*
* @return 内建 MetaAction 定义数据,参数为命令列表及进程描述,返回值为进程句柄 id
*/
private BuiltinActionRegistry.BuiltinActionDefinition buildCommandStartDefinition() {
Set<String> tags = new HashSet<>(basicTags);
tags.add("Command Session");
MetaActionInfo info = new MetaActionInfo(
false,
null,
commandSessionParams(),
"Purpose: start a long-running or streaming command as a background session. Inputs: session description and argument entries. Returns: JSON with executionId; pass it as id to inspect, read, or cancel. Use when: the command may run for a while, produce incremental output, or need later control. Notes: usually follow with builtin::command::inspect or builtin::command::read.",
tags,
Set.of(),
Set.of(createActionKey("inspect")),
false,
JSONObject.of("executionId", "Command execution session id.")
);
Function<Map<String, Object>, String> invoker = params -> {
cleanupExpiredHandles();
String desc = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "desc");
List<String> commands = requireCommandArguments(params);
CommandExecutionService.CommandSession session = commandExecutionService.createSessionTask(wrapCommands(commands));
String executionId = UUID.randomUUID().toString();
Path stdoutLogPath = createSessionLogPath(executionId, "stdout.log");
Path stderrLogPath = createSessionLogPath(executionId, "stderr.log");
CommandHandle handle = new CommandHandle(
executionId,
desc,
commands,
Instant.now(),
session.getProcess(),
session.getStdoutBuffer(),
session.getStderrBuffer(),
new StringBuilder(),
new StringBuilder(),
stdoutLogPath,
stderrLogPath,
null,
null
);
commandHandles.put(executionId, handle);
startOutputFlusher(handle);
monitorProcess(handle);
return JSONObject.of("executionId", executionId).toJSONString();
};
return new BuiltinActionRegistry.BuiltinActionDefinition(createActionKey("start"), info, invoker);
}
/**
* 用于返回指定后台 Builtin MetaAction 的摘要内容
*
* @return 内建 MetaAction 定义数据,参数为进程 id,返回值为摘要内容 JSON
*/
private BuiltinActionRegistry.BuiltinActionDefinition buildCommandInspectDefinition() {
Set<String> tags = new HashSet<>(basicTags);
tags.add("Command Session");
MetaActionInfo info = new MetaActionInfo(
false,
null,
Map.of("id", param("required", "string", "Command executionId returned by builtin::command::start.")),
"Purpose: inspect status and short output summaries for an existing background command session. Inputs: id from command::start. Returns: JSON with executionId, desc, exitCode, stdoutSize, stderrSize, stdoutSummary, stderrSummary, startAt, and endAt. Use when: deciding whether a background command is still running, failed, or needs a full output read. Notes: use builtin::command::read for complete or paginated output.",
tags,
Set.of(createActionKey("overview")),
Set.of(),
false,
JSONObject.of(
"executionId", "Command execution session id.",
"desc", "Command session description.",
"exitCode", "Process exit code. Null when the process is still running.",
"stdoutSize", "Current stdout buffer size in characters.",
"stderrSize", "Current stderr buffer size in characters.",
"stdoutSummary", "Summary text for stdout output.",
"stderrSummary", "Summary text for stderr output.",
"startAt", "Command session start time.",
"endAt", "Command session end time. Null when the process is still running."
)
);
Function<Map<String, Object>, String> invoker = params -> {
cleanupExpiredHandles();
CommandHandle handle = requireHandle(BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "id"));
flushHandleBuffers(handle);
return JSONObject.of(
"executionId", handle.executionId,
"desc", handle.desc,
"exitCode", handle.exitCode,
"stdoutSize", streamLength(handle.stdoutLogPath, handle.stdoutSourceBuffer),
"stderrSize", streamLength(handle.stderrLogPath, handle.stderrSourceBuffer),
"stdoutSummary", summarizeBuffer(handle.stdoutBuffer),
"stderrSummary", summarizeBuffer(handle.stderrBuffer),
"startAt", handle.startAt,
"endAt", handle.exitAt
).toJSONString();
};
return new BuiltinActionRegistry.BuiltinActionDefinition(createActionKey("inspect"), info, invoker);
}
/**
* 用于读取指定后台 Builtin MetaAction 的输出内容
*
* @return 内建 MetaAction 定义数据,参数为进程 id 与读取流(stdout/stderr),返回值为读取内容 JSON
*/
private BuiltinActionRegistry.BuiltinActionDefinition buildCommandReadDefinition() {
Set<String> tags = new HashSet<>(basicTags);
tags.add("Command Session");
tags.add("Command Read");
MetaActionInfo info = new MetaActionInfo(
false,
null,
Map.of(
"id", param("required", "string", "Command executionId returned by builtin::command::start."),
"stream", param("optional", "string", "Target stream to read. Allowed values: stdout, stderr. Default: stdout."),
"offset", param("optional", "int", "Start byte offset in the persisted stream log. Default: 0. Use nextOffset from a previous read to continue."),
"limit", param("optional", "int", "Maximum bytes to read from the persisted stream log. Default: 4096. Must be greater than 0.")
),
"Purpose: read stdout or stderr content from a background command session with offset pagination. Returns: JSON with content, contentTruncated, nextOffset, and eof. Use when: inspect summaries are insufficient or a command failed and stderr/stdout details are needed. Notes: repeat with nextOffset until eof or enough evidence is collected.",
tags,
Set.of(createActionKey("overview")),
Set.of(),
false,
JSONObject.of(
"executionId", "Command execution session id.",
"desc", "Command session description.",
"stream", "The stream that was read, stdout or stderr.",
"content", "Content read from the selected stream in this call.",
"contentTruncated", "Whether the content was truncated because of the read limit.",
"offset", "Read start offset used in this call.",
"nextOffset", "Suggested next offset for the following read.",
"eof", "Whether the stream has reached end-of-file and the process has already exited."
)
);
Function<Map<String, Object>, String> invoker = params -> {
cleanupExpiredHandles();
CommandHandle handle = requireHandle(BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "id"));
flushHandleBuffers(handle);
String stream = BuiltinActionRegistry.BuiltinActionDefinition.optionalString(params, "stream", "stdout");
if (!"stdout".equals(stream) && !"stderr".equals(stream)) {
throw new IllegalArgumentException("参数 stream 只能为 stdout 或 stderr");
}
int offset = BuiltinActionRegistry.BuiltinActionDefinition.optionalInt(params, "offset", 0);
int limit = BuiltinActionRegistry.BuiltinActionDefinition.optionalInt(params, "limit", DEFAULT_READ_LIMIT);
if (offset < 0) {
throw new IllegalArgumentException("参数 offset 必须大于等于 0");
}
if (limit <= 0) {
throw new IllegalArgumentException("参数 limit 必须大于 0");
}
Path logPath = "stderr".equals(stream) ? handle.stderrLogPath : handle.stdoutLogPath;
StreamChunk chunk = readChunk(logPath, offset, limit);
boolean eof = !handle.isRunning() && chunk.nextOffset >= chunk.totalLength;
return JSONObject.of(
"executionId", handle.executionId,
"desc", handle.desc,
"stream", stream,
"content", chunk.content,
"contentTruncated", chunk.truncated,
"offset", chunk.offset,
"nextOffset", chunk.nextOffset,
"eof", eof
).toJSONString();
};
return new BuiltinActionRegistry.BuiltinActionDefinition(createActionKey("read"), info, invoker);
}
/**
* 用于取消指定后台命令的 Builtin MetaAction
*
* @return 内建 MetaAction 定义数据,参数为进程 id,返回值为是否成功取消
*/
private BuiltinActionRegistry.BuiltinActionDefinition buildCommandCancelDefinition() {
Set<String> tags = new HashSet<>(basicTags);
tags.add("Command Session");
tags.add("Command Cancel");
MetaActionInfo info = new MetaActionInfo(
false,
null,
Map.of("id", param("required", "string", "Command executionId returned by builtin::command::start.")),
"Purpose: terminate an existing background command session. Inputs: id from command::start. Returns: JSON with ok and executionId. Use when: the user explicitly asks to stop a command, or correction determines the running command is wrong, stuck, unsafe, or no longer needed. Notes: this has process side effects and should not be used as a status check.",
tags,
Set.of(),
Set.of(createActionKey("overview")),
false,
JSONObject.of(
"ok", "Whether the command has been cancelled.",
"executionId", "Command execution session id."
)
);
Function<Map<String, Object>, String> invoker = params -> {
cleanupExpiredHandles();
CommandHandle handle = requireHandle(BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "id"));
flushHandleBuffers(handle);
if (handle.process.isAlive()) {
handle.process.destroy();
waitProcessExit(handle.process, 200);
if (handle.process.isAlive()) {
handle.process.destroyForcibly();
waitProcessExit(handle.process, 200);
}
}
if (!handle.process.isAlive()) {
try {
handle.exitCode = handle.process.exitValue();
} catch (IllegalThreadStateException ignored) {
}
if (handle.exitAt == null) {
handle.exitAt = Instant.now();
}
}
return JSONObject.of(
"ok", !handle.process.isAlive(),
"executionId", handle.executionId
).toJSONString();
};
return new BuiltinActionRegistry.BuiltinActionDefinition(createActionKey("cancel"), info, invoker);
}
/**
* 用于列出全量后台进程的 Builtin MetaAction
*
* @return 内建 MetaAction 定义数据,无参数,返回值为后台进程集合 JSON
*/
private BuiltinActionRegistry.BuiltinActionDefinition buildCommandOverviewDefinition() {
Set<String> tags = new HashSet<>(basicTags);
tags.add("Command Session");
tags.add("Command Overview");
MetaActionInfo info = new MetaActionInfo(
false,
null,
Map.of(),
"Purpose: list known background command sessions. Inputs: none. Returns: JSON with result array of session overviews containing executionId, desc, and exitCode. Use when: an id is missing or correction needs to discover active command sessions before inspect/read/cancel. Notes: this does not start, stop, or read a command.",
tags,
Set.of(createActionKey("start")),
Set.of(createActionKey("inspect"), createActionKey("read"), createActionKey("cancel")),
false,
JSONObject.of(
"result", "Array of command session overview items.",
"result[].executionId", "Command execution session id for each overview item.",
"result[].desc", "Command session description for each overview item.",
"result[].exitCode", "Process exit code for each overview item. Null when still running."
)
);
Function<Map<String, Object>, String> invoker = params -> {
cleanupExpiredHandles();
commandHandles.values().forEach(this::flushHandleBuffers);
List<JSONObject> items = commandHandles.values().stream()
.sorted(Comparator.comparing(handle -> handle.startAt))
.map(handle -> JSONObject.of(
"executionId", handle.executionId,
"desc", handle.desc,
"exitCode", handle.exitCode
))
.toList();
return JSONObject.of("result", items).toJSONString();
};
return new BuiltinActionRegistry.BuiltinActionDefinition(createActionKey("overview"), info, invoker);
}
@Override
public String createActionKey(String actionName) {
return COMMAND_LOCATION + "::" + actionName;
}
private void monitorProcess(CommandHandle handle) {
Thread.startVirtualThread(() -> {
try {
handle.exitCode = handle.process.waitFor();
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
} finally {
flushHandleBuffers(handle);
handle.exitAt = Instant.now();
}
});
}
private void startOutputFlusher(CommandHandle handle) {
Thread.startVirtualThread(() -> {
try {
while (handle.process.isAlive()) {
flushHandleBuffers(handle);
Thread.sleep(OUTPUT_FLUSH_INTERVAL.toMillis());
}
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
} finally {
flushHandleBuffers(handle);
}
});
}
private void flushHandleBuffers(CommandHandle handle) {
flushStreamBuffer(handle.stdoutSourceBuffer, handle.stdoutBuffer, handle.stdoutLogPath);
flushStreamBuffer(handle.stderrSourceBuffer, handle.stderrBuffer, handle.stderrLogPath);
}
private void flushStreamBuffer(StringBuilder sourceBuffer, StringBuilder tailBuffer, Path logPath) {
String chunk;
synchronized (sourceBuffer) {
if (sourceBuffer.isEmpty()) {
return;
}
chunk = sourceBuffer.toString();
sourceBuffer.setLength(0);
}
try {
Files.writeString(logPath, chunk, StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
} catch (IOException e) {
throw new IllegalStateException("写入命令输出日志失败: " + logPath, e);
}
synchronized (tailBuffer) {
tailBuffer.append(chunk);
trimTailBuffer(tailBuffer);
}
}
private void trimTailBuffer(StringBuilder buffer) {
if (buffer.length() <= COMMAND_SESSION_TAIL_LIMIT) {
return;
}
buffer.delete(0, buffer.length() - COMMAND_SESSION_TAIL_LIMIT);
}
private void cleanupExpiredHandles() {
Instant now = Instant.now();
Iterator<Map.Entry<String, CommandHandle>> iterator = commandHandles.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, CommandHandle> entry = iterator.next();
CommandHandle handle = entry.getValue();
if (!isExpired(handle, now)) {
continue;
}
flushHandleBuffers(handle);
cleanupLogFiles(handle);
iterator.remove();
}
}
private boolean isExpired(CommandHandle handle, Instant now) {
Instant exitTime = handle.exitAt;
return exitTime != null && !exitTime.plus(COMMAND_SESSION_TTL).isAfter(now);
}
private Path createSessionLogPath(String executionId, String fileName) {
try {
Files.createDirectories(COMMAND_SESSION_LOG_DIR);
Path file = COMMAND_SESSION_LOG_DIR.resolve(executionId + "-" + fileName);
Files.deleteIfExists(file);
Files.createFile(file);
return file;
} catch (IOException e) {
throw new IllegalStateException("创建命令会话日志文件失败", e);
}
}
private long streamLength(Path logPath, StringBuilder sourceBuffer) {
long fileLength;
try {
fileLength = Files.exists(logPath) ? Files.size(logPath) : 0L;
} catch (IOException e) {
throw new IllegalStateException("读取命令会话日志长度失败", e);
}
synchronized (sourceBuffer) {
return fileLength + sourceBuffer.length();
}
}
private StreamChunk readChunk(Path logPath, int offset, int limit) {
if (!Files.exists(logPath)) {
return new StreamChunk("", 0, 0, false, 0);
}
try (RandomAccessFile raf = new RandomAccessFile(logPath.toFile(), "r")) {
int totalLength = (int) raf.length();
int safeOffset = Math.min(offset, totalLength);
int nextOffset = Math.min(safeOffset + limit, totalLength);
byte[] bytes = new byte[nextOffset - safeOffset];
raf.seek(safeOffset);
raf.readFully(bytes);
String content = new String(bytes, StandardCharsets.UTF_8);
return new StreamChunk(content, safeOffset, nextOffset, nextOffset < totalLength, totalLength);
} catch (IOException e) {
throw new IllegalStateException("读取命令会话日志失败", e);
}
}
private void cleanupLogFiles(CommandHandle handle) {
try {
Files.deleteIfExists(handle.stdoutLogPath);
Files.deleteIfExists(handle.stderrLogPath);
} catch (IOException ignored) {
}
}
private WrappedLaunchSpec wrapCommands(List<String> commands) {
return ExecutionPolicyRegistry.INSTANCE.prepare(commands);
}
private CommandHandle requireHandle(String id) {
CommandHandle handle = commandHandles.get(id);
if (handle == null) {
throw new IllegalArgumentException("未找到对应命令会话: " + id);
}
return handle;
}
private List<String> requireCommandArguments(Map<String, Object> params) {
List<Map.Entry<String, Object>> unexpectedArgs = params.entrySet().stream()
.filter(entry -> entry.getKey().startsWith(COMMAND_ARG_PREFIX))
.filter(entry -> !COMMAND_ARG_PREFIX.equals(entry.getKey()) && !isNumberedCommandArg(entry.getKey()))
.toList();
if (!unexpectedArgs.isEmpty()) {
throw new IllegalArgumentException("非法命令参数: " + unexpectedArgs.getFirst().getKey());
}
List<String> commands = new ArrayList<>();
commands.add(BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, COMMAND_ARG_PREFIX));
params.entrySet().stream()
.filter(entry -> isNumberedCommandArg(entry.getKey()))
.sorted(Comparator.comparingInt(entry -> commandArgIndex(entry.getKey())))
.forEach(entry -> commands.add(BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, entry.getKey())));
return commands;
}
private boolean isNumberedCommandArg(String key) {
if (!key.startsWith(COMMAND_ARG_PREFIX) || COMMAND_ARG_PREFIX.equals(key)) {
return false;
}
String suffix = key.substring(COMMAND_ARG_PREFIX.length()).trim();
if (suffix.isEmpty()) {
return false;
}
return suffix.chars().allMatch(Character::isDigit);
}
private int commandArgIndex(String key) {
return Integer.parseInt(key.substring(COMMAND_ARG_PREFIX.length()).trim());
}
private String bufferSnapshot(StringBuilder buffer) {
synchronized (buffer) {
return buffer.toString();
}
}
private String summarizeBuffer(StringBuilder buffer) {
String snapshot = bufferSnapshot(buffer);
if (snapshot.isBlank()) {
return "";
}
List<String> lines = snapshot.lines().toList();
if (lines.size() <= SUMMARY_MAX_LINES * 2) {
return trimSummary(snapshot);
}
List<String> head = lines.subList(0, SUMMARY_MAX_LINES);
List<String> tail = lines.subList(Math.max(lines.size() - SUMMARY_MAX_LINES, SUMMARY_MAX_LINES), lines.size());
String summary = String.join("\n", head)
+ "\n...\n"
+ String.join("\n", tail);
return trimSummary(summary);
}
private String trimSummary(String content) {
if (content.length() <= SUMMARY_MAX_LENGTH) {
return content;
}
return content.substring(0, SUMMARY_MAX_LENGTH);
}
private void waitProcessExit(Process process, long millis) {
try {
process.waitFor(millis, java.util.concurrent.TimeUnit.MILLISECONDS);
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
}
}
private record StreamChunk(String content, int offset, int nextOffset, boolean truncated, int totalLength) {
}
@AllArgsConstructor
private static class CommandHandle {
private String executionId;
private String desc;
private List<String> commands;
private Instant startAt;
private Process process;
private StringBuilder stdoutSourceBuffer;
private StringBuilder stderrSourceBuffer;
/**
* stdout/stderr 摘要 tail
*/
private StringBuilder stdoutBuffer;
private StringBuilder stderrBuffer;
private Path stdoutLogPath;
private Path stderrLogPath;
/**
* 退出码:进程未结束时可为 null
*/
private volatile Integer exitCode;
private volatile Instant exitAt;
boolean isRunning() {
return exitCode == null && process.isAlive();
}
}
}
@@ -1,261 +0,0 @@
package work.slhaf.partner.module.action.builtin;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import kotlin.Unit;
import work.slhaf.partner.core.action.ActionCapability;
import work.slhaf.partner.core.action.entity.*;
import work.slhaf.partner.framework.agent.factory.capability.annotation.InjectCapability;
import work.slhaf.partner.framework.agent.factory.component.annotation.AgentComponent;
import work.slhaf.partner.framework.agent.factory.component.annotation.Init;
import work.slhaf.partner.framework.agent.factory.component.annotation.InjectModule;
import work.slhaf.partner.module.action.scheduler.ActionScheduler;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.ZonedDateTime;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import static work.slhaf.partner.core.action.ActionCore.BUILTIN_LOCATION;
@AgentComponent
class BuiltinDynamicActionProvider implements BuiltinActionProvider {
private static final String ORIGIN_LOCATION = "origin";
private static final long TEMP_ACTION_TTL_MILLIS = 30 * 60 * 1000L;
private static final String DYNAMIC_LOCATION = BUILTIN_LOCATION + "::" + "dynamic";
private final Set<String> basicTags = Set.of("Builtin MetaAction", "Dynamic Generation");
private final ConcurrentHashMap<String, TempDynamicActionRecord> tempDynamicActions = new ConcurrentHashMap<>();
@InjectCapability
private ActionCapability actionCapability;
@InjectModule
private ActionScheduler actionScheduler;
@InjectModule
private BuiltinActionRegistry builtinActionRegistry;
@Init
public void init() {
builtinActionRegistry.register(this);
}
@Override
public List<BuiltinActionRegistry.BuiltinActionDefinition> provideBuiltinActions() {
return List.of(
buildGenerateDynamicActionDefinition(),
buildPersistDynamicActionDefinition()
);
}
private BuiltinActionRegistry.BuiltinActionDefinition buildGenerateDynamicActionDefinition() {
MetaActionInfo info = new MetaActionInfo(
false,
null,
Map.of(
"desc", param("required", "string", "Human-readable description for the generated temporary action. This becomes the new action's planning description."),
"code", param("required", "string", "Dynamic action source code content."),
"codeType", param("required", "string", "Source file extension without or with leading dot, for example py, sh, js, or .py."),
"launcher", param("required", "string", "Interpreter or launcher command used to run the generated code, for example python3, bash, or node."),
"meta", param("required", "json-string", "MetaActionInfo fields for the generated action. Supported fields: io, params, tags, preActions, postActions, strictDependencies, responseSchema.")
),
"Purpose: generate and register a temporary script as an ORIGIN MetaAction from supplied source code. Inputs: desc, code, codeType, launcher, and meta info. Returns: JSON with ok and temporary actionKey. Use when: existing MetaActions cannot perform the task and a temporary code-backed action is needed. Notes: this registers executable code with cleanup TTL; the generated action is temporary until builtin::dynamic::persist is called.",
basicTags,
Set.of(),
Set.of(createActionKey("persist")),
false,
JSONObject.of(
"ok", "Whether the dynamic action was generated successfully.",
"actionKey", "Temporary ORIGIN actionKey."
)
);
return new BuiltinActionRegistry.BuiltinActionDefinition(createActionKey("generate"), info, params -> {
String desc = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "desc").trim();
if (desc.isEmpty()) {
throw new IllegalArgumentException("参数 desc 不能为空");
}
String code = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "code");
String codeType = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "codeType");
String launcher = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "launcher");
String metaJson = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "meta");
JSONObject meta = parseMeta(metaJson);
MetaActionInfo metaActionInfo = buildMetaActionInfo(meta, launcher, desc);
String tempName = "dyn-" + shortUuid();
String location = actionCapability.runnerClient().buildTmpPath(tempName, codeType);
MetaAction tempAction = new MetaAction(
tempName,
metaActionInfo.getIo(),
launcher,
MetaAction.Type.ORIGIN,
location
);
String actionKey = ORIGIN_LOCATION + "::" + location;
try {
actionCapability.runnerClient().tmpSerialize(tempAction, code, codeType);
} catch (java.io.IOException e) {
throw new IllegalStateException("临时动态行动序列化失败", e);
}
actionCapability.registerMetaActions(Map.of(actionKey, metaActionInfo));
ActionFileMetaData fileMetaData = buildActionFileMetaData(location, code, codeType);
StateAction cleanupAction = buildCleanupAction(actionKey);
tempDynamicActions.put(actionKey, new TempDynamicActionRecord(
actionKey,
location,
cleanupAction.getUuid(),
fileMetaData,
metaActionInfo
));
actionScheduler.schedule(cleanupAction);
return JSONObject.of(
"ok", true,
"actionKey", actionKey
).toJSONString();
});
}
private BuiltinActionRegistry.BuiltinActionDefinition buildPersistDynamicActionDefinition() {
MetaActionInfo info = new MetaActionInfo(
false,
null,
Map.of("actionKey", param("required", "string", "Temporary ORIGIN actionKey returned by builtin::dynamic::generate.")),
"Purpose: persist a previously generated temporary ORIGIN MetaAction and cancel its cleanup task. Inputs: actionKey from dynamic::generate. Returns: JSON with ok and actionKey. Use when: a generated temporary action should become reusable beyond its cleanup TTL. Notes: this has persistence side effects and should only be used when the generated action is validated or explicitly requested.",
basicTags,
Set.of(createActionKey("generate")),
Set.of(),
false,
JSONObject.of(
"ok", "Whether the dynamic action was persisted successfully.",
"actionKey", "Temporary ORIGIN actionKey."
)
);
return new BuiltinActionRegistry.BuiltinActionDefinition(createActionKey("persist"), info, params -> {
String actionKey = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "actionKey");
TempDynamicActionRecord record = tempDynamicActions.get(actionKey);
if (record == null) {
throw new IllegalArgumentException("未找到对应临时动态行动: " + actionKey);
}
actionCapability.runnerClient().persistSerialize(record.metaActionInfo(), record.fileMetaData());
actionScheduler.cancel(record.cleanupActionId());
removeTempDynamicAction(actionKey);
return JSONObject.of(
"ok", true,
"actionKey", actionKey
).toJSONString();
});
}
@Override
public String createActionKey(String actionName) {
return DYNAMIC_LOCATION + "::" + actionName;
}
private JSONObject parseMeta(String metaJson) {
try {
return JSON.parseObject(metaJson);
} catch (Exception e) {
throw new IllegalArgumentException("参数 meta 必须为合法 JSON 字符串", e);
}
}
private MetaActionInfo buildMetaActionInfo(JSONObject meta, String launcher, String description) {
return new MetaActionInfo(
Boolean.TRUE.equals(meta.getBoolean("io")),
launcher,
copyStringMap(meta.getJSONObject("params")),
description,
toOrderedSet(meta.getJSONArray("tags")),
toOrderedSet(meta.getJSONArray("preActions")),
toOrderedSet(meta.getJSONArray("postActions")),
Boolean.TRUE.equals(meta.getBoolean("strictDependencies")),
copyJsonObject(meta.getJSONObject("responseSchema"))
);
}
private ActionFileMetaData buildActionFileMetaData(String location, String code, String codeType) {
ActionFileMetaData fileMetaData = new ActionFileMetaData();
fileMetaData.setContent(code);
fileMetaData.setExt(normalizeCodeType(codeType));
fileMetaData.setName(extractFileBaseName(location, fileMetaData.getExt()));
return fileMetaData;
}
private StateAction buildCleanupAction(String actionKey) {
return new StateAction(
"system",
"dynamic-action-cleanup:" + actionKey,
"清理临时动态行动",
Schedulable.ScheduleType.ONCE,
ZonedDateTime.now().plusSeconds(TEMP_ACTION_TTL_MILLIS / 1000).toString(),
new StateAction.Trigger.Call(() -> {
removeTempDynamicAction(actionKey);
return Unit.INSTANCE;
})
);
}
private void removeTempDynamicAction(String actionKey) {
TempDynamicActionRecord record = tempDynamicActions.remove(actionKey);
if (record == null) {
return;
}
actionCapability.listAvailableMetaActions().remove(actionKey);
deleteTempFileQuietly(record.location());
}
private void deleteTempFileQuietly(String location) {
try {
Files.deleteIfExists(Path.of(location));
} catch (Exception ignored) {
}
}
private Map<String, String> copyStringMap(JSONObject jsonObject) {
if (jsonObject == null) {
return Map.of();
}
Map<String, String> params = new LinkedHashMap<>();
jsonObject.forEach((key, value) -> params.put(key, value == null ? "" : String.valueOf(value)));
return params;
}
private Set<String> toOrderedSet(com.alibaba.fastjson2.JSONArray jsonArray) {
if (jsonArray == null) {
return Set.of();
}
return jsonArray.toJavaList(String.class).stream()
.filter(item -> item != null && !item.isBlank())
.collect(java.util.stream.Collectors.toCollection(java.util.LinkedHashSet::new));
}
private JSONObject copyJsonObject(JSONObject jsonObject) {
return jsonObject == null ? JSONObject.of() : JSONObject.from(jsonObject);
}
private String normalizeCodeType(String codeType) {
return codeType.startsWith(".") ? codeType.substring(1) : codeType;
}
private String extractFileBaseName(String location, String ext) {
String fileName = Path.of(location).getFileName().toString();
String suffix = "." + ext;
if (fileName.endsWith(suffix)) {
return fileName.substring(0, fileName.length() - suffix.length());
}
return fileName;
}
private String shortUuid() {
return UUID.randomUUID().toString().replace("-", "").substring(0, 12);
}
private record TempDynamicActionRecord(
String actionKey,
String location,
String cleanupActionId,
ActionFileMetaData fileMetaData,
MetaActionInfo metaActionInfo
) {
}
}
@@ -1,420 +0,0 @@
package work.slhaf.partner.module.action.builtin;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import org.jetbrains.annotations.NotNull;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import work.slhaf.partner.core.action.ActionCapability;
import work.slhaf.partner.core.action.entity.Action;
import work.slhaf.partner.core.action.entity.ExecutableAction;
import work.slhaf.partner.core.action.entity.MetaAction;
import work.slhaf.partner.core.action.entity.MetaActionInfo;
import work.slhaf.partner.core.action.entity.intervention.InterventionType;
import work.slhaf.partner.core.action.entity.intervention.MetaIntervention;
import work.slhaf.partner.core.cognition.CognitionCapability;
import work.slhaf.partner.core.cognition.context.BlockContent;
import work.slhaf.partner.core.cognition.context.CommunicationBlockContent;
import work.slhaf.partner.core.cognition.context.ContextBlock;
import work.slhaf.partner.core.cognition.context.ContextWorkspace;
import work.slhaf.partner.framework.agent.exception.AgentRuntimeException;
import work.slhaf.partner.framework.agent.exception.ExceptionReporterHandler;
import work.slhaf.partner.framework.agent.factory.capability.annotation.InjectCapability;
import work.slhaf.partner.framework.agent.factory.component.annotation.AgentComponent;
import work.slhaf.partner.framework.agent.factory.component.annotation.Init;
import work.slhaf.partner.framework.agent.factory.component.annotation.InjectModule;
import work.slhaf.partner.framework.agent.support.Result;
import java.util.*;
import java.util.function.Function;
import static work.slhaf.partner.core.action.ActionCore.BUILTIN_LOCATION;
@AgentComponent
class BuiltinInterventionActionProvider implements BuiltinActionProvider {
private static final String INTERVENTION_LOCATION = BUILTIN_LOCATION + "::" + "intervention";
private final Set<String> basicTags = Set.of("Builtin MetaAction", "Action Intervention");
@InjectCapability
private ActionCapability actionCapability;
@InjectCapability
private CognitionCapability cognitionCapability;
@InjectModule
private BuiltinActionRegistry builtinActionRegistry;
@Init
public void init() {
builtinActionRegistry.register(this);
}
@Override
public List<BuiltinActionRegistry.BuiltinActionDefinition> provideBuiltinActions() {
return List.of(
buildCreateInterventionDefinition(),
buildShowAvailableMetaActionsDefinition(),
buildShowIntervenableActionsDefinition(),
buildAcquireInterventionDefinition(),
buildResumeInterruptedActionDefinition()
);
}
private BuiltinActionRegistry.BuiltinActionDefinition buildResumeInterruptedActionDefinition() {
Set<String> tags = new HashSet<>(basicTags);
tags.add("Agent Turn");
tags.add("Action Management");
MetaActionInfo info = new MetaActionInfo(
false,
null,
Map.of(
"actionId", param("required", "string", "Uuid of an executable action currently in INTERRUPTED status.")
),
"Purpose: resume an executable action that is currently interrupted. Inputs: actionId. Returns: plain text success or failure reason. Use when: an intervention or user decision is complete and the original action should continue. Notes: this does not modify the action chain; use create_intervention before resuming when chain changes are required.",
tags,
Set.of(),
Set.of(),
false,
JSONObject.of(
"result", "Plain text resume result, describes whether it is succeed or fail reason."
)
);
Function<Map<String, Object>, String> invoker = params -> {
String actionId = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "actionId");
try {
ExecutableAction executableAction = getExecutableAction(actionId, Action.Status.INTERRUPTED);
executableAction.resume();
return "Resume succeed";
} catch (Exception e) {
return "Failed to resume action[" + actionId + "], reason: " + e.getLocalizedMessage();
}
};
return new BuiltinActionRegistry.BuiltinActionDefinition(
createActionKey("resume_interrupted_action"),
info,
invoker
);
}
/**
* 尝试向指定用户请求干预,通过自对话通道
*
* @return 内建 MetaAction 定义数据
*/
private BuiltinActionRegistry.BuiltinActionDefinition buildAcquireInterventionDefinition() {
Set<String> tags = new HashSet<>(basicTags);
tags.add("Agent Turn");
tags.add("Action Management");
MetaActionInfo info = new MetaActionInfo(
true,
null,
Map.of(
"actionId", param("required", "string", "Uuid of the executing action that should be interrupted for external intervention."),
"actionInfo", param("required", "string", "Readable summary of the current action and why intervention is needed."),
"demand", param("required", "string", "Specific feedback, decision, permission, or operation required from the target."),
"target", param("required", "string", "Target user or channel identifier that should receive the intervention request."),
"input", param("required", "string", "Prompt content used to initiate the intervention turn toward the target."),
"timeout", param("required", "int", "Maximum wait time passed to ExecutableAction.interrupt(timeout).")
),
"Purpose: interrupt an executing action and ask a target participant/channel for intervention. Inputs: actionId, actionInfo, demand, target, input, timeout. Returns: plain text intervention status. Use when: execution cannot safely continue without user decision, missing information, permission, or manual operation. Notes: this creates communication side effects and temporarily interrupts the action.",
tags,
Set.of(),
Set.of(),
false,
JSONObject.of(
"result", "Plain text intervention status. It describes whether the target answered before timeout, or returns an error reason when the turn/interruption flow fails."
)
);
Function<Map<String, Object>, String> invoker = params -> {
String actionId = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "actionId").trim();
String actionInfo = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "actionInfo").trim();
String demand = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "demand").trim();
String target = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "target").trim();
String input = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "input").trim();
int timeout = BuiltinActionRegistry.BuiltinActionDefinition.requireInt(params, "timeout");
ContextWorkspace contextWorkspace = cognitionCapability.contextWorkspace();
String blockName = "acquire_intervention-" + actionId;
String source = "action_executor";
contextWorkspace.register(new ContextBlock(
new CommunicationBlockContent(blockName, source, BlockContent.Urgency.HIGH, CommunicationBlockContent.Projection.SUPPLY) {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
appendTextElement(document, root, "state", "Partner needs some help.");
appendTextElement(document, root, "action_id", actionId);
appendTextElement(document, root, "action_info", actionInfo);
appendTextElement(document, root, "demand", demand);
}
},
Set.of(ContextBlock.FocusedDomain.COMMUNICATION),
10,
10,
20
));
ExecutableAction executableAction = null;
try {
executableAction = getExecutableAction(actionId, Action.Status.EXECUTING);
cognitionCapability.initiateTurn(input, target);
boolean normal = executableAction.interrupt(timeout);
return normal ? target + "not resumed execution in time" : target + "answered, looking for related answer in recent-chat-messages";
} catch (Exception e) {
return "Error happened while calling turn: " + e.getLocalizedMessage();
} finally {
contextWorkspace.expire(blockName, source);
if (executableAction != null) {
executableAction.resume();
}
}
};
return new BuiltinActionRegistry.BuiltinActionDefinition(
createActionKey("acquire_intervention"),
info,
invoker
);
}
private ExecutableAction getExecutableAction(String actionId, Action.Status status) {
return actionCapability.listActions(status, null)
.stream()
.filter(action -> action.getUuid().equals(actionId))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("未找到对应状态的 Action: " + actionId + ", status=" + status));
}
/**
* 用于展示当前已存在的可被干预的行动
*
* @return 内建 MetaAction 定义数据
*/
private BuiltinActionRegistry.BuiltinActionDefinition buildShowIntervenableActionsDefinition() {
Set<String> tags = new HashSet<>(basicTags);
tags.add("Available Resource");
tags.add("Agent State");
MetaActionInfo info = new MetaActionInfo(
false,
null,
Map.of(),
"Purpose: list executable actions that can be inspected for possible intervention. Inputs: none. Returns: JSON array with tendency, description, status, and uuid for each action. Use when: an action id is needed before acquire_intervention, create_intervention, or resume_interrupted_action. Notes: this only discovers actions; it does not modify them.",
tags,
Set.of(),
Set.of(),
false,
JSONObject.of(
"actions", "Info list of actions that could be intervened.",
"action.tendency", "Original tendency that the action is intended to resolve.",
"action.description", "Description of this action.",
"action.status", "Execution status of this action.",
"action.uuid", "Unique uuid of each action."
)
);
Function<Map<String, Object>, String> invoker = params -> {
Set<ExecutableAction> executableActions = actionCapability.listActions(null, null);
JSONArray interventions = new JSONArray();
for (ExecutableAction action : executableActions) {
JSONObject item = interventions.addObject();
item.put("tendency", action.getTendency());
item.put("description", action.getDescription());
item.put("status", action.getStatus().name().toLowerCase());
item.put("uuid", action.getUuid());
}
return interventions.toJSONString();
};
return new BuiltinActionRegistry.BuiltinActionDefinition(
createActionKey("list_intervenable_actions"),
info,
invoker
);
}
/**
* 用于展示当前可用的 MetaAction
*
* @return 内建 MetaAction 定义数据
*/
private BuiltinActionRegistry.BuiltinActionDefinition buildShowAvailableMetaActionsDefinition() {
Set<String> tags = new HashSet<>(basicTags);
tags.add("Available Resource");
MetaActionInfo info = new MetaActionInfo(
false,
null,
Map.of(),
"Purpose: list currently available MetaActions that can be used in action chains or interventions. Inputs: none. Returns: JSON array with action key/location, description, tags, and params. Use when: correction needs to discover valid action_key values for APPEND, INSERT, DELETE, or REBUILD. Notes: this is a discovery action and does not execute those MetaActions.",
tags,
Set.of(),
Set.of(),
false,
JSONObject.of(
"meta_actions", "Available MetaAction info list.",
"meta_action.actionKey", "MetaAction actionKey, describing action source and its unique calling id.",
"meta_action.description", "MetaAction description.",
"meta_action.tags", "MetaAction tag list as string.",
"meta_action.params", "MetaAction parameter definition JSON string."
)
);
Function<Map<String, Object>, String> invoker = params -> {
Map<String, MetaActionInfo> availableMetaActions = actionCapability.listAvailableMetaActions();
JSONArray actions = new JSONArray();
for (Map.Entry<String, MetaActionInfo> entry : availableMetaActions.entrySet()) {
JSONObject item = actions.addObject();
item.put("location", entry.getKey());
MetaActionInfo action = entry.getValue();
item.put("description", action.getDescription());
item.put("tags", Arrays.toString(action.getTags().toArray()));
item.put("params", JSONObject.toJSONString(action.getParams()));
}
return actions.toJSONString();
};
return new BuiltinActionRegistry.BuiltinActionDefinition(
createActionKey("list_available_meta_actions"),
info,
invoker
);
}
/**
* 用于创建一个 Action Intervention,并作用于指定的 Executable Action
*
* @return 内建 MetaAction 定义数据
*/
private BuiltinActionRegistry.BuiltinActionDefinition buildCreateInterventionDefinition() {
MetaActionInfo info = new MetaActionInfo(
false,
null,
Map.of(
"id", param("required", "string", "Uuid of the executable action to modify."),
"type", param("required", "string", "Intervention type. Allowed values: APPEND, INSERT, DELETE, CANCEL, REBUILD."),
"order", param("required", "int", "Action chain order/stage where the intervention applies."),
"actions", param("conditional", "string", "Comma-separated actionKey list to insert, append, rebuild, or delete. Required except for CANCEL. Example: builtin::command::execute,builtin::command::read.")
),
"Purpose: apply a structural intervention to an executable action chain. Inputs: id, type, order, and conditional actions. Returns: JSON with ok and optional result. Use when: correction must append, insert, delete, cancel, or rebuild future action-chain steps. Notes: actions must be valid action_key values; CANCEL may omit actions; this has direct action-chain side effects and resumes the target action after applying changes.",
basicTags,
Set.of(
createActionKey("list_available_meta_actions"),
createActionKey("list_intervenable_actions")
),
Set.of(),
true,
JSONObject.of(
"ok", "Intervene status",
"result", "Intervene result or failed message."
)
);
Function<Map<String, Object>, String> invoker = params -> {
String targetId = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "id").trim();
if (targetId.isEmpty()) {
throw new IllegalArgumentException("参数 id 不能为空");
}
InterventionType type = requireInterventionType(params);
Integer order = BuiltinActionRegistry.BuiltinActionDefinition.requireInt(params, "order");
List<String> actions = requireActions(params, type);
ExecutableAction target = requireTargetAction(targetId);
Result<Void> validationResult = validateActionKeys(actions);
AgentRuntimeException validationFailure = validationResult.onFailure(ExceptionReporterHandler.INSTANCE::report).exceptionOrNull();
if (validationFailure != null) {
return JSONObject.of("ok", false, "result", validationFailure.getLocalizedMessage()).toJSONString();
}
MetaIntervention intervention = new MetaIntervention();
intervention.setType(type);
intervention.setOrder(order);
intervention.setActions(actions);
actionCapability.handleInterventions(List.of(intervention), target);
target.resume();
return JSONObject.of("ok", true).toJSONString();
};
return new BuiltinActionRegistry.BuiltinActionDefinition(
createActionKey("create_intervention"),
info,
invoker
);
}
@Override
public String createActionKey(String actionName) {
return INTERVENTION_LOCATION + "::" + actionName;
}
private InterventionType requireInterventionType(Map<String, Object> params) {
String typeValue = BuiltinActionRegistry.BuiltinActionDefinition.requireString(params, "type").trim();
if (typeValue.isEmpty()) {
throw new IllegalArgumentException("参数 type 不能为空");
}
try {
return InterventionType.valueOf(typeValue.toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException("参数 type 非法: " + typeValue, e);
}
}
private List<String> requireActions(Map<String, Object> params, InterventionType type) {
Object value = params.get("actions");
if (value == null) {
if (type == InterventionType.CANCEL) {
return List.of();
}
throw new IllegalArgumentException("缺少参数: actions");
}
if (!(value instanceof String actionsValue)) {
throw new IllegalArgumentException("参数 actions 必须为字符串");
}
String trimmedActionsValue = actionsValue.trim();
if (trimmedActionsValue.isEmpty()) {
if (type == InterventionType.CANCEL) {
return List.of();
}
throw new IllegalArgumentException("参数 actions 不能为空");
}
List<String> actions = Arrays.stream(trimmedActionsValue.split(","))
.map(String::trim)
.filter(item -> !item.isEmpty())
.toList();
if (type != InterventionType.CANCEL && actions.isEmpty()) {
throw new IllegalArgumentException("参数 actions 不能为空");
}
return actions;
}
private ExecutableAction requireTargetAction(String targetId) {
return actionCapability.listActions(null, null)
.stream()
.filter(action -> targetId.equals(action.getUuid()))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("未找到对应的 Action: " + targetId));
}
private Result<Void> validateActionKeys(List<String> actions) {
for (String actionKey : actions) {
Result<MetaAction> metaActionResult = actionCapability.loadMetaAction(actionKey);
AgentRuntimeException failure = metaActionResult.exceptionOrNull();
if (failure != null) {
return Result.failure(failure);
}
}
return Result.success(null);
}
}
@@ -1,138 +0,0 @@
package work.slhaf.partner.module.action.executor;
import kotlin.Unit;
import org.jetbrains.annotations.NotNull;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import work.slhaf.partner.core.cognition.CognitionCapability;
import work.slhaf.partner.core.cognition.context.ContextBlock;
import work.slhaf.partner.framework.agent.factory.capability.annotation.InjectCapability;
import work.slhaf.partner.framework.agent.factory.component.abstracts.AbstractAgentModule;
import work.slhaf.partner.framework.agent.model.ActivateModel;
import work.slhaf.partner.framework.agent.model.pojo.Message;
import work.slhaf.partner.framework.agent.support.Result;
import work.slhaf.partner.module.TaskBlock;
import work.slhaf.partner.module.action.executor.entity.CorrectorInput;
import work.slhaf.partner.module.action.executor.entity.RecognizerResult;
import java.util.List;
/**
* 负责在行动链执行过程中判断当前进度是否异常,是否需要引入 corrector 介入。
*/
public class ActionCorrectionRecognizer extends AbstractAgentModule.Sub<CorrectorInput, Result<RecognizerResult>> implements ActivateModel {
private static final String MODULE_PROMPT = """
你负责在行动链执行过程中识别当前执行是否出现异常,并判断是否需要引入 corrector 介入。
你会收到:
- 一条结构化上下文消息,其中包含近期交流轨迹与当前活跃记忆切片;
- 一条任务消息,其中包含:
- check_mode:当前识别模式,PROCESS_CHECK 表示过程纠偏识别,FINAL_CHECK 表示链路末尾完成度验收;
- executable_action_info:当前正在执行的行动信息,包括 executing_action_id、original_tendency、evaluation_passed_reason、description 与 from_who;
- current_action_chain_overview:当前行动链概览,按 stage_count 分组,包含各阶段已有 meta_action 的 action_key、description、status 与 result。
你的任务:
- 当 check_mode=PROCESS_CHECK 时,基于当前上下文、当前行动信息与当前行动链概览,判断这条行动链是否仍在合理推进;
- 当 check_mode=FINAL_CHECK 时,判断当前行动链即将结束时,original_tendency 是否已经被执行结果满足;
- 判断当前是否已经出现明显偏航、停滞、重复打转、条件失配、目标漂移、目标未完成,或与最新语境不再一致的情况;
- 若需要引入 corrector,则返回 needCorrection=true,并给出简洁明确的 reason;
- 若当前仍可继续推进或已经满足目标,则返回 needCorrection=false。
识别原则:
- executable_action_info 用于说明当前链路最初为何成立、要解决什么问题、当前由谁发起;你的判断必须围绕这条行动本身,而不是被无关历史带偏。
- executing_action_id 用于标识当前正在执行的行动;current_action_chain_overview 用于说明当前链路整体结构、已知阶段状态与执行结果。
- original_tendency 表示这条行动链最初要解决的问题;若当前链路明显偏离这一倾向,应优先视为异常信号。
- evaluation_passed_reason 与 description 表示该行动最初为何被判定为可推进;若当前状态已经与这些前提不再一致,应考虑触发纠正。
- current_action_chain_overview 是判断当前链路是否停滞、重复、缺步骤、顺序异常、整体失衡或最终目标未满足的主要依据。
- PROCESS_CHECK 关注执行过程是否仍然合理;FINAL_CHECK 关注链路结果是否满足 original_tendency。
- FINAL_CHECK 时应重点查看各 meta_action 的 result;如果 result 显示对象不存在、权限不足、参数错误、输出为空、命令失败、目标未产生或结果明显不符合 original_tendency,应触发纠正。
- communication 域用于判断最新交流语境是否已发生明显变化,导致当前行动继续推进不再合适。
- memory 域只在与当前行动明显相关时作为辅助参考使用。
应优先考虑需要纠正的情形:
- 当前行动长期没有形成有效推进,只在重复相近步骤、相近动作或相近结论;
- 当前执行显著偏离 original_tendency,开始围绕无关目标展开;
- 当前行动所依赖的前提已被新的交流内容或上下文推翻;
- 当前行动链反复遇到同类失败、阻塞或空转迹象,继续按原链推进意义不大;
- 当前行动链已经明显需要改写策略、调整阶段顺序、补入新步骤或删除无效步骤,但现有链路无法自行收敛;
- FINAL_CHECK 时,执行结果明确显示 original_tendency 尚未满足,且可能通过补救步骤继续推进。
不应轻易触发纠正的情形:
- PROCESS_CHECK 时,只是正常的多步推进;
- PROCESS_CHECK 时,存在短暂等待、一次性失败或合理重试,但整体方向仍正确;
- 行动链整体仍与 original_tendency、evaluation_passed_reason 和当前语境保持一致;
- FINAL_CHECK 时,执行结果已经足以满足 original_tendency,只需要向用户报告结果;
- 仅凭旧对话、低相关记忆或轻微波动,不足以判定当前行动异常。
关于输出:
- needCorrection=true 表示当前应引入 corrector 介入。
- needCorrection=false 表示当前行动仍可继续按既有链路推进。
- reason 用于简洁说明判断依据;若 needCorrection=false,也应给出简短理由,说明为何当前仍可继续推进。
- 不要输出结构之外的解释、说明或额外文本。
输出要求:
- 严格按照 RecognizerResult 对应结构输出。
""";
@InjectCapability
private CognitionCapability cognitionCapability;
@Override
protected @NotNull Result<RecognizerResult> doExecute(CorrectorInput input) {
List<Message> messages = List.of(
resolveContextMessage(),
resolveTaskMessage(input)
);
return formattedChat(messages, RecognizerResult.class);
}
private Message resolveTaskMessage(CorrectorInput input) {
return new TaskBlock() {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
appendTextElement(document, root, "check_mode", input.getCheckMode());
appendChildElement(document, root, "executable_action_info", block -> {
appendTextElement(document, block, "executing_action_id", input.getActionId());
appendTextElement(document, block, "original_tendency", input.getTendency());
appendTextElement(document, block, "evaluation_passed_reason", input.getReason());
appendTextElement(document, block, "description", input.getDescription());
appendTextElement(document, block, "from_who", input.getSource());
return Unit.INSTANCE;
});
appendListElement(document, root, "current_action_chain_overview", "action_chain_stage", input.getActionChainOverview().entrySet(), (stageElement, stageData) -> {
stageElement.setAttribute("stage_count", String.valueOf(stageData.getKey()));
appendRepeatedElements(document, stageElement, "meta_action", stageData.getValue(), (metaActionElement, metaActionData) -> {
appendTextElement(document, metaActionElement, "action_key", metaActionData.getActionKey());
appendTextElement(document, metaActionElement, "description", metaActionData.getDescription());
appendTextElement(document, metaActionElement, "status", metaActionData.getStatus());
appendTextElement(document, metaActionElement, "result", metaActionData.getResult());
return Unit.INSTANCE;
});
return Unit.INSTANCE;
});
}
}.encodeToMessage();
}
private Message resolveContextMessage() {
return cognitionCapability.contextWorkspace().resolve(List.of(
ContextBlock.FocusedDomain.COMMUNICATION,
ContextBlock.FocusedDomain.PERCEIVE,
ContextBlock.FocusedDomain.MEMORY
)).encodeToMessage();
}
@Override
@NotNull
public List<Message> modulePrompt() {
return List.of(new Message(Message.Character.SYSTEM, MODULE_PROMPT));
}
@NotNull
@Override
public String modelKey() {
return "action_correction_recognizer";
}
}
@@ -1,171 +0,0 @@
package work.slhaf.partner.module.action.executor;
import kotlin.Unit;
import org.jetbrains.annotations.NotNull;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import work.slhaf.partner.core.action.ActionCapability;
import work.slhaf.partner.core.cognition.CognitionCapability;
import work.slhaf.partner.core.cognition.context.ContextBlock;
import work.slhaf.partner.framework.agent.factory.capability.annotation.InjectCapability;
import work.slhaf.partner.framework.agent.factory.component.abstracts.AbstractAgentModule;
import work.slhaf.partner.framework.agent.model.ActivateModel;
import work.slhaf.partner.framework.agent.model.pojo.Message;
import work.slhaf.partner.framework.agent.support.Result;
import work.slhaf.partner.module.TaskBlock;
import work.slhaf.partner.module.action.executor.entity.CorrectorInput;
import work.slhaf.partner.module.action.executor.entity.CorrectorResult;
import java.util.List;
/**
* 负责在单组行动执行后,根据行动意图与结果检查后续行动是否符合目的,必要时直接调整行动链,或发起自对话请求进行干预
*/
public class ActionCorrector extends AbstractAgentModule.Sub<CorrectorInput, Result<CorrectorResult>> implements ActivateModel {
private static final String MODULE_PROMPT = """
你负责在行动链执行过程中进行纠偏。你的任务不是重新评估是否要启动这条行动,而是在当前行动已经进入执行后,根据原始行动意图、当前链路结构与最新语境,判断是否需要调整后续行动链。
你会收到:
- 一条结构化上下文消息,其中包含近期交流轨迹与当前活跃记忆切片;
- 一条任务消息,其中包含:
- check_mode:当前纠偏模式,PROCESS_CHECK 表示过程纠偏,FINAL_CHECK 表示链路末尾目标未满足后的补救;
- executable_action_info:当前正在执行的行动信息,包括 executing_action_id、original_tendency、evaluation_passed_reason、description 与 from_who;
- current_action_chain_overview:当前行动链概览,按 stage_count 分组,包含各阶段已有 meta_action 的 action_key、description、stage_description、status 与 result;stage_description 是该阶段的具体执行目标,description 是 MetaAction 的通用能力说明。
- available_meta_action:当前系统真实可用的 MetaAction 候选,包含 meta_action_key 与 meta_action_description。
你的任务:
- 当 check_mode=PROCESS_CHECK 时,基于当前上下文、原始行动意图与当前行动链进展,判断后续行动是否仍然符合目的;
- 当 check_mode=FINAL_CHECK 时,基于当前行动链的最终结果判断 original_tendency 尚未满足时应如何补救;
- 若当前链路仍可继续推进或最终结果已足够满足目标,则不要随意干预;
- 若当前链路已明显跑偏、缺少必要步骤、顺序不合理、存在冗余、已经不再适合继续,或需要引入新的动作单元,则输出干预方案;
- FINAL_CHECK 下若结果显示目标未满足,应优先补入可继续执行的后续动作;如果无法补救,可以输出空 intervention 并在 correctionReason 中说明原因。
- correctionReason 用于简洁说明为何需要这些纠偏。
纠偏原则:
- executable_action_info 用于说明当前链路最初为何成立、要解决什么问题、当前由谁发起;你的纠偏必须围绕这条行动本身,而不是转向新的无关目标。
- executing_action_id 用于标识当前正在执行的行动;current_action_chain_overview 用于说明当前链路整体结构与阶段状态。
- original_tendency 是这条行动链最初要解决的问题;后续行动的调整必须仍然围绕它。
- evaluation_passed_reason 与 description 表示该行动最初为何能够成立;若当前链路已经与这些前提不一致,可据此进行纠偏。
- current_action_chain_overview 是判断后续链路是否缺步骤、顺序失衡、重复冗余或整体方向错误的主要依据。
- available_meta_action 是你进行 APPEND、INSERT 或 REBUILD 时可选择的动作全集;不要使用其中不存在的 action_key。
- communication 域用于判断最新交流语境是否已经变化,导致当前链路需要调整。
- memory 域只在与当前行动明显相关时作为辅助参考使用。
何时应考虑干预:
- 当前链路缺少继续推进所必需的动作;
- 当前链路顺序明显不合理,继续执行会降低成功率或偏离目的;
- 某些动作已经失效、重复、无意义,或不再适合当前状态;
- 当前链路在某一阶段之后整体方向需要重建;
- 当前行动已不应继续推进,应直接取消整条链路。
何时不应轻易干预:
- 当前只是正常的多步推进;
- 某一步刚完成,尚不足以说明后续链路错误;
- 没有足够依据证明当前行动链存在明显问题;
- 仅凭旧对话、低相关记忆或轻微波动,不足以支持修改行动链。
关于干预类型:
- APPEND: 在指定 order 之后追加新的动作。
- INSERT: 在指定 order 执行过程中即时插入并执行新的动作。
- DELETE: 删除指定 order 上的指定动作。
- CANCEL: 取消当前行动链后续执行。
- REBUILD: 清空当前行动链与既有执行进度,并用新的规划内容整体重建行动链。
关于 intervention 列表:
- 系统会按照 metaInterventionList 的顺序逐条应用干预;前面的干预可能改变后续可理解的 order 位置,因此你在生成后续 intervention 时,必须考虑前面 intervention 已经生效后的链路形态。
- 不要把一组本应整体表达的修改拆成互相冲突、顺序不自洽的 intervention。
- actions 中只能填写当前系统中真实存在、可用的 action_key,不要编造不存在的动作。
- order 必须对应当前行动链中的合理阶段位置。
关于 REBUILD:
- REBUILD 表示放弃当前既有链路,并重新给出新的整体规划。
- 一旦本次纠偏结果中使用了 REBUILD,则 metaInterventionList 中所有 intervention 都必须是 REBUILD;不要将 REBUILD 与 APPEND、INSERT、DELETE、CANCEL 混用。
- 使用 REBUILD 时,应把新的链路规划完整表达为一组按 order 分布的 REBUILD intervention,而不是只局部补几步。
其他约束:
- 若无需干预,可返回空的 metaInterventionList,并给出简短的 correctionReason 说明当前为何可继续推进。
- 不要输出结构之外的解释、说明或额外文本。
输出要求:
- 严格按照 CorrectorResult 对应结构输出。
- metaInterventionList 中每一项都必须是明确、可执行、且与其他 intervention 顺序一致的干预单元。
""";
@InjectCapability
private CognitionCapability cognitionCapability;
@InjectCapability
private ActionCapability actionCapability;
@Override
protected @NotNull Result<CorrectorResult> doExecute(CorrectorInput input) {
List<Message> messages = List.of(
resolveContextMessage(),
resolveTaskMessage(input)
);
return formattedChat(messages, CorrectorResult.class);
}
private Message resolveTaskMessage(CorrectorInput input) {
return new TaskBlock() {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
appendTextElement(document, root, "check_mode", input.getCheckMode());
appendChildElement(document, root, "executable_action_info", block -> {
appendTextElement(document, block, "executing_action_id", input.getActionId());
appendTextElement(document, block, "original_tendency", input.getTendency());
appendTextElement(document, block, "evaluation_passed_reason", input.getReason());
appendTextElement(document, block, "description", input.getDescription());
appendTextElement(document, block, "from_who", input.getSource());
return Unit.INSTANCE;
});
appendListElement(document, root, "current_action_chain_overview", "action_chain_stage", input.getActionChainOverview().entrySet(), (stageElement, stageData) -> {
stageElement.setAttribute("stage_count", String.valueOf(stageData.getKey()));
appendRepeatedElements(document, stageElement, "meta_action", stageData.getValue(), (metaActionElement, metaActionData) -> {
appendTextElement(document, metaActionElement, "action_key", metaActionData.getActionKey());
appendTextElement(document, metaActionElement, "description", metaActionData.getDescription());
if (metaActionData.getStageDescription() != null && !metaActionData.getStageDescription().isBlank()) {
appendTextElement(document, metaActionElement, "stage_description", metaActionData.getStageDescription());
}
appendTextElement(document, metaActionElement, "status", metaActionData.getStatus());
appendTextElement(document, metaActionElement, "result", metaActionData.getResult());
return Unit.INSTANCE;
});
return Unit.INSTANCE;
});
appendRepeatedElements(
document,
root,
"available_meta_action",
actionCapability.listAvailableMetaActions().entrySet(),
(block, value) -> {
appendTextElement(document, block, "meta_action_key", value.getKey());
appendTextElement(document, block, "meta_action_description", value.getValue().getDescription());
return Unit.INSTANCE;
}
);
}
}.encodeToMessage();
}
private Message resolveContextMessage() {
return cognitionCapability.contextWorkspace().resolve(List.of(
ContextBlock.FocusedDomain.COMMUNICATION,
ContextBlock.FocusedDomain.PERCEIVE,
ContextBlock.FocusedDomain.MEMORY
)).encodeToMessage();
}
@Override
@NotNull
public List<Message> modulePrompt() {
return List.of(new Message(Message.Character.SYSTEM, MODULE_PROMPT));
}
@NotNull
@Override
public String modelKey() {
return "action_corrector";
}
}
@@ -1,796 +0,0 @@
package work.slhaf.partner.module.action.executor;
import lombok.extern.slf4j.Slf4j;
import lombok.val;
import org.jetbrains.annotations.NotNull;
import work.slhaf.partner.core.action.ActionCapability;
import work.slhaf.partner.core.action.ActionCore;
import work.slhaf.partner.core.action.entity.*;
import work.slhaf.partner.core.action.runner.RunnerClient;
import work.slhaf.partner.core.cognition.CognitionCapability;
import work.slhaf.partner.framework.agent.exception.AgentRuntimeException;
import work.slhaf.partner.framework.agent.factory.capability.annotation.InjectCapability;
import work.slhaf.partner.framework.agent.factory.component.abstracts.AbstractAgentModule;
import work.slhaf.partner.framework.agent.factory.component.annotation.Init;
import work.slhaf.partner.framework.agent.factory.component.annotation.InjectModule;
import work.slhaf.partner.framework.agent.factory.context.Shutdown;
import work.slhaf.partner.framework.agent.support.Result;
import work.slhaf.partner.module.action.executor.entity.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
@Slf4j
public class ActionExecutor extends AbstractAgentModule.Standalone {
private static final int MAX_EXTRACTOR_ATTEMPTS = 3;
private static final int MAX_FINAL_CORRECTION_ATTEMPTS = 1;
private final AssemblyHelper assemblyHelper = new AssemblyHelper();
@InjectCapability
private ActionCapability actionCapability;
@InjectCapability
private CognitionCapability cognitionCapability;
@InjectModule
private ParamsExtractor paramsExtractor;
@InjectModule
private ActionCorrector actionCorrector;
@InjectModule
private ActionCorrectionRecognizer actionCorrectionRecognizer;
private ExecutingActionBlockManager blockManager;
private ExecutorService virtualExecutor;
private ExecutorService platformExecutor;
private RunnerClient runnerClient;
private final AtomicBoolean closed = new AtomicBoolean(false);
@Init
public void init() {
virtualExecutor = actionCapability.getExecutor(ActionCore.ExecutorType.VIRTUAL);
platformExecutor = actionCapability.getExecutor(ActionCore.ExecutorType.PLATFORM);
runnerClient = actionCapability.runnerClient();
blockManager = new ExecutingActionBlockManager(cognitionCapability.contextWorkspace());
Set<ExecutableAction> recoveredActions = new HashSet<>();
recoveredActions.addAll(actionCapability.listActions(Action.Status.EXECUTING, null));
recoveredActions.addAll(actionCapability.listActions(Action.Status.INTERRUPTED, null).stream()
.peek(executableAction -> executableAction.setStatus(Action.Status.EXECUTING))
.collect(Collectors.toSet()));
if (recoveredActions.isEmpty()) {
return;
}
recoveredActions.forEach(this::execute);
blockManager.emitActionRecoveredBlock(recoveredActions);
}
@Shutdown
public void shutdown() {
closed.set(true);
}
public void execute(Action action) {
Future<?> future = virtualExecutor.submit(actionExecutionRouter(action));
virtualExecutor.execute(() -> {
try {
future.get(action.getTimeoutMills(), TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
future.cancel(true);
action.setStatus(Action.Status.FAILED);
if (action instanceof ExecutableAction executableAction) {
ensureExecutableResult(executableAction, true, "行动执行超时");
}
log.warn("Action timeout, uuid: {}", action.getUuid());
} catch (Exception ignored) {
}
});
}
private Runnable actionExecutionRouter(Action action) {
return () -> {
try {
switch (action) {
case ExecutableAction executableAction -> handleExecutableAction(executableAction);
case StateAction stateAction -> handleStateAction(stateAction);
default -> handleUnknownAction(action);
}
if (action instanceof ExecutableAction executableAction) {
if (action.getStatus() == Action.Status.FAILED) {
ensureExecutableResult(executableAction, true, null);
emitExecutableActionFinished(executableAction);
return;
}
action.setStatus(Action.Status.SUCCESS);
ensureExecutableResult(executableAction, false, null);
emitExecutableActionFinished(executableAction);
return;
}
if (action.getStatus() == Action.Status.FAILED) {
return;
}
action.setStatus(Action.Status.SUCCESS);
} catch (Exception e) {
log.warn("Unexpected action execution failure, uuid: {}, description: {}, failure reason: {}", action.getUuid(), action.getDescription(), e.getLocalizedMessage());
action.setStatus(Action.Status.FAILED);
if (action instanceof ExecutableAction executableAction) {
ensureExecutableResult(executableAction, true, e.getLocalizedMessage());
emitExecutableActionFinished(executableAction);
}
}
};
}
private void handleUnknownAction(Action action) {
log.warn("unknown Action type: {}", action.getClass().getSimpleName());
action.setStatus(Action.Status.FAILED);
}
private void emitExecutableActionFinished(ExecutableAction executableAction) {
blockManager.emitActionFinishedBlock(executableAction);
cognitionCapability.initiateTurn(
"An executable action has finished. Check the latest action-finished state and inform the user of the outcome if it is relevant to the current conversation.",
executableAction.getSource(),
"action_planner"
);
}
private void handleStateAction(StateAction stateAction) {
if (closed.get()) {
return;
}
blockManager.emitStateActionTriggeredBlock(stateAction);
stateAction.getTrigger().onTrigger();
}
private void handleExecutableAction(ExecutableAction executableAction) {
actionCapability.putAction(executableAction);
val actionChain = executableAction.getActionChain();
if (!prepareExecutableAction(executableAction, actionChain)) {
return;
}
blockManager.emitActionLaunchedBlock(executableAction);
val stageCursor = initStageCursor(executableAction, actionChain);
int finalCorrectionAttempts = 0;
while (true) {
val stageSelection = selectCurrentStage(executableAction, actionChain);
if (stageSelection.shouldReturn()) {
return;
}
if (stageSelection.shouldStop()) {
break;
}
val stageExecution = runCurrentStage(executableAction, stageCursor, stageSelection.metaActions());
if (stageExecution.closed()) {
return;
}
if (!applyStageCorrectionAndAdvance(executableAction, stageCursor, stageExecution)) {
if (finalCorrectionAttempts >= MAX_FINAL_CORRECTION_ATTEMPTS || !applyFinalCorrection(executableAction, stageCursor)) {
break;
}
finalCorrectionAttempts++;
}
}
finishExecutableAction(executableAction);
}
private boolean prepareExecutableAction(ExecutableAction executableAction, Map<Integer, List<MetaAction>> actionChain) {
synchronized (executableAction.getExecutionLock()) {
val status = executableAction.getStatus();
if (status != Action.Status.PREPARE && status != Action.Status.EXECUTING) {
return false;
}
if (actionChain.isEmpty()) {
executableAction.setStatus(Action.Status.FAILED);
executableAction.setResult("行动链为空");
return false;
}
normalizeExecutingStage(executableAction, actionChain);
executableAction.setStatus(Action.Status.EXECUTING);
return true;
}
}
private StageCursor initStageCursor(ExecutableAction executableAction, Map<Integer, List<MetaAction>> actionChain) {
StageCursor stageCursor = new StageCursor(executableAction, actionChain);
synchronized (executableAction.getExecutionLock()) {
stageCursor.init();
}
return stageCursor;
}
private StageSelection selectCurrentStage(ExecutableAction executableAction, Map<Integer, List<MetaAction>> actionChain) {
synchronized (executableAction.getExecutionLock()) {
if (closed.get()) {
return StageSelection.returnNow();
}
if (executableAction.getStatus() == Action.Status.FAILED) {
return StageSelection.stop();
}
return StageSelection.continueWith(actionChain.get(executableAction.getExecutingStage()));
}
}
private StageExecution runCurrentStage(
ExecutableAction executableAction,
StageCursor stageCursor,
List<MetaAction> metaActions
) {
Phaser phaser = new Phaser();
val recognizerRecord = startRecognizerIfNeeded(executableAction, phaser);
val listeningRecord = executeAndListening(metaActions, phaser, executableAction);
phaser.awaitAdvance(listeningRecord.phase());
// synchronized 同步防止 accepting 循环间、phase guard 判定后发生 stage 推进
// 导致新行动的 phaser 投放阶段错乱无法阻塞的场景
// 该 synchronized 将阶段推进与 accepting 监听 loop 捆绑为互斥的原子事件,避免了细粒度的 phaser 阶段竞态问题
if (closed.get()) {
return StageExecution.closed(recognizerRecord, metaActions);
}
synchronized (executableAction.getExecutionLock()) {
synchronized (listeningRecord.accepting()) {
listeningRecord.accepting().set(false);
// 立即尝试推进,本次推进中,如果前方仍有未执行 stage,将执行一次阶段推进
stageCursor.requestAdvance();
}
}
blockManager.emitActionStageSettledBlock(executableAction);
return StageExecution.completed(recognizerRecord, metaActions);
}
private boolean applyStageCorrectionAndAdvance(
ExecutableAction executableAction,
StageCursor stageCursor,
StageExecution stageExecution
) {
boolean hasFailedMetaAction = hasFailedMetaAction(stageExecution.metaActions());
boolean shouldRunCorrector = hasFailedMetaAction;
if (!shouldRunCorrector) {
val recognizerResult = resolveRecognizerResult(stageExecution.recognizerRecord());
shouldRunCorrector = recognizerResult != null && recognizerResult.isNeedCorrection();
}
if (shouldRunCorrector) {
val correctorInput = assemblyHelper.buildCorrectorInput(executableAction, CorrectorInput.CheckMode.PROCESS_CHECK);
actionCorrector.execute(correctorInput)
.onSuccess(correctorResult -> {
actionCapability.handleInterventions(correctorResult.getMetaInterventionList(), executableAction);
blockManager.emitActionCorrectionBlock(
executableAction,
hasFailedMetaAction ? "has_failed_meta_action" : correctorResult.getCorrectionReason(),
correctorResult.getMetaInterventionList()
);
});
}
// 第二次尝试进行阶段推进,本次负责补充上一次在不存在 stage时,但 corrector 执行期间发生了 actionChain 的插入事件
// 如果第一次已经推进完毕,本次将会跳过
synchronized (executableAction.getExecutionLock()) {
stageCursor.requestAdvance();
return stageCursor.next();
}
}
private boolean applyFinalCorrection(ExecutableAction executableAction, StageCursor stageCursor) {
val recognizerInput = assemblyHelper.buildCorrectorInput(executableAction, CorrectorInput.CheckMode.FINAL_CHECK);
val recognizerResult = actionCorrectionRecognizer.execute(recognizerInput)
.getOrDefault(new RecognizerResult());
if (!recognizerResult.isNeedCorrection()) {
return false;
}
val previousStage = executableAction.getExecutingStage();
val correctorResult = actionCorrector.execute(recognizerInput).getOrDefault(new CorrectorResult());
val interventions = correctorResult.getMetaInterventionList() == null
? new ArrayList<work.slhaf.partner.core.action.entity.intervention.MetaIntervention>()
: correctorResult.getMetaInterventionList();
if (!interventions.isEmpty()) {
actionCapability.handleInterventions(interventions, executableAction);
}
blockManager.emitActionCorrectionBlock(
executableAction,
recognizerResult.getReason() == null || recognizerResult.getReason().isBlank()
? correctorResult.getCorrectionReason()
: recognizerResult.getReason(),
interventions
);
synchronized (executableAction.getExecutionLock()) {
if (executableAction.getStatus() == Action.Status.FAILED) {
return false;
}
if (interventions.isEmpty() || executableAction.getActionChain().isEmpty()) {
executableAction.setStatus(Action.Status.FAILED);
ensureExecutableResult(executableAction, true, "行动未达到目标,且未能生成可继续执行的纠偏行动");
return false;
}
if (executableAction.getStatus() == Action.Status.PREPARE) {
normalizeExecutingStage(executableAction, executableAction.getActionChain());
executableAction.setStatus(Action.Status.EXECUTING);
} else {
Integer nextStage = executableAction.getActionChain().keySet().stream()
.filter(stage -> stage > previousStage)
.min(Integer::compareTo)
.orElse(null);
if (nextStage == null) {
executableAction.setStatus(Action.Status.FAILED);
ensureExecutableResult(executableAction, true, "行动未达到目标,纠偏未生成后续执行阶段");
return false;
}
executableAction.setExecutingStage(nextStage);
}
stageCursor.refresh();
return true;
}
}
private void finishExecutableAction(ExecutableAction executableAction) {
// 如果是 ScheduledActionData, 则重置 ActionData 内容,记录执行历史与最终结果
if (executableAction instanceof SchedulableExecutableAction scheduledActionData) {
scheduledActionData.recordAndReset();
}
}
private MetaActionsListeningRecord executeAndListening(List<MetaAction> metaActions, Phaser phaser, ExecutableAction executableAction) {
AtomicBoolean accepting = new AtomicBoolean(true);
AtomicInteger cursor = new AtomicInteger();
CountDownLatch latch = new CountDownLatch(1);
val phase = phaser.register();
platformExecutor.execute(() -> {
boolean first = true;
while (accepting.get()) {
synchronized (accepting) {
MetaAction next = null;
synchronized (metaActions) {
if (cursor.get() < metaActions.size()) {
next = metaActions.get(cursor.getAndIncrement());
}
}
if (next == null) {
Thread.onSpinWait();
continue;
}
if (phaser.getPhase() != phase) {
metaActions.remove(next);
log.warn("行动阶段已推进,丢弃该行动: {}", next);
continue;
}
ExecutorService executor = next.getIo() ? virtualExecutor : platformExecutor;
executor.execute(buildMataActionTask(next, phaser, executableAction));
if (first) {
phaser.arriveAndDeregister();
latch.countDown();
first = false;
}
}
}
});
try {
// 确保执行一次,防止没来得及注册任务就已经结束
latch.await();
} catch (InterruptedException ignored) {
}
return new MetaActionsListeningRecord(accepting, phase);
}
private Runnable buildMataActionTask(MetaAction metaAction, Phaser phaser, ExecutableAction executableAction) {
phaser.register();
return () -> {
val actionKey = metaAction.getKey();
try {
executeMetaActionWithRetry(metaAction, executableAction);
} catch (Exception e) {
log.error("Action executing failed: {}", actionKey, e);
} finally {
phaser.arriveAndDeregister();
}
};
}
private void executeMetaActionWithRetry(MetaAction metaAction, ExecutableAction actionData) {
AtomicReference<String> failureReason = new AtomicReference<>("参数提取失败");
int executingStage = actionData.getExecutingStage();
boolean succeeded = false;
for (int attempt = 1; attempt <= MAX_EXTRACTOR_ATTEMPTS; attempt++) {
val result = metaAction.getResult();
result.reset();
metaAction.getParams().clear();
Result<ExtractorInput> extractorInputResult = assemblyHelper.buildExtractorInput(
metaAction.getKey(),
actionData.getUuid(),
actionData.getDescription(),
resolveCurrentStageDescription(actionData, executingStage)
);
AgentRuntimeException exception = extractorInputResult.exceptionOrNull();
if (exception != null) {
failureReason.set(exception.getMessage());
break;
}
ExtractorInput extractorInput = extractorInputResult.getOrThrow();
Result<ExtractorResult> extractorResultWrapped = paramsExtractor.execute(extractorInput).onFailure(exp -> failureReason.set(exp.getLocalizedMessage()));
if (extractorResultWrapped.exceptionOrNull() != null) {
continue;
}
ExtractorResult extractorResult = extractorResultWrapped.getOrThrow();
if (!extractorResult.isOk()) {
failureReason.set(buildAttemptFailureReason("参数提取失败", null));
continue;
}
metaAction.getParams().putAll(toMetaActionParams(extractorResult.getParams()));
try {
runnerClient.submit(metaAction);
} catch (Exception e) {
failureReason.set(buildAttemptFailureReason("行动执行异常", e.getLocalizedMessage()));
continue;
}
if (result.getStatus() == MetaAction.Result.Status.SUCCESS) {
succeeded = true;
break;
}
failureReason.set(buildAttemptFailureReason("行动执行失败", result.getData()));
}
if (!succeeded) {
metaAction.getResult().setStatus(MetaAction.Result.Status.FAILED);
metaAction.getResult().setData(failureReason.get());
}
appendHistoryAction(actionData, executingStage, metaAction);
}
private Map<String, Object> toMetaActionParams(List<ExtractorResult.ParamEntry> params) {
if (params == null || params.isEmpty()) {
return Map.of();
}
Map<String, Object> converted = new LinkedHashMap<>();
for (ExtractorResult.ParamEntry entry : params) {
if (entry == null) {
continue;
}
String name = entry.getName();
if (name == null || name.isBlank()) {
continue;
}
converted.put(name, entry.getValue());
}
return converted;
}
private void appendHistoryAction(ExecutableAction actionData, int executingStage, MetaAction metaAction) {
HistoryAction historyAction = new HistoryAction(
metaAction.getKey(),
resolveHistoryDescription(metaAction.getKey()),
metaAction.getResult().getData()
);
actionData.getHistory()
.computeIfAbsent(executingStage, integer -> new ArrayList<>())
.add(historyAction);
}
private RecognizerTaskRecord startRecognizerIfNeeded(ExecutableAction executableAction, Phaser phaser) {
if (!shouldRunCorrectionRecognizer(executableAction)) {
return RecognizerTaskRecord.disabled();
}
val recognizerInput = assemblyHelper.buildCorrectorInput(executableAction, CorrectorInput.CheckMode.PROCESS_CHECK);
val task = buildRecognizerTask(recognizerInput, phaser);
Future<RecognizerResult> future = virtualExecutor.submit(task);
return new RecognizerTaskRecord(true, future);
}
private Callable<RecognizerResult> buildRecognizerTask(CorrectorInput input, Phaser phaser) {
phaser.register();
return () -> {
try {
return actionCorrectionRecognizer.execute(input)
.getOrDefault(new RecognizerResult());
} finally {
phaser.arriveAndDeregister();
}
};
}
private RecognizerResult resolveRecognizerResult(RecognizerTaskRecord record) {
if (record == null || !record.enabled() || record.future() == null) {
return null;
}
try {
if (!record.future().isDone()) {
return null;
}
return record.future().get();
} catch (Exception e) {
return null;
}
}
private String resolveCurrentStageDescription(ExecutableAction executableAction, int executingStage) {
return executableAction.getStageDescriptions().get(executingStage);
}
private String buildAttemptFailureReason(String prefix, String detail) {
if (detail == null || detail.isBlank()) {
return prefix;
}
return prefix + ": " + detail;
}
private boolean hasFailedMetaAction(List<MetaAction> metaActions) {
return metaActions.stream().anyMatch(metaAction -> metaAction.getResult().getStatus() == MetaAction.Result.Status.FAILED);
}
private boolean shouldRunCorrectionRecognizer(ExecutableAction executableAction) {
val orderedStages = new ArrayList<>(executableAction.getActionChain().keySet());
orderedStages.sort(Integer::compareTo);
int totalStages = orderedStages.size();
if (totalStages < 3) {
return false;
}
int stageIndex = orderedStages.indexOf(executableAction.getExecutingStage());
if (stageIndex < 0) {
return false;
}
if (stageIndex == totalStages - 1) {
return true;
}
return stageIndex >= 2 && (stageIndex - 2) % 2 == 0;
}
private void normalizeExecutingStage(ExecutableAction executableAction, Map<Integer, List<MetaAction>> actionChain) {
Integer firstStage = actionChain.keySet().stream()
.min(Integer::compareTo)
.orElse(null);
if (firstStage == null) {
return;
}
if (actionChain.containsKey(executableAction.getExecutingStage())) {
return;
}
if (executableAction.getStatus() == Action.Status.EXECUTING) {
resetExecutableActionForReplay(executableAction);
}
executableAction.setExecutingStage(firstStage);
}
private void resetExecutableActionForReplay(ExecutableAction executableAction) {
executableAction.getHistory().clear();
executableAction.getActionChain().values().forEach(metaActions -> metaActions.forEach(metaAction -> {
metaAction.getParams().clear();
metaAction.getResult().reset();
}));
if (hasExecutableResult(executableAction)) {
executableAction.setResult("");
}
}
private void ensureExecutableResult(ExecutableAction executableAction, boolean failed, String failureReason) {
if (hasExecutableResult(executableAction)) {
return;
}
executableAction.setResult(resolveExecutableResult(executableAction, failed, failureReason));
}
private String resolveExecutableResult(ExecutableAction executableAction, boolean failed, String failureReason) {
String extracted = extractLastMetaActionResult(executableAction);
if (extracted != null && !extracted.isBlank()) {
return extracted;
}
if (!failed) {
return "行动执行成功";
}
if (failureReason != null && !failureReason.isBlank()) {
return "行动执行失败: " + failureReason;
}
return "行动执行失败";
}
private String extractLastMetaActionResult(ExecutableAction executableAction) {
if (!executableAction.getHistory().isEmpty()) {
Integer lastStage = executableAction.getHistory().keySet().stream()
.max(Integer::compareTo)
.orElse(null);
if (lastStage != null) {
List<HistoryAction> historyActions = executableAction.getHistory().get(lastStage);
if (historyActions != null && !historyActions.isEmpty()) {
String result = historyActions.getLast().result();
if (result != null && !result.isBlank()) {
return result;
}
}
}
}
if (!executableAction.getActionChain().isEmpty()) {
Integer lastStage = executableAction.getActionChain().keySet().stream()
.max(Integer::compareTo)
.orElse(null);
if (lastStage != null) {
List<MetaAction> metaActions = executableAction.getActionChain().get(lastStage);
if (metaActions != null && !metaActions.isEmpty()) {
String result = metaActions.getLast().getResult().getData();
if (result != null && !result.isBlank()) {
return result;
}
}
}
}
return null;
}
private boolean hasExecutableResult(ExecutableAction executableAction) {
try {
String result = executableAction.getResult();
return result != null && !result.isBlank();
} catch (Exception e) {
return false;
}
}
private String resolveHistoryDescription(String actionKey) {
return actionCapability.loadMetaActionInfo(actionKey)
.fold(
metaActionInfo -> metaActionInfo.getDescription().isBlank() ? actionKey : metaActionInfo.getDescription(),
exception -> actionKey
);
}
private enum StageSelectionType {
CONTINUE,
STOP,
RETURN
}
private record StageSelection(StageSelectionType type, List<MetaAction> metaActions) {
private static StageSelection continueWith(List<MetaAction> metaActions) {
return new StageSelection(StageSelectionType.CONTINUE, metaActions);
}
private static StageSelection stop() {
return new StageSelection(StageSelectionType.STOP, null);
}
private static StageSelection returnNow() {
return new StageSelection(StageSelectionType.RETURN, null);
}
private boolean shouldStop() {
return type == StageSelectionType.STOP;
}
private boolean shouldReturn() {
return type == StageSelectionType.RETURN;
}
}
private record StageExecution(RecognizerTaskRecord recognizerRecord, List<MetaAction> metaActions, boolean closed) {
private static StageExecution completed(RecognizerTaskRecord recognizerRecord, List<MetaAction> metaActions) {
return new StageExecution(recognizerRecord, metaActions, false);
}
private static StageExecution closed(RecognizerTaskRecord recognizerRecord, List<MetaAction> metaActions) {
return new StageExecution(recognizerRecord, metaActions, true);
}
}
private record MetaActionsListeningRecord(AtomicBoolean accepting, int phase) {
}
private record RecognizerTaskRecord(boolean enabled, Future<RecognizerResult> future) {
private static RecognizerTaskRecord disabled() {
return new RecognizerTaskRecord(false, null);
}
}
private static final class StageCursor {
private final ExecutableAction executableAction;
private final Map<Integer, List<MetaAction>> actionChain;
private int stageCount;
private boolean executingStageUpdated;
private boolean stageCountUpdated;
private StageCursor(ExecutableAction executableAction, Map<Integer, List<MetaAction>> actionChain) {
this.executableAction = executableAction;
this.actionChain = actionChain;
}
private void init() {
val orderList = new ArrayList<>(actionChain.keySet());
orderList.sort(Integer::compareTo);
stageCount = orderList.indexOf(executableAction.getExecutingStage());
update();
}
private void requestAdvance() {
if (!stageCountUpdated) {
stageCount++;
stageCountUpdated = true;
}
if (stageCount < actionChain.size() && !executingStageUpdated) {
update();
executingStageUpdated = true;
}
}
private boolean next() {
executingStageUpdated = false;
stageCountUpdated = false;
return stageCount < actionChain.size();
}
private void refresh() {
val orderList = new ArrayList<>(actionChain.keySet());
orderList.sort(Integer::compareTo);
stageCount = orderList.indexOf(executableAction.getExecutingStage());
if (stageCount < 0) {
stageCount = 0;
}
executingStageUpdated = false;
stageCountUpdated = false;
}
private void update() {
val orderList = new ArrayList<>(actionChain.keySet());
orderList.sort(Integer::compareTo);
executableAction.setExecutingStage(orderList.get(stageCount));
}
}
@SuppressWarnings("InnerClassMayBeStatic")
private class AssemblyHelper {
private AssemblyHelper() {
}
private Result<ExtractorInput> buildExtractorInput(String actionKey, @NotNull String uuid, @NotNull String description, String currentStageDescription) {
return actionCapability.loadMetaActionInfo(actionKey).fold(
metaActionInfo -> {
ExtractorInput input = new ExtractorInput();
input.setMetaActionInfo(metaActionInfo);
input.setTargetActionId(uuid);
input.setTargetActionDesc(description);
input.setCurrentStageDesc(currentStageDescription);
return Result.success(input);
},
Result::failure
);
}
private CorrectorInput buildCorrectorInput(ExecutableAction executableAction, CorrectorInput.CheckMode checkMode) {
Map<Integer, List<CorrectorInput.ActionChainItem>> overview = new LinkedHashMap<>();
executableAction.getActionChain().forEach((stage, list) -> {
List<CorrectorInput.ActionChainItem> overviewItems = list.stream()
.map(metaAction -> new CorrectorInput.ActionChainItem(
metaAction.getKey(),
resolveHistoryDescription(metaAction.getKey()),
executableAction.getStageDescriptions().get(stage),
metaAction.getResult().getStatus().name().toLowerCase(Locale.ROOT),
metaAction.getResult().getData()
))
.toList();
overview.put(stage, overviewItems);
});
return CorrectorInput.builder()
.checkMode(checkMode)
.tendency(executableAction.getTendency())
.source(executableAction.getSource())
.reason(executableAction.getReason())
.description(executableAction.getDescription())
.actionId(executableAction.getUuid())
.actionChainOverview(overview)
.build();
}
}
}
@@ -1,452 +0,0 @@
package work.slhaf.partner.module.action.executor;
import kotlin.Unit;
import org.jetbrains.annotations.NotNull;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import work.slhaf.partner.core.action.entity.*;
import work.slhaf.partner.core.action.entity.intervention.MetaIntervention;
import work.slhaf.partner.core.cognition.context.BlockContent;
import work.slhaf.partner.core.cognition.context.ContextBlock;
import work.slhaf.partner.core.cognition.context.ContextWorkspace;
import work.slhaf.partner.module.StateHintContent;
import work.slhaf.partner.module.action.executor.entity.HistoryAction;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
class ExecutingActionBlockManager {
private static final String SOURCE = "action_executor";
private final ContextWorkspace contextWorkspace;
ExecutingActionBlockManager(ContextWorkspace contextWorkspace) {
this.contextWorkspace = contextWorkspace;
}
void emitActionRecoveredBlock(Set<ExecutableAction> recoveredActions) {
Set<ExecutableActionSnapshot> snapshots = recoveredActions.stream().map(ExecutableAction::snapshot).collect(Collectors.toSet());
String blockName = "actions_recovered";
String emittedAt = emittedAt();
String event = "actions_recovered";
contextWorkspace.register(new ContextBlock(
buildExecutingActionRecoveredFullBlock(snapshots, blockName, emittedAt, event),
buildExecutingActionRecoveredCompactBlock(snapshots, blockName, emittedAt, event),
buildExecutingActionRecoveredAbstractBlock(snapshots, blockName, event),
Set.of(ContextBlock.FocusedDomain.ACTION),
100,
12,
1
));
}
private @NotNull BlockContent buildExecutingActionRecoveredAbstractBlock(Set<ExecutableActionSnapshot> recoveredExecutingActions, String blockName, String event) {
return new ActionBlockContent(blockName, SOURCE) {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
appendEventElement(document, root, event);
appendTextElement(document, root, "abstract", recoveredExecutingActions.size() + " executing actions recovered.");
}
};
}
private @NotNull BlockContent buildExecutingActionRecoveredCompactBlock(Set<ExecutableActionSnapshot> recoveredExecutingActions, String blockName, String emittedAt, String event) {
return new ActionBlockContent(blockName, SOURCE) {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
appendEventElement(document, root, event);
appendTextElement(document, root, "emitted_at", emittedAt);
appendListElement(document, root, "recovered_actions", "action", recoveredExecutingActions, (actionElement, action) -> {
appendTextElement(document, actionElement, "description", action.getDescription());
return Unit.INSTANCE;
});
}
};
}
private @NotNull BlockContent buildExecutingActionRecoveredFullBlock(Set<ExecutableActionSnapshot> recoveredExecutingActions, String blockName, String emittedAt, String event) {
return new ActionBlockContent(blockName, SOURCE) {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
appendEventElement(document, root, event);
appendTextElement(document, root, "emitted_at", emittedAt);
appendListElement(document, root, "recovered_actions", "action", recoveredExecutingActions, (actionElement, action) -> {
appendTextElement(document, actionElement, "description", action.getDescription());
appendTextElement(document, actionElement, "source", action.getSource());
appendTextElement(document, actionElement, "executing_stage", action.getExecutingStage());
return Unit.INSTANCE;
});
}
};
}
void emitStateActionTriggeredBlock(StateAction stateAction) {
StateActionSnapshot snapshot = stateAction.snapshot();
String blockName = buildBlockName(stateAction.getUuid());
String emittedAt = emittedAt();
String event = "state_action_triggered";
contextWorkspace.register(new ContextBlock(
buildStateActionFullBlock(snapshot, blockName, emittedAt, event),
buildStateActionCompactBlock(snapshot, blockName, emittedAt, event),
buildStateActionAbstractBlock(snapshot, blockName, event),
Set.of(ContextBlock.FocusedDomain.ACTION),
90,
30,
10
));
}
private @NotNull BlockContent buildStateActionAbstractBlock(StateActionSnapshot snapshot, String blockName, String event) {
return new ActionBlockContent(blockName, SOURCE) {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
appendEventElement(document, root, event);
appendTextElement(document, root, "description", snapshot.getDescription());
}
};
}
private @NotNull BlockContent buildStateActionCompactBlock(StateActionSnapshot snapshot, String blockName, String emittedAt, String event) {
return new ActionBlockContent(blockName, SOURCE) {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
appendEventElement(document, root, event);
appendTextElement(document, root, "emitted_at", emittedAt);
appendTextElement(document, root, "reason", snapshot.getReason());
appendTextElement(document, root, "description", snapshot.getDescription());
}
};
}
private @NotNull BlockContent buildStateActionFullBlock(StateActionSnapshot snapshot, String blockName, String emittedAt, String event) {
return new ActionBlockContent(blockName, SOURCE) {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
appendEventElement(document, root, event);
appendTextElement(document, root, "emitted_at", emittedAt);
appendTextElement(document, root, "reason", snapshot.getReason());
appendTextElement(document, root, "description", snapshot.getDescription());
appendTextElement(document, root, "source", snapshot.getSource());
appendTextElement(document, root, "schedule_type", snapshot.getScheduleType().name().toLowerCase(Locale.ROOT));
}
};
}
void emitActionLaunchedBlock(ExecutableAction action) {
ExecutableActionSnapshot snapshot = action.snapshot();
String blockName = buildBlockName(action.getUuid());
String emittedAt = emittedAt();
String event = "executable_action_launched";
contextWorkspace.register(new ContextBlock(
buildActionLaunchedFullBlock(snapshot, blockName, event, emittedAt),
buildActionCompactBlock(snapshot, blockName, event, emittedAt),
buildActionAbstractBlock(snapshot, blockName, event),
Set.of(ContextBlock.FocusedDomain.ACTION),
28,
6,
18
));
}
private @NotNull BlockContent buildActionAbstractBlock(ExecutableActionSnapshot snapshot, String blockName, String event) {
return new ActionBlockContent(blockName, SOURCE) {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
appendEventElement(document, root, event);
appendTextElement(document, root, "description", snapshot.getDescription());
}
};
}
private @NotNull BlockContent buildActionCompactBlock(ExecutableActionSnapshot snapshot, String blockName, String event, String emittedAt) {
return new ActionBlockContent(blockName, SOURCE) {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
appendEventElement(document, root, event);
appendTextElement(document, root, "emitted_at", emittedAt);
appendTextElement(document, root, "primary_action_chain_size", snapshot.getActionChainSize());
appendTextElement(document, root, "reason", snapshot.getReason());
appendTextElement(document, root, "description", snapshot.getDescription());
Schedulable.ScheduleType scheduleType = snapshot.getScheduleType();
String scheduleContent = snapshot.getScheduleContent();
if (scheduleType != null && scheduleContent != null) {
appendChildElement(document, root, "schedule_info", (element) -> {
appendTextElement(document, element, "schedule_type", scheduleType.name().toLowerCase(Locale.ROOT));
appendTextElement(document, element, "schedule_content", scheduleContent);
return Unit.INSTANCE;
});
}
}
};
}
private @NotNull BlockContent buildActionLaunchedFullBlock(ExecutableActionSnapshot snapshot, String blockName, String event, String emittedAt) {
return new ActionBlockContent(blockName, SOURCE) {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
appendEventElement(document, root, event);
appendTextElement(document, root, "emitted_at", emittedAt);
appendTextElement(document, root, "primary_action_chain_size", snapshot.getActionChainSize());
appendTextElement(document, root, "reason", snapshot.getReason());
appendTextElement(document, root, "description", snapshot.getDescription());
appendTextElement(document, root, "source", snapshot.getSource());
appendTextElement(document, root, "tendency", snapshot.getTendency());
Schedulable.ScheduleType scheduleType = snapshot.getScheduleType();
String scheduleContent = snapshot.getScheduleContent();
if (scheduleType != null && scheduleContent != null) {
appendChildElement(document, root, "schedule_info", (element) -> {
appendTextElement(document, element, "schedule_type", scheduleType.name().toLowerCase(Locale.ROOT));
appendTextElement(document, element, "schedule_content", scheduleContent);
return Unit.INSTANCE;
});
}
}
};
}
void emitActionStageSettledBlock(ExecutableAction action) {
ExecutableActionSnapshot snapshot = action.snapshot();
String blockName = buildBlockName(action.getUuid());
String emittedAt = emittedAt();
String event = "executable_action_stage_settled";
contextWorkspace.register(new ContextBlock(
buildActionStageFullBlock(snapshot, blockName, emittedAt, event),
buildActionStageCompactBlock(snapshot, blockName, emittedAt, event),
buildActionStageAbstractBlock(snapshot, blockName, event),
Set.of(ContextBlock.FocusedDomain.ACTION),
55,
10,
12
));
}
private @NotNull BlockContent buildActionStageAbstractBlock(ExecutableActionSnapshot snapshot, String blockName, String event) {
int settledStage = snapshot.getExecutingStage();
List<HistoryAction> history = resolveStageHistory(snapshot, settledStage);
return new ActionBlockContent(blockName, SOURCE) {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
appendEventElement(document, root, event);
appendTextElement(document, root, "abstract", history.size() + " meta actions are resolved in stage " + settledStage);
}
};
}
private @NotNull BlockContent buildActionStageCompactBlock(ExecutableActionSnapshot snapshot, String blockName, String emittedAt, String event) {
int settledStage = snapshot.getExecutingStage();
List<HistoryAction> history = resolveStageHistory(snapshot, settledStage);
return new ActionBlockContent(blockName, SOURCE) {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
appendEventElement(document, root, event);
appendTextElement(document, root, "emitted_at", emittedAt);
appendTextElement(document, root, "action_chain_size", snapshot.getActionChainSize());
appendTextElement(document, root, "abstract", history.size() + " meta actions are resolved in stage " + settledStage);
}
};
}
private @NotNull BlockContent buildActionStageFullBlock(ExecutableActionSnapshot snapshot, String blockName, String emittedAt, String event) {
int settledStage = snapshot.getExecutingStage();
List<HistoryAction> history = resolveStageHistory(snapshot, settledStage);
return new ActionBlockContent(blockName, SOURCE) {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
appendEventElement(document, root, event);
appendTextElement(document, root, "emitted_at", emittedAt);
appendTextElement(document, root, "action_chain_size", snapshot.getActionChainSize());
appendTextElement(document, root, "settled_action_chain_stage", settledStage);
appendListElement(document,
root,
"settled_meta_actions",
"meta_action",
history.subList(0, Math.min(3, history.size())),
(item, action) -> {
String primaryResult = action.result();
String result = primaryResult.length() > 160 ? primaryResult.substring(0, 160) : primaryResult;
appendTextElement(document, item, "action_key", action.actionKey());
appendTextElement(document, item, "result", result);
return Unit.INSTANCE;
}
);
}
};
}
void emitActionCorrectionBlock(ExecutableAction action, String reason, List<MetaIntervention> interventions) {
ExecutableActionSnapshot snapshot = action.snapshot();
String blockName = buildBlockName(action.getUuid());
String emittedAt = emittedAt();
String event = "executable_action_correction_triggered";
contextWorkspace.register(new ContextBlock(
buildActionCorrectionFullBlock(snapshot, reason, interventions, blockName, emittedAt, event),
buildActionCorrectionCompactBlock(snapshot, reason, interventions, blockName, emittedAt, event),
buildActionCorrectionAbstractBlock(snapshot, interventions, blockName, event),
Set.of(ContextBlock.FocusedDomain.ACTION),
22,
5,
22
));
}
private List<HistoryAction> resolveStageHistory(ExecutableActionSnapshot snapshot, int settledStage) {
List<HistoryAction> history = snapshot.getHistory().get(settledStage);
return history == null ? List.of() : history;
}
private @NotNull BlockContent buildActionCorrectionAbstractBlock(ExecutableActionSnapshot snapshot, List<MetaIntervention> interventions, String blockName, String event) {
return new ActionBlockContent(blockName, SOURCE) {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
appendEventElement(document, root, event);
appendTextElement(document, root, "abstract", interventions.size() + " interventions occurred after stage " + snapshot.getExecutingStage());
}
};
}
private @NotNull BlockContent buildActionCorrectionCompactBlock(ExecutableActionSnapshot snapshot, String reason, List<MetaIntervention> interventions, String blockName, String emittedAt, String event) {
return new ActionBlockContent(blockName, SOURCE, BlockContent.Urgency.HIGH) {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
Set<Integer> affectedStage = interventions.stream().map(MetaIntervention::getOrder).collect(Collectors.toSet());
appendEventElement(document, root, event);
appendTextElement(document, root, "emitted_at", emittedAt);
appendTextElement(document, root, "action_chain_size", snapshot.getActionChainSize());
appendTextElement(document, root, "abstract", interventions.size() + " interventions occurred after stage " + snapshot.getExecutingStage() + ", stage: " + affectedStage + " are affected");
appendTextElement(document, root, "correction_reason", reason);
}
};
}
private @NotNull BlockContent buildActionCorrectionFullBlock(ExecutableActionSnapshot snapshot, String reason, List<MetaIntervention> interventions, String blockName, String emittedAt, String event) {
return new ActionBlockContent(blockName, SOURCE, BlockContent.Urgency.HIGH) {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
appendEventElement(document, root, event);
appendTextElement(document, root, "emitted_at", emittedAt);
appendTextElement(document, root, "action_chain_size", snapshot.getActionChainSize());
appendTextElement(document, root, "correction_reason", reason);
appendListElement(document,
root,
"applied_interventions",
"intervention",
interventions,
(item, intervention) -> {
appendTextElement(document, item, "type", intervention.getType().name().toLowerCase(Locale.ROOT));
appendTextElement(document, item, "affected_stage", intervention.getOrder());
appendTextElement(document, item, "applied_action_key_set", intervention.getActions());
return Unit.INSTANCE;
}
);
}
};
}
void emitActionFinishedBlock(ExecutableAction action) {
ExecutableActionSnapshot snapshot = action.snapshot();
String blockName = buildBlockName(action.getUuid());
String emittedAt = emittedAt();
String event = "executable_action_finished";
contextWorkspace.register(new ContextBlock(
buildActionFinishedFullBlock(snapshot, blockName, emittedAt, event),
buildActionFinishedFullBlock(snapshot, blockName, emittedAt, event),
buildActionFinishedAbstractBlock(snapshot, blockName, event),
Set.of(ContextBlock.FocusedDomain.ACTION),
35,
14,
24
));
contextWorkspace.register(buildActionFinishedStateHintBlock(snapshot, emittedAt, event));
}
private @NotNull BlockContent buildActionFinishedAbstractBlock(ExecutableActionSnapshot snapshot, String blockName, String event) {
return new ActionBlockContent(blockName, SOURCE) {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
appendEventElement(document, root, event);
appendTextElement(document, root, "final_status", snapshot.getStatus().name().toLowerCase(Locale.ROOT));
}
};
}
private @NotNull BlockContent buildActionFinishedFullBlock(ExecutableActionSnapshot snapshot, String blockName, String emittedAt, String event) {
return new ActionBlockContent(blockName, SOURCE, BlockContent.Urgency.HIGH) {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
appendEventElement(document, root, event);
appendTextElement(document, root, "emitted_at", emittedAt);
appendTextElement(document, root, "final_status", snapshot.getStatus().name().toLowerCase(Locale.ROOT));
appendTextElement(document, root, "result", snapshot.getResult());
}
};
}
private @NotNull ContextBlock buildActionFinishedStateHintBlock(ExecutableActionSnapshot snapshot, String emittedAt, String event) {
return StateHintContent.createBlock(new StateHintContent(
SOURCE,
"An executable action has finished."
) {
@Override
public void fillStateContent(@NotNull Document document, @NotNull Element stateElement) {
appendTextElement(document, stateElement, "event", event);
appendTextElement(document, stateElement, "emitted_at", emittedAt);
appendTextElement(document, stateElement, "action_id", snapshot.getUuid());
appendTextElement(document, stateElement, "final_status", snapshot.getStatus().name().toLowerCase(Locale.ROOT));
appendTextElement(document, stateElement, "description", snapshot.getDescription());
appendTextElement(document, stateElement, "result", snapshot.getResult());
appendTextElement(document, stateElement, "source_user", snapshot.getSource());
}
});
}
private String emittedAt() {
return ZonedDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
private String buildBlockName(String actionId) {
return "executing_action-" + actionId;
}
private static abstract class ActionBlockContent extends BlockContent {
private ActionBlockContent(@NotNull String blockName, @NotNull String source, @NotNull Urgency urgency) {
super(blockName, source, urgency);
}
private ActionBlockContent(@NotNull String blockName, @NotNull String source) {
super(blockName, source);
}
protected void appendEventElement(@NotNull Document document, @NotNull Element root, String event) {
appendTextElement(document, root, "event", event);
}
}
}
@@ -1,123 +0,0 @@
package work.slhaf.partner.module.action.executor;
import kotlin.Unit;
import org.jetbrains.annotations.NotNull;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import work.slhaf.partner.core.action.entity.MetaActionInfo;
import work.slhaf.partner.core.cognition.CognitionCapability;
import work.slhaf.partner.core.cognition.context.ContextBlock;
import work.slhaf.partner.framework.agent.factory.capability.annotation.InjectCapability;
import work.slhaf.partner.framework.agent.factory.component.abstracts.AbstractAgentModule;
import work.slhaf.partner.framework.agent.model.ActivateModel;
import work.slhaf.partner.framework.agent.model.pojo.Message;
import work.slhaf.partner.framework.agent.support.Result;
import work.slhaf.partner.module.TaskBlock;
import work.slhaf.partner.module.action.executor.entity.ExtractorInput;
import work.slhaf.partner.module.action.executor.entity.ExtractorResult;
import java.util.List;
/**
* 负责依据输入内容进行行动单元的参数信息提取
*/
public class ParamsExtractor extends AbstractAgentModule.Sub<ExtractorInput, Result<ExtractorResult>> implements ActivateModel {
private static final String MODULE_PROMPT = """
你负责为指定行动提取参数信息。
你会收到:
- 一条结构化上下文消息,其中可能包含当前行动相关状态、近期交流轨迹、以及活跃记忆切片;
- 一条任务消息,其中包含:
- target_action:本次参数提取所面向的目标行动,用于帮助你判断上下文中哪些内容与当前提取直接相关;其中 current_stage_description 表示本次 MetaAction 所属阶段的具体执行目标,应优先用于约束参数提取;
- meta_action_info:该行动对应的说明,以及允许提取的参数列表。每个 <param name="..."> 节点的文本内容表示该参数的含义或期望内容。
你的任务:
- 根据当前上下文与目标行动信息,提取本次行动所需的参数;
- 只提取能够从当前输入、近期会话、相关行动历史或明确上下文中得到支持的参数;
- 若当前信息不足以支持可靠提取,则返回 ok=false,而不是猜测或编造参数。
提取原则:
- target_action 用于帮助你在上下文中定位当前面对的是哪一个行动,不要把无关行动历史混入当前参数提取。
- action 域中的执行中行动块及其阶段历史,可作为理解当前行动推进位置、已知条件与已出现参数的参考。
- communication 域主要用于理解用户最近表达的条件、补充、修正、确认或否定信息。
- memory 域只在与当前目标行动明显相关时作为辅助参考使用。
- params 中只能填写 meta_action_info.params 中声明过的参数名,不要编造不存在的参数。
- 每个参数值都应尽量保持为简洁、明确、可直接使用的文本,不要输出冗长解释。
- 若某个参数无法从现有信息中可靠确定,就不要填写它。
关于输出:
- ok=true 表示你已经提取出了一组可用参数;不要求必须填满全部参数,但结果应足以支持后续执行继续推进。
- ok=false 表示当前信息不足以形成可靠参数结果。
- params 为参数项列表;每一项都必须包含:
- name: 参数名
- value: 参数值文本
- 不要把 params 输出为对象、Map 或其他结构,只能输出 ParamEntry 列表。
- 不要输出结构之外的解释、说明或额外文本。
输出要求:
- 严格按照 ExtractorResult 对应结构输出。
""";
@InjectCapability
private CognitionCapability cognitionCapability;
@Override
protected @NotNull Result<ExtractorResult> doExecute(ExtractorInput input) {
List<Message> messages = List.of(
resolveContextMessage(),
resolveTaskMessage(input)
);
return formattedChat(messages, ExtractorResult.class);
}
private Message resolveTaskMessage(ExtractorInput input) {
return new TaskBlock() {
@Override
protected void fillXml(@NotNull Document document, @NotNull Element root) {
appendChildElement(document, root, "target_action", block -> {
appendTextElement(document, block, "uuid", input.getTargetActionId());
appendTextElement(document, block, "description", input.getTargetActionDesc());
if (input.getCurrentStageDesc() != null && !input.getCurrentStageDesc().isBlank()) {
appendTextElement(document, block, "current_stage_description", input.getCurrentStageDesc());
}
return Unit.INSTANCE;
});
appendChildElement(document, root, "meta_action_info", element -> {
MetaActionInfo info = input.getMetaActionInfo();
appendTextElement(document, element, "description", info.getDescription());
appendListElement(document, element, "params", "param", info.getParams().entrySet(), (item, param) -> {
item.setAttribute("name", param.getKey());
item.setTextContent(param.getValue());
return Unit.INSTANCE;
});
return Unit.INSTANCE;
});
}
}.encodeToMessage();
}
private Message resolveContextMessage() {
return cognitionCapability.contextWorkspace()
.resolve(List.of(
ContextBlock.FocusedDomain.ACTION,
ContextBlock.FocusedDomain.PERCEIVE,
ContextBlock.FocusedDomain.COMMUNICATION,
ContextBlock.FocusedDomain.MEMORY
))
.encodeToMessage();
}
@Override
@NotNull
public List<Message> modulePrompt() {
return List.of(new Message(Message.Character.SYSTEM, MODULE_PROMPT));
}
@NotNull
@Override
public String modelKey() {
return "params_extractor";
}
}
@@ -1,36 +0,0 @@
package work.slhaf.partner.module.action.executor.entity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import java.util.List;
import java.util.Map;
@Data
@Builder
public class CorrectorInput {
private CheckMode checkMode;
private String tendency;
private String source;
private String reason;
private String description;
private String actionId;
private Map<Integer, List<ActionChainItem>> actionChainOverview;
public enum CheckMode {
PROCESS_CHECK,
FINAL_CHECK
}
@Data
@AllArgsConstructor
public static class ActionChainItem {
private String actionKey;
private String description;
private String stageDescription;
private String status;
private String result;
}
}
@@ -1,12 +0,0 @@
package work.slhaf.partner.module.action.executor.entity;
import lombok.Data;
import work.slhaf.partner.core.action.entity.intervention.MetaIntervention;
import java.util.List;
@Data
public class CorrectorResult {
private List<MetaIntervention> metaInterventionList;
private String correctionReason;
}
@@ -1,28 +0,0 @@
package work.slhaf.partner.module.action.executor.entity;
import lombok.Data;
import work.slhaf.partner.core.action.entity.MetaActionInfo;
@Data
public class ExtractorInput {
/**
* 目标行动数据的 uuid
*/
private String targetActionId;
/**
* 目标行动的 description
*/
private String targetActionDesc;
/**
* 当前执行阶段的 description
*/
private String currentStageDesc;
/**
* 目标 MetaActionInfo
*/
private MetaActionInfo metaActionInfo;
}
@@ -1,21 +0,0 @@
package work.slhaf.partner.module.action.executor.entity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
public class ExtractorResult {
private boolean ok;
private List<ParamEntry> params;
@Data
@AllArgsConstructor
@NoArgsConstructor
public static class ParamEntry {
private String name;
private String value;
}
}
@@ -1,4 +0,0 @@
package work.slhaf.partner.module.action.executor.entity;
public record HistoryAction(String actionKey, String description, String result) {
}
@@ -1,9 +0,0 @@
package work.slhaf.partner.module.action.executor.entity;
import lombok.Data;
@Data
public class RecognizerResult {
private boolean needCorrection = false;
private String reason;
}

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